Files
kjol/go/jsbundler/hmr_watch.go
2026-07-15 11:28:24 -04:00

178 lines
5.5 KiB
Go

//go:build dev
package jsbundler
// The dev watcher: a lightweight mtime poll over frontend/src and frontend/css
// (no external dependency). A source-module change is turned into an HMR update
// or a full reload via the module graph; a CSS/Tailwind change triggers a
// (coalesced) stylesheet rebuild and hot-swap. The public-route manifest is
// regenerated on edit, then the page reloads.
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// pollInterval is the mtime scan cadence. Kept tight so save→update latency isn't
// dominated by detection lag: the WalkDir+Stat over frontend/src+css is sub-ms, so
// scanning this often is cheap. (For zero detection lag, swap the poll for native
// FS events — deliberately avoided to keep the watcher dependency-free.)
const pollInterval = 50 * time.Millisecond
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 JS kit + styles
roots = append(roots, d.kitRoot)
}
mtimes := map[string]time.Time{}
d.scan(roots, mtimes, nil) // seed: record current state, emit nothing
for {
time.Sleep(pollInterval)
var changed []string
d.scan(roots, mtimes, func(p string) { changed = append(changed, p) })
if len(changed) > 0 {
d.handleChanges(changed)
}
}
}
// scan walks roots, calling onchange for every file whose mtime advanced since
// the last scan (or is new). mtimes is updated in place.
func (d *devServer) scan(roots []string, mtimes map[string]time.Time, onchange func(string)) {
for _, root := range roots {
filepath.WalkDir(root, func(p string, entry os.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return nil
}
switch filepath.Ext(p) {
case ".ts", ".tsx", ".js", ".jsx", ".css":
default:
return nil
}
info, err := entry.Info()
if err != nil {
return nil
}
mt := info.ModTime()
if prev, ok := mtimes[p]; !ok || mt.After(prev) {
mtimes[p] = mt
// onchange is nil on the seed pass, so nothing is reported until the
// baseline is recorded; afterwards every new/changed file is reported.
if onchange != nil {
onchange(p)
}
}
return nil
})
}
}
func (d *devServer) handleChanges(changed []string) {
pagesManifestAbs := filepath.Join(d.frontend, filepath.FromSlash(pagesManifest))
faIconsOutAbs := filepath.Join(d.frontend, "src", "ui", "generated", "faIcons.ts")
cssDirty := false
srcDirty := false
iconsDirty := false
for _, p := range changed {
base := filepath.Base(p)
// Generated files are written by the tools below; ignore to avoid loops.
if strings.HasSuffix(base, ".gen.ts") || strings.HasSuffix(base, ".gen.go") {
continue
}
if p == pagesManifestAbs {
// The public-page manifest drives generated route tables — regenerate,
// then reload (these pages are live-reload scope, not component HMR).
if err := generatePublicRoutes(); err != nil {
fmt.Fprintf(os.Stderr, "[hmr] regenerating public routes: %v\n", err)
}
d.hub.broadcastJSON(hmrMessage{Type: "full-reload"})
return
}
switch filepath.Ext(p) {
case ".css":
cssDirty = true
case ".ts", ".tsx", ".js", ".jsx":
d.hmrJS(p)
cssDirty = true // a class may have been added/removed
srcDirty = true
// The icon registry is itself a generated .ts file under srcRoot, so its
// own hot-reload (above) must not re-trigger a regeneration pass — that
// would bump its mtime and loop forever.
if p != faIconsOutAbs {
iconsDirty = true
}
}
}
if iconsDirty {
// A source edit may have referenced a new icon name; rescan so it's
// available without a manual `go run ./cmd/bundle`. The regenerated file's
// own mtime change is picked up and hot-reloaded on the next poll.
if err := generateFAIcons(); err != nil {
fmt.Fprintf(os.Stderr, "[hmr] regenerating FA icons: %v\n", err)
}
}
if cssDirty {
select {
case d.cssTrigger <- struct{}{}:
default: // a rebuild is already queued
}
}
if srcDirty {
// Proactively re-render the public pages' SSR so the no-JS ("static")
// version served on the next reload is already current, not rendered lazily.
RewarmDevPublicPages()
}
}
// hmrJS turns one changed source module into a hot update or a full reload.
func (d *devServer) hmrJS(abs string) {
rel := d.moduleID(abs)
boundaries, version, reload := d.graph.invalidate(abs)
if reload {
fmt.Printf("[hmr] full reload (%s)\n", rel)
d.hub.broadcastJSON(hmrMessage{Type: "full-reload"})
return
}
if len(boundaries) == 0 {
return // not on the current page's graph — nothing to do
}
updates := make([]hmrUpdate, 0, len(boundaries))
for _, b := range boundaries {
updates = append(updates, hmrUpdate{
Path: d.moduleURLBase(b),
Timestamp: version,
})
}
fmt.Printf("[hmr] update %s -> %d boundary(ies)\n", rel, len(boundaries))
d.hub.broadcastJSON(hmrMessage{Type: "update", Updates: updates})
}
// cssLoop serializes and coalesces CSS rebuilds, hot-swapping the stylesheet when
// each completes.
func (d *devServer) cssLoop() {
for range d.cssTrigger {
if _, err := bundleSPACSS(); err != nil {
fmt.Fprintf(os.Stderr, "[hmr] CSS rebuild failed: %v\n", err)
} else {
d.hub.broadcastJSON(hmrMessage{Type: "css-update", Path: "/bundle.min.css"})
}
// Public pages hot-reload too, off their own stylesheet.
if _, err := bundlePublicCSS(); err != nil {
fmt.Fprintf(os.Stderr, "[hmr] public CSS rebuild failed: %v\n", err)
} else {
d.hub.broadcastJSON(hmrMessage{Type: "css-update", Path: "/public.bundle.min.css"})
}
}
}