//go:build dev package bundler // 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 internal/bundler 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/ serves the // transformed module frontend/src/. 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/.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/ (recording a graph // edge), `?url` → /@fs/ 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 ""`. 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" }