162 lines
4.8 KiB
Go
162 lines
4.8 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))
|
|
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"})
|
|
}
|
|
}
|
|
}
|