From a0c5f73b644940feae4ea7ef47dce1c32f3ee804 Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Thu, 9 Jul 2026 12:49:17 -0400 Subject: [PATCH] update HMR to support kjol --- go/bundler/hmr_server.go | 149 +++++++++++++++++++++++++++++++++------ go/bundler/hmr_vendor.go | 8 +-- go/bundler/hmr_watch.go | 7 +- 3 files changed, 135 insertions(+), 29 deletions(-) diff --git a/go/bundler/hmr_server.go b/go/bundler/hmr_server.go index 8854a547..1689730b 100644 --- a/go/bundler/hmr_server.go +++ b/go/bundler/hmr_server.go @@ -28,17 +28,20 @@ import ( // devURLPrefix roots the served source tree: GET /@src/ serves the // transformed module frontend/src/. const ( - srcURLPrefix = "/@src/" // transformed source modules + 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 frontend/ - srcRoot string // abs frontend/src - vendor string // abs frontend/vendor - output string // abs wwwroot/ + 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 @@ -55,7 +58,8 @@ type devServer struct { // 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) (importMap string, err error) { +func StartDevHMR(mux *http.ServeMux, cfg Config) (importMap string, err error) { + Configure(cfg) d, err := newDevServer() if err != nil { return "", err @@ -83,18 +87,26 @@ func newDevServer() (*devServer, error) { // 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"), - vendor: filepath.Join(frontendAbs, "vendor"), - output: outputAbs, + 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.vendor) + eps, err := loadVendorManifest(d.vendorDirs) if err != nil { return nil, fmt.Errorf("loading vendor manifest: %w", err) } @@ -105,6 +117,7 @@ func newDevServer() (*devServer, error) { 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) @@ -139,6 +152,72 @@ func (d *devServer) serveModule(w http.ResponseWriter, r *http.Request) { 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 @@ -174,7 +253,7 @@ func (d *devServer) transformModule(abs string) ([]byte, error) { return nil, fmt.Errorf("no output") } - selfURL := srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, abs)) + 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 @@ -192,7 +271,7 @@ func (d *devServer) solidRefreshLoadPlugin() esbuild.Plugin { if err != nil { return esbuild.OnLoadResult{}, err } - id := filepath.ToSlash(mustRel(d.srcRoot, a.Path)) + id := d.moduleID(a.Path) out, err := CompileDev(string(data), id) if err != nil { return esbuild.OnLoadResult{}, err @@ -233,22 +312,43 @@ func (d *devServer) externalRewritePlugin() esbuild.Plugin { } } + // @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 and rewrite to - // its /@src/ URL, recording the importer→import edge for HMR. + // 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) - if resolved == "" || !within(d.srcRoot, resolved) { - // Unknown relative import: leave it and let the browser 404 loudly. + 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) } - url := srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, resolved)) if v := d.graph.versionOf(resolved); v > 0 { url += fmt.Sprintf("?t=%d", v) } @@ -268,9 +368,14 @@ func (d *devServer) resolveAsset(resolveDir, spec string) string { if strings.HasPrefix(spec, ".") || filepath.IsAbs(spec) { abs = filepath.Join(resolveDir, spec) } else { - abs = filepath.Join(d.vendor, filepath.FromSlash(spec)) // vendored subpath + for _, vd := range d.vendorDirs { // vendored subpath + if cand := filepath.Join(vd, filepath.FromSlash(spec)); fileExists(cand) { + abs = cand + break + } + } } - if !within(d.frontend, abs) || !fileExists(abs) { + if abs == "" || !within(d.frontend, abs) || !fileExists(abs) { return "" } return assetURLPrefix + filepath.ToSlash(mustRel(d.frontend, abs)) diff --git a/go/bundler/hmr_vendor.go b/go/bundler/hmr_vendor.go index c1a89de3..9d0a2bcf 100644 --- a/go/bundler/hmr_vendor.go +++ b/go/bundler/hmr_vendor.go @@ -13,7 +13,6 @@ import ( "encoding/json" "fmt" "net/http" - "path/filepath" "strings" esbuild "github.com/evanw/esbuild/pkg/api" @@ -71,17 +70,16 @@ func (d *devServer) serveVendor(w http.ResponseWriter, r *http.Request) { // re-externalizing the OTHER vendored specifiers so they resolve (once) through // the import map. Mirrors the prod vendor resolution (NodePaths + development // condition) so the dev copy matches the shipped one. -func (d *devServer) bundleVendor(relFile string) ([]byte, error) { - entry := filepath.Join(d.vendor, filepath.FromSlash(relFile)) +func (d *devServer) bundleVendor(absFile string) ([]byte, error) { result := esbuild.Build(esbuild.BuildOptions{ - EntryPoints: []string{entry}, + EntryPoints: []string{absFile}, Bundle: true, Write: false, Format: esbuild.FormatESModule, Target: esbuild.ES2022, Platform: esbuild.PlatformBrowser, Conditions: []string{"development"}, - NodePaths: []string{d.vendor}, + NodePaths: d.vendorDirs, Sourcemap: esbuild.SourceMapInline, LogLevel: esbuild.LogLevelSilent, Plugins: []esbuild.Plugin{d.vendorSharedExternalPlugin()}, diff --git a/go/bundler/hmr_watch.go b/go/bundler/hmr_watch.go index f385198c..3a9729da 100644 --- a/go/bundler/hmr_watch.go +++ b/go/bundler/hmr_watch.go @@ -26,6 +26,9 @@ func (d *devServer) watch() { go d.cssLoop() roots := []string{d.srcRoot, filepath.Join(d.frontend, "css")} + if webDir != "" { // cross-tree: also watch the shared kjol web kit + styles + roots = append(roots, d.kitRoot) + } mtimes := map[string]time.Time{} d.scan(roots, mtimes, nil) // seed: record current state, emit nothing @@ -117,7 +120,7 @@ func (d *devServer) handleChanges(changed []string) { // hmrJS turns one changed source module into a hot update or a full reload. func (d *devServer) hmrJS(abs string) { - rel := filepath.ToSlash(mustRel(d.srcRoot, abs)) + rel := d.moduleID(abs) boundaries, version, reload := d.graph.invalidate(abs) if reload { fmt.Printf("[hmr] full reload (%s)\n", rel) @@ -131,7 +134,7 @@ func (d *devServer) hmrJS(abs string) { updates := make([]hmrUpdate, 0, len(boundaries)) for _, b := range boundaries { updates = append(updates, hmrUpdate{ - Path: srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, b)), + Path: d.moduleURLBase(b), Timestamp: version, }) }