//go:build dev package bundler // 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 web 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)) cssDirty := false srcDirty := 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 } } 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"}) } } }