update HMR to support kjol

This commit is contained in:
2026-07-09 12:49:17 -04:00
parent c8fe422b4e
commit a0c5f73b64
3 changed files with 135 additions and 29 deletions

View File

@@ -28,17 +28,20 @@ import (
// devURLPrefix roots the served source tree: GET /@src/<rel> serves the // devURLPrefix roots the served source tree: GET /@src/<rel> serves the
// transformed module frontend/src/<rel>. // transformed module frontend/src/<rel>.
const ( 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 assetURLPrefix = "/@url/" // `?url` shim: a module whose default export is the file URL
fsURLPrefix = "/@fs/" // raw file passthrough (the URL the shim points at) fsURLPrefix = "/@fs/" // raw file passthrough (the URL the shim points at)
) )
type devServer struct { type devServer struct {
hub *hub hub *hub
frontend string // abs frontend/ frontend string // abs app frontend/
srcRoot string // abs frontend/src srcRoot string // abs app frontend/src
vendor string // abs frontend/vendor kitRoot string // abs kjol web (@ui/@kjol root); == frontend in single-tree
output string // abs wwwroot/ genRoot string // abs app generated dir (@appgen)
vendorDirs []string // abs vendor roots (kjol runtime first, then app vendor)
output string // abs wwwroot/
graph *moduleGraph graph *moduleGraph
@@ -55,7 +58,8 @@ type devServer struct {
// and returns the import map (JSON object body) the SPA shell must inline so bare // 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 // specifiers resolve to the single vendored copies. Called only from cmd/server's
// `dev`-tagged shim. // `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() d, err := newDevServer()
if err != nil { if err != nil {
return "", err return "", err
@@ -83,18 +87,26 @@ func newDevServer() (*devServer, error) {
// the containment/URL math breaks on symlinked trees (e.g. macOS /var→/private/var). // the containment/URL math breaks on symlinked trees (e.g. macOS /var→/private/var).
frontendAbs = evalSymlinks(frontendAbs) frontendAbs = evalSymlinks(frontendAbs)
outputAbs = evalSymlinks(outputAbs) 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{ d := &devServer{
hub: newHub(), hub: newHub(),
frontend: frontendAbs, frontend: frontendAbs,
srcRoot: filepath.Join(frontendAbs, "src"), srcRoot: filepath.Join(frontendAbs, "src"),
vendor: filepath.Join(frontendAbs, "vendor"), kitRoot: absSym(webRoot()),
output: outputAbs, genRoot: absSym(genTSDir),
vendorDirs: vdirs,
output: outputAbs,
graph: newModuleGraph(), graph: newModuleGraph(),
vendorCache: map[string][]byte{}, vendorCache: map[string][]byte{},
cssTrigger: make(chan struct{}, 1), cssTrigger: make(chan struct{}, 1),
} }
eps, err := loadVendorManifest(d.vendor) eps, err := loadVendorManifest(d.vendorDirs)
if err != nil { if err != nil {
return nil, fmt.Errorf("loading vendor manifest: %w", err) return nil, fmt.Errorf("loading vendor manifest: %w", err)
} }
@@ -105,6 +117,7 @@ func newDevServer() (*devServer, error) {
func (d *devServer) register(mux *http.ServeMux) { func (d *devServer) register(mux *http.ServeMux) {
mux.HandleFunc(srcURLPrefix, d.serveModule) mux.HandleFunc(srcURLPrefix, d.serveModule)
mux.HandleFunc(kitURLPrefix, d.serveKit)
mux.HandleFunc(vendorURLPrefix, d.serveVendor) mux.HandleFunc(vendorURLPrefix, d.serveVendor)
mux.HandleFunc(assetURLPrefix, d.serveAssetURL) mux.HandleFunc(assetURLPrefix, d.serveAssetURL)
mux.HandleFunc(fsURLPrefix, d.serveFS) mux.HandleFunc(fsURLPrefix, d.serveFS)
@@ -139,6 +152,72 @@ func (d *devServer) serveModule(w http.ResponseWriter, r *http.Request) {
w.Write(code) 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 // 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 // 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 // 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") 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" + prelude := "import { createHotContext as __createHotContext } from \"/@hmr/client\";\n" +
"import.meta.hot = __createHotContext(" + jsString(selfURL) + ");\n" "import.meta.hot = __createHotContext(" + jsString(selfURL) + ");\n"
return append([]byte(prelude), result.OutputFiles[0].Contents...), nil return append([]byte(prelude), result.OutputFiles[0].Contents...), nil
@@ -192,7 +271,7 @@ func (d *devServer) solidRefreshLoadPlugin() esbuild.Plugin {
if err != nil { if err != nil {
return esbuild.OnLoadResult{}, err return esbuild.OnLoadResult{}, err
} }
id := filepath.ToSlash(mustRel(d.srcRoot, a.Path)) id := d.moduleID(a.Path)
out, err := CompileDev(string(data), id) out, err := CompileDev(string(data), id)
if err != nil { if err != nil {
return esbuild.OnLoadResult{}, err 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). // Bare specifier → import map (single vendored copy).
if !strings.HasPrefix(spec, ".") && !filepath.IsAbs(spec) { if !strings.HasPrefix(spec, ".") && !filepath.IsAbs(spec) {
return esbuild.OnResolveResult{Path: spec, External: true}, nil return esbuild.OnResolveResult{Path: spec, External: true}, nil
} }
// Relative/absolute → resolve to the real source file and rewrite to // Relative/absolute → resolve to the real source file (app OR kit tree)
// its /@src/ URL, recording the importer→import edge for HMR. // and rewrite to its /@src/ or /@kit/ URL, recording the edge for HMR.
resolved := resolveSource(a.ResolveDir, spec) resolved := resolveSource(a.ResolveDir, spec)
if resolved == "" || !within(d.srcRoot, resolved) { url := ""
// Unknown relative import: leave it and let the browser 404 loudly. 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 return esbuild.OnResolveResult{Path: spec, External: true}, nil
} }
if a.Importer != "" { if a.Importer != "" {
d.graph.recordEdge(a.Importer, resolved) d.graph.recordEdge(a.Importer, resolved)
} }
url := srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, resolved))
if v := d.graph.versionOf(resolved); v > 0 { if v := d.graph.versionOf(resolved); v > 0 {
url += fmt.Sprintf("?t=%d", v) 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) { if strings.HasPrefix(spec, ".") || filepath.IsAbs(spec) {
abs = filepath.Join(resolveDir, spec) abs = filepath.Join(resolveDir, spec)
} else { } 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 ""
} }
return assetURLPrefix + filepath.ToSlash(mustRel(d.frontend, abs)) return assetURLPrefix + filepath.ToSlash(mustRel(d.frontend, abs))

View File

@@ -13,7 +13,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
esbuild "github.com/evanw/esbuild/pkg/api" 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 // re-externalizing the OTHER vendored specifiers so they resolve (once) through
// the import map. Mirrors the prod vendor resolution (NodePaths + development // the import map. Mirrors the prod vendor resolution (NodePaths + development
// condition) so the dev copy matches the shipped one. // condition) so the dev copy matches the shipped one.
func (d *devServer) bundleVendor(relFile string) ([]byte, error) { func (d *devServer) bundleVendor(absFile string) ([]byte, error) {
entry := filepath.Join(d.vendor, filepath.FromSlash(relFile))
result := esbuild.Build(esbuild.BuildOptions{ result := esbuild.Build(esbuild.BuildOptions{
EntryPoints: []string{entry}, EntryPoints: []string{absFile},
Bundle: true, Bundle: true,
Write: false, Write: false,
Format: esbuild.FormatESModule, Format: esbuild.FormatESModule,
Target: esbuild.ES2022, Target: esbuild.ES2022,
Platform: esbuild.PlatformBrowser, Platform: esbuild.PlatformBrowser,
Conditions: []string{"development"}, Conditions: []string{"development"},
NodePaths: []string{d.vendor}, NodePaths: d.vendorDirs,
Sourcemap: esbuild.SourceMapInline, Sourcemap: esbuild.SourceMapInline,
LogLevel: esbuild.LogLevelSilent, LogLevel: esbuild.LogLevelSilent,
Plugins: []esbuild.Plugin{d.vendorSharedExternalPlugin()}, Plugins: []esbuild.Plugin{d.vendorSharedExternalPlugin()},

View File

@@ -26,6 +26,9 @@ func (d *devServer) watch() {
go d.cssLoop() go d.cssLoop()
roots := []string{d.srcRoot, filepath.Join(d.frontend, "css")} 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{} mtimes := map[string]time.Time{}
d.scan(roots, mtimes, nil) // seed: record current state, emit nothing 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. // hmrJS turns one changed source module into a hot update or a full reload.
func (d *devServer) hmrJS(abs string) { func (d *devServer) hmrJS(abs string) {
rel := filepath.ToSlash(mustRel(d.srcRoot, abs)) rel := d.moduleID(abs)
boundaries, version, reload := d.graph.invalidate(abs) boundaries, version, reload := d.graph.invalidate(abs)
if reload { if reload {
fmt.Printf("[hmr] full reload (%s)\n", rel) fmt.Printf("[hmr] full reload (%s)\n", rel)
@@ -131,7 +134,7 @@ func (d *devServer) hmrJS(abs string) {
updates := make([]hmrUpdate, 0, len(boundaries)) updates := make([]hmrUpdate, 0, len(boundaries))
for _, b := range boundaries { for _, b := range boundaries {
updates = append(updates, hmrUpdate{ updates = append(updates, hmrUpdate{
Path: srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, b)), Path: d.moduleURLBase(b),
Timestamp: version, Timestamp: version,
}) })
} }