rename packages, refactor out tailwind compiler,
This commit is contained in:
602
go/webbundler/hmr_server.go
Normal file
602
go/webbundler/hmr_server.go
Normal file
@@ -0,0 +1,602 @@
|
||||
//go:build dev
|
||||
|
||||
package webbundler
|
||||
|
||||
// The development HMR server: it serves the SPA source tree as unbundled native
|
||||
// ES modules (transformed on the fly), tracks the module import graph, watches
|
||||
// the filesystem, and pushes hot-update / reload messages to the browser over a
|
||||
// WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing
|
||||
// a non-boundary module bubbles up to a full page reload.
|
||||
//
|
||||
// This is the reason webbundler grew a `dev` build tag: the whole HMR
|
||||
// subsystem (this file, hmr_ws.go, hmr_vendor.go, hmr_client.go, hmr_watch.go,
|
||||
// and the solid-refresh bits of jsx.go) compiles only under `-tags dev`, so the
|
||||
// production server and the plain `cmd/bundle` build carry none of it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// devURLPrefix roots the served source tree: GET /@src/<rel> serves the
|
||||
// transformed module frontend/src/<rel>.
|
||||
const (
|
||||
srcURLPrefix = "/@src/" // transformed app source modules
|
||||
kitURLPrefix = "/@kit/" // transformed shared-kit modules (kjol web tree)
|
||||
assetURLPrefix = "/@url/" // `?url` shim: a module whose default export is the file URL
|
||||
fsURLPrefix = "/@fs/" // raw file passthrough (the URL the shim points at)
|
||||
)
|
||||
|
||||
type devServer struct {
|
||||
hub *hub
|
||||
frontend string // abs app frontend/
|
||||
srcRoot string // abs app frontend/src
|
||||
kitRoot string // abs kjol web (@ui/@kjol root); == frontend in single-tree
|
||||
genRoot string // abs app generated dir (@appgen)
|
||||
vendorDirs []string // abs vendor roots (kjol runtime first, then app vendor)
|
||||
output string // abs wwwroot/
|
||||
|
||||
graph *moduleGraph
|
||||
|
||||
importMapJSON string
|
||||
vendorEntrypoints map[string]string
|
||||
|
||||
vendorMu sync.Mutex
|
||||
vendorCache map[string][]byte // /@vendor/<spec>.js -> bundled bytes
|
||||
|
||||
cssTrigger chan struct{} // coalesced CSS rebuild requests (buffered, size 1)
|
||||
}
|
||||
|
||||
// StartDevHMR registers the dev routes on mux, launches the filesystem watcher,
|
||||
// and returns the import map (JSON object body) the SPA shell must inline so bare
|
||||
// specifiers resolve to the single vendored copies. Called only from cmd/server's
|
||||
// `dev`-tagged shim.
|
||||
func StartDevHMR(mux *http.ServeMux, cfg Config) (importMap string, err error) {
|
||||
Configure(cfg)
|
||||
d, err := newDevServer()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d.register(mux)
|
||||
go d.watch()
|
||||
|
||||
fmt.Println("HMR dev server: serving native-ESM source from /@src/, WebSocket at /@hmr/ws")
|
||||
return d.importMapJSON, nil
|
||||
}
|
||||
|
||||
// newDevServer constructs the dev server (abs paths, vendor manifest, import map)
|
||||
// without registering routes or starting the watcher — the split lets tests drive
|
||||
// the handlers directly.
|
||||
func newDevServer() (*devServer, error) {
|
||||
frontendAbs, err := filepath.Abs(frontendDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outputAbs, err := filepath.Abs(outputDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// esbuild reports realpaths (symlinks resolved), so the roots must be too or
|
||||
// the containment/URL math breaks on symlinked trees (e.g. macOS /var→/private/var).
|
||||
frontendAbs = evalSymlinks(frontendAbs)
|
||||
outputAbs = evalSymlinks(outputAbs)
|
||||
|
||||
absSym := func(p string) string { a, _ := filepath.Abs(p); return evalSymlinks(a) }
|
||||
var vdirs []string
|
||||
for _, v := range vendorDirs() {
|
||||
vdirs = append(vdirs, absSym(v))
|
||||
}
|
||||
d := &devServer{
|
||||
hub: newHub(),
|
||||
frontend: frontendAbs,
|
||||
srcRoot: filepath.Join(frontendAbs, "src"),
|
||||
kitRoot: absSym(webRoot()),
|
||||
genRoot: absSym(genTSDir),
|
||||
vendorDirs: vdirs,
|
||||
output: outputAbs,
|
||||
graph: newModuleGraph(),
|
||||
vendorCache: map[string][]byte{},
|
||||
cssTrigger: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
eps, err := loadVendorManifest(d.vendorDirs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading vendor manifest: %w", err)
|
||||
}
|
||||
d.vendorEntrypoints = eps
|
||||
d.importMapJSON = d.buildImportMap()
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *devServer) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc(srcURLPrefix, d.serveModule)
|
||||
mux.HandleFunc(kitURLPrefix, d.serveKit)
|
||||
mux.HandleFunc(vendorURLPrefix, d.serveVendor)
|
||||
mux.HandleFunc(assetURLPrefix, d.serveAssetURL)
|
||||
mux.HandleFunc(fsURLPrefix, d.serveFS)
|
||||
mux.HandleFunc("/@hmr/client", d.serveClient)
|
||||
mux.HandleFunc("/@hmr/ws", d.hub.ServeWS)
|
||||
}
|
||||
|
||||
// serveModule transforms and serves one source module as native ESM.
|
||||
func (d *devServer) serveModule(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, srcURLPrefix)
|
||||
abs := filepath.Join(d.srcRoot, filepath.FromSlash(rel))
|
||||
// Contain the request to the source tree.
|
||||
if !within(d.srcRoot, abs) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, statErr := os.Stat(abs); statErr != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
code, err := d.transformModule(abs)
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if err != nil {
|
||||
// Surface the failure as a browser overlay rather than a hard 500 that
|
||||
// would break the module graph; the next save re-runs the transform.
|
||||
fmt.Fprintf(os.Stderr, "HMR transform %s: %v\n", rel, err)
|
||||
w.Write([]byte(errorModule(rel, err)))
|
||||
return
|
||||
}
|
||||
w.Write(code)
|
||||
}
|
||||
|
||||
// serveKit transforms and serves one shared-kit module (kjol web tree) as ESM.
|
||||
func (d *devServer) serveKit(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, kitURLPrefix)
|
||||
abs := filepath.Join(d.kitRoot, filepath.FromSlash(rel))
|
||||
if !within(d.kitRoot, abs) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, statErr := os.Stat(abs); statErr != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
code, err := d.transformModule(abs)
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "HMR transform %s: %v\n", rel, err)
|
||||
w.Write([]byte(errorModule(rel, err)))
|
||||
return
|
||||
}
|
||||
w.Write(code)
|
||||
}
|
||||
|
||||
// moduleURLBase maps an absolute module file to its dev URL (no version query):
|
||||
// app sources → /@src/, shared-kit modules (kjol web) → /@kit/. "" if outside both.
|
||||
func (d *devServer) moduleURLBase(abs string) string {
|
||||
if within(d.srcRoot, abs) {
|
||||
return srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, abs))
|
||||
}
|
||||
if within(d.kitRoot, abs) {
|
||||
return kitURLPrefix + filepath.ToSlash(mustRel(d.kitRoot, abs))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// moduleID is a stable identifier for solid-refresh's HMR registry (survives
|
||||
// recompiles), tree-aware so app and kit modules never collide.
|
||||
func (d *devServer) moduleID(abs string) string {
|
||||
if within(d.srcRoot, abs) {
|
||||
return filepath.ToSlash(mustRel(d.srcRoot, abs))
|
||||
}
|
||||
if within(d.kitRoot, abs) {
|
||||
return "@kit/" + filepath.ToSlash(mustRel(d.kitRoot, abs))
|
||||
}
|
||||
return filepath.ToSlash(abs)
|
||||
}
|
||||
|
||||
// resolveAliasSpec resolves an @ui/@kjol/@appgen import to an absolute file, or ""
|
||||
// if spec is not one of those aliases (e.g. a vendored @scope/pkg like @solidjs/router).
|
||||
func (d *devServer) resolveAliasSpec(spec string) string {
|
||||
roots := aliasRoots()
|
||||
slash := strings.IndexByte(spec, '/')
|
||||
if slash < 0 {
|
||||
return ""
|
||||
}
|
||||
prefix, rest := spec[:slash], spec[slash+1:]
|
||||
root, ok := roots[prefix]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if p, ok := resolveAlias(root, rest); ok {
|
||||
return evalSymlinks(p)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// transformModule runs esbuild over a single entry file with all imports marked
|
||||
// external (so only this file is transformed) and rewritten to dev URLs, then
|
||||
// prepends the import.meta.hot bootstrap. .tsx/.jsx are Solid+refresh compiled
|
||||
// via the goja pipeline first.
|
||||
func (d *devServer) transformModule(abs string) ([]byte, error) {
|
||||
result := esbuild.Build(esbuild.BuildOptions{
|
||||
EntryPoints: []string{abs},
|
||||
Bundle: true,
|
||||
Write: false,
|
||||
Format: esbuild.FormatESModule,
|
||||
Platform: esbuild.PlatformBrowser,
|
||||
Target: esbuild.ES2022,
|
||||
Sourcemap: esbuild.SourceMapInline,
|
||||
SourcesContent: esbuild.SourcesContentInclude,
|
||||
LogLevel: esbuild.LogLevelSilent,
|
||||
// Same compile-time env define as the production bundle, so env.ts reads
|
||||
// the baked value under HMR too (see esbuildDefine).
|
||||
Define: esbuildDefine(),
|
||||
// Order matters: the Solid/JSX loader and the export shim handle the entry
|
||||
// file's contents; the external-rewrite resolver must be able to see every
|
||||
// import, so it runs last (its OnResolve filter is `.*`).
|
||||
Plugins: []esbuild.Plugin{
|
||||
d.solidRefreshLoadPlugin(),
|
||||
defaultExportShimPlugin(),
|
||||
d.externalRewritePlugin(),
|
||||
},
|
||||
})
|
||||
if len(result.Errors) > 0 {
|
||||
msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{})
|
||||
return nil, fmt.Errorf("%s", strings.Join(msgs, "\n"))
|
||||
}
|
||||
if len(result.OutputFiles) == 0 {
|
||||
return nil, fmt.Errorf("no output")
|
||||
}
|
||||
|
||||
selfURL := d.moduleURLBase(abs)
|
||||
prelude := "import { createHotContext as __createHotContext } from \"/@hmr/client\";\n" +
|
||||
"import.meta.hot = __createHotContext(" + jsString(selfURL) + ");\n"
|
||||
return append([]byte(prelude), result.OutputFiles[0].Contents...), nil
|
||||
}
|
||||
|
||||
// solidRefreshLoadPlugin Solid-compiles .tsx/.jsx via the goja pipeline WITH
|
||||
// solid-refresh instrumentation, passing the src-relative path as the stable id
|
||||
// solid-refresh keys its HMR registry on (so it survives across recompiles).
|
||||
func (d *devServer) solidRefreshLoadPlugin() esbuild.Plugin {
|
||||
return esbuild.Plugin{
|
||||
Name: "dev-solid-refresh",
|
||||
Setup: func(b esbuild.PluginBuild) {
|
||||
b.OnLoad(esbuild.OnLoadOptions{Filter: `\.(tsx|jsx)$`}, func(a esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
|
||||
data, err := os.ReadFile(a.Path)
|
||||
if err != nil {
|
||||
return esbuild.OnLoadResult{}, err
|
||||
}
|
||||
id := d.moduleID(a.Path)
|
||||
out, err := CompileDev(string(data), id)
|
||||
if err != nil {
|
||||
return esbuild.OnLoadResult{}, err
|
||||
}
|
||||
// Load as TS, not JS: esbuild only re-emits (and thus rewrites this
|
||||
// module's external import paths to our /@src/ URLs) when it has to
|
||||
// transpile. A JS loader leaves the already-JS CompileDev output
|
||||
// untouched, leaking the raw ./x.js specifiers to the browser. esbuild
|
||||
// chains solid-refresh's inline sourcemap into its own, so the browser
|
||||
// still debugs against the original .tsx.
|
||||
loader := esbuild.LoaderTS
|
||||
dir := filepath.Dir(a.Path)
|
||||
return esbuild.OnLoadResult{Contents: &out, Loader: loader, ResolveDir: dir}, nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// externalRewritePlugin marks every non-entry import external and rewrites its
|
||||
// path to a dev URL: relative/absolute → /@src/<resolved> (recording a graph
|
||||
// edge), `?url` → /@fs/<file> raw passthrough, bare → left as-is for the import
|
||||
// map. esbuild emits the returned path verbatim, so we control the URL.
|
||||
func (d *devServer) externalRewritePlugin() esbuild.Plugin {
|
||||
return esbuild.Plugin{
|
||||
Name: "dev-external-rewrite",
|
||||
Setup: func(b esbuild.PluginBuild) {
|
||||
b.OnResolve(esbuild.OnResolveOptions{Filter: `.*`}, func(a esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
|
||||
if a.Kind == esbuild.ResolveEntryPoint {
|
||||
return esbuild.OnResolveResult{}, nil // let esbuild load the entry
|
||||
}
|
||||
spec := a.Path
|
||||
|
||||
// `import x from "...?url"` — emit the referenced file as a raw asset.
|
||||
if strings.HasSuffix(spec, "?url") {
|
||||
real := strings.TrimSuffix(spec, "?url")
|
||||
if target := d.resolveAsset(a.ResolveDir, real); target != "" {
|
||||
return esbuild.OnResolveResult{Path: target, External: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// @ui/@kjol/@appgen → resolve into the kjol web tree (or the app's
|
||||
// generated dir) and rewrite to the /@src/ or /@kit/ URL that serves it.
|
||||
if strings.HasPrefix(spec, "@") {
|
||||
if resolved := d.resolveAliasSpec(spec); resolved != "" {
|
||||
if url := d.moduleURLBase(resolved); url != "" {
|
||||
if a.Importer != "" {
|
||||
d.graph.recordEdge(a.Importer, resolved)
|
||||
}
|
||||
if v := d.graph.versionOf(resolved); v > 0 {
|
||||
url += fmt.Sprintf("?t=%d", v)
|
||||
}
|
||||
return esbuild.OnResolveResult{Path: url, External: true}, nil
|
||||
}
|
||||
}
|
||||
// Not an @ui/@kjol/@appgen alias (e.g. @solidjs/router): fall through
|
||||
// to the bare-specifier / import-map path below.
|
||||
}
|
||||
|
||||
// Bare specifier → import map (single vendored copy).
|
||||
if !strings.HasPrefix(spec, ".") && !filepath.IsAbs(spec) {
|
||||
return esbuild.OnResolveResult{Path: spec, External: true}, nil
|
||||
}
|
||||
|
||||
// Relative/absolute → resolve to the real source file (app OR kit tree)
|
||||
// and rewrite to its /@src/ or /@kit/ URL, recording the edge for HMR.
|
||||
resolved := resolveSource(a.ResolveDir, spec)
|
||||
url := ""
|
||||
if resolved != "" {
|
||||
url = d.moduleURLBase(resolved)
|
||||
}
|
||||
if url == "" {
|
||||
// Unknown import: leave it and let the browser 404 loudly.
|
||||
return esbuild.OnResolveResult{Path: spec, External: true}, nil
|
||||
}
|
||||
if a.Importer != "" {
|
||||
d.graph.recordEdge(a.Importer, resolved)
|
||||
}
|
||||
if v := d.graph.versionOf(resolved); v > 0 {
|
||||
url += fmt.Sprintf("?t=%d", v)
|
||||
}
|
||||
return esbuild.OnResolveResult{Path: url, External: true}, nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAsset resolves a `?url` target (relative to importer, or a vendored
|
||||
// subpath) to a /@url/ shim-module path rooted at the frontend dir. The shim's
|
||||
// default export is the raw /@fs/ URL — matching esbuild's file-loader `?url`
|
||||
// semantics, where `import u from "x?url"` binds u to the asset's URL string, not
|
||||
// the module's exports.
|
||||
func (d *devServer) resolveAsset(resolveDir, spec string) string {
|
||||
var abs string
|
||||
if strings.HasPrefix(spec, ".") || filepath.IsAbs(spec) {
|
||||
abs = filepath.Join(resolveDir, spec)
|
||||
} else {
|
||||
for _, vd := range d.vendorDirs { // vendored subpath
|
||||
if cand := filepath.Join(vd, filepath.FromSlash(spec)); fileExists(cand) {
|
||||
abs = cand
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if abs == "" || !within(d.frontend, abs) || !fileExists(abs) {
|
||||
return ""
|
||||
}
|
||||
return assetURLPrefix + filepath.ToSlash(mustRel(d.frontend, abs))
|
||||
}
|
||||
|
||||
// serveAssetURL serves the `?url` shim module: `export default "<raw file URL>"`.
|
||||
func (d *devServer) serveAssetURL(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, assetURLPrefix)
|
||||
abs := filepath.Join(d.frontend, filepath.FromSlash(rel))
|
||||
if !within(d.frontend, abs) || !fileExists(abs) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
fmt.Fprintf(w, "export default %s;\n", jsString(fsURLPrefix+rel))
|
||||
}
|
||||
|
||||
// serveFS serves a raw file under the frontend dir (the target of a `?url` shim,
|
||||
// e.g. the pdfjs worker, which must load from a real URL for import.meta.url).
|
||||
func (d *devServer) serveFS(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, fsURLPrefix)
|
||||
abs := filepath.Join(d.frontend, filepath.FromSlash(rel))
|
||||
if !within(d.frontend, abs) || !fileExists(abs) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Pin the JS MIME so module workers (.mjs) aren't rejected for a text/plain type.
|
||||
switch strings.ToLower(filepath.Ext(abs)) {
|
||||
case ".mjs", ".js":
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, abs)
|
||||
}
|
||||
|
||||
// ---- module graph ----------------------------------------------------------
|
||||
|
||||
// moduleGraph tracks importer→import edges and per-module versions so a change
|
||||
// can be propagated to the nearest self-accepting boundary (a .tsx/.jsx compiled
|
||||
// with solid-refresh) or, failing that, a full reload. Keys are absolute paths.
|
||||
type moduleGraph struct {
|
||||
mu sync.Mutex
|
||||
importers map[string]map[string]bool // module -> set of modules that import it
|
||||
version map[string]int64 // module -> current version (0 = never changed)
|
||||
counter int64
|
||||
}
|
||||
|
||||
func newModuleGraph() *moduleGraph {
|
||||
return &moduleGraph{importers: map[string]map[string]bool{}, version: map[string]int64{}}
|
||||
}
|
||||
|
||||
func (g *moduleGraph) recordEdge(importer, imported string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
set := g.importers[imported]
|
||||
if set == nil {
|
||||
set = map[string]bool{}
|
||||
g.importers[imported] = set
|
||||
}
|
||||
set[importer] = true
|
||||
}
|
||||
|
||||
func (g *moduleGraph) versionOf(module string) int64 {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.version[module]
|
||||
}
|
||||
|
||||
// invalidate walks up from the changed file to the nearest HMR boundaries,
|
||||
// bumping the version of every module on the path (so a re-imported boundary
|
||||
// re-fetches the changed dep, not a cached copy). It returns the boundary URLs to
|
||||
// re-import and the shared version, or fullReload if the change reaches a
|
||||
// non-accepting root (the entry, a routes table).
|
||||
func (g *moduleGraph) invalidate(changed string) (boundaries []string, version int64, fullReload bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.counter++
|
||||
v := g.counter
|
||||
visited := map[string]bool{}
|
||||
queue := []string{changed}
|
||||
for len(queue) > 0 {
|
||||
m := queue[0]
|
||||
queue = queue[1:]
|
||||
if visited[m] {
|
||||
continue
|
||||
}
|
||||
visited[m] = true
|
||||
g.version[m] = v
|
||||
|
||||
if isHMRBoundary(m) {
|
||||
boundaries = append(boundaries, m)
|
||||
continue // don't climb past a self-accepting boundary
|
||||
}
|
||||
imps := g.importers[m]
|
||||
if len(imps) == 0 {
|
||||
fullReload = true // reached a root that can't accept -> reload
|
||||
continue
|
||||
}
|
||||
for imp := range imps {
|
||||
queue = append(queue, imp)
|
||||
}
|
||||
}
|
||||
if fullReload {
|
||||
return nil, v, true
|
||||
}
|
||||
return boundaries, v, false
|
||||
}
|
||||
|
||||
// isHMRBoundary reports whether a module can self-accept — only solid-refresh
|
||||
// -instrumented JSX (.tsx/.jsx). .ts/.js (incl. the solid-js/html app pages)
|
||||
// bubble to a full reload.
|
||||
func isHMRBoundary(path string) bool {
|
||||
switch filepath.Ext(path) {
|
||||
case ".tsx", ".jsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// resolveSource replicates esbuild's resolution of a relative import to a real
|
||||
// source file, including the project's `.js`-specifier-for-a-.ts-file convention
|
||||
// (see import_check.go) and extensionless / index resolution.
|
||||
func resolveSource(resolveDir, spec string) string {
|
||||
base := filepath.Join(resolveDir, filepath.FromSlash(spec))
|
||||
if fileExists(base) {
|
||||
return base
|
||||
}
|
||||
// `./Foo.js` may name a sibling .ts/.tsx/.jsx (esbuild resolves it silently).
|
||||
if strings.HasSuffix(base, ".js") {
|
||||
stem := strings.TrimSuffix(base, ".js")
|
||||
for _, ext := range []string{".ts", ".tsx", ".jsx"} {
|
||||
if fileExists(stem + ext) {
|
||||
return stem + ext
|
||||
}
|
||||
}
|
||||
}
|
||||
// Extensionless specifier.
|
||||
for _, ext := range []string{".ts", ".tsx", ".jsx", ".js"} {
|
||||
if fileExists(base + ext) {
|
||||
return base + ext
|
||||
}
|
||||
}
|
||||
// Directory index.
|
||||
for _, ext := range []string{".ts", ".tsx", ".jsx", ".js"} {
|
||||
if idx := filepath.Join(base, "index"+ext); fileExists(idx) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// evalSymlinks resolves symlinks so paths compare equal to esbuild's realpaths;
|
||||
// returns the input unchanged if it can't be resolved (e.g. doesn't exist yet).
|
||||
func evalSymlinks(p string) string {
|
||||
if r, err := filepath.EvalSymlinks(p); err == nil {
|
||||
return r
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// within reports whether abs is inside root (after cleaning), guarding against
|
||||
// `..` traversal out of the served tree.
|
||||
func within(root, abs string) bool {
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func mustRel(root, abs string) string {
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil {
|
||||
return abs
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// jsString renders s as a double-quoted JS string literal.
|
||||
func jsString(s string) string {
|
||||
b := strings.Builder{}
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// errorModule returns an ES module that surfaces a compile/transform failure as
|
||||
// a full-screen overlay in the browser (Vite-style) instead of breaking the page
|
||||
// hard. It imports the HMR client's showErrorOverlay and calls it with the error
|
||||
// text; because the stub evaluates synchronously as part of the importing
|
||||
// boundary's module graph, the overlay appears the moment a broken module is
|
||||
// (re-)imported — on first load or on a hot re-import. A later successful hot
|
||||
// update clears it (see applyUpdates in hmr_client.go). rel is the src-relative
|
||||
// path of the failing module, shown in the overlay header.
|
||||
func errorModule(rel string, err error) string {
|
||||
payload, jerr := json.Marshal(hmrError{Message: err.Error(), File: rel})
|
||||
if jerr != nil {
|
||||
return "console.error(" + jsString("[hmr] build error:\n"+err.Error()) + ");\n"
|
||||
}
|
||||
return "import { showErrorOverlay as __hmrShowError } from \"/@hmr/client\";\n" +
|
||||
"__hmrShowError(" + string(payload) + ");\n"
|
||||
}
|
||||
Reference in New Issue
Block a user