Files
kjol/go/bundler/hmr_server.go

603 lines
20 KiB
Go

//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/<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"
}