rename packages, refactor out tailwind compiler,

This commit is contained in:
2026-07-13 15:06:09 -04:00
parent c5b14d7c41
commit d52151cc1a
62 changed files with 1446 additions and 460 deletions

View File

@@ -1,3 +1,5 @@
//go:build !(js && wasm)
// Package wasmdevserver is a reusable development server for gowasm apps: it serves
// the built web assets, renders routes server-side (SSR) at request time, hosts
// the /rsc server-component endpoint, and hot-swaps the freshly built wasm into
@@ -33,14 +35,21 @@ import (
// Config wires an app into the dev server. Render and Document are called per
// request; Build is called for the initial build and on every source change.
type Config struct {
Addr string // listen address (default ":8085")
Dir string // static assets dir; Build writes app.wasm here (default "./wwwroot")
Watch bool // rebuild on change + hot reload
WatchDirs []string // source dirs to watch when Watch is set
Build func() ([]byte, error) // (re)build the wasm bundle; combined output on failure
Render func(path string) (inner string, ok bool) // SSR the #app inner HTML for a route (ok=false => client-rendered)
Document func(inner string) string // wrap #app inner HTML in a full HTML document
Handle func(mux *http.ServeMux) // optional: register extra routes (e.g. app API endpoints)
Addr string // listen address (default ":8085")
Dir string // static assets dir; Build writes app.wasm here (default "./wwwroot")
Watch bool // rebuild on change + hot reload
WatchDirs []string // source dirs to watch when Watch is set
Build func() ([]byte, error) // (re)build everything; combined output on failure
// BuildCSS recompiles ONLY the stylesheet. When a save touched stylesheets and no
// Go code, this runs instead of Build and the browser swaps the stylesheet in
// place — no wasm rebuild, no page reload, no lost state. Without it a colour
// tweak waits on the whole Go compiler, which is the difference between a design
// loop and a coffee break.
BuildCSS func() ([]byte, error)
Render func(path string) (inner string, ok bool) // SSR the #app inner HTML for a route (ok=false => client-rendered)
Document func(inner string) string // wrap #app inner HTML in a full HTML document
Handle func(mux *http.ServeMux) // optional: register extra routes (e.g. app API endpoints)
}
// Serve builds once (in watch mode), wires the routes, and blocks serving.
@@ -116,15 +125,45 @@ func rootHandler(cfg Config) http.HandlerFunc {
// ---- build + watch ------------------------------------------------------
// watchLoop rebuilds on change, and treats a stylesheet edit differently from a code
// edit — because they cost wildly different amounts.
//
// A Go change means recompiling a multi-megabyte wasm binary. A CSS change means
// re-running Tailwind, which takes a moment. Putting both through the same path
// would make every tweak to a colour wait on the compiler, so a save that touched
// ONLY stylesheets runs BuildCSS and pushes a stylesheet swap: no wasm rebuild, no
// page reload, no lost state — the new CSS just appears.
func watchLoop(cfg Config, h *hub) {
prev := fingerprint(cfg.WatchDirs)
prevGo := fingerprintExt(cfg.WatchDirs, ".go")
prevCSS := fingerprintExt(cfg.WatchDirs, ".css")
for {
time.Sleep(300 * time.Millisecond)
fp := fingerprint(cfg.WatchDirs)
if fp == prev {
fpGo := fingerprintExt(cfg.WatchDirs, ".go")
fpCSS := fingerprintExt(cfg.WatchDirs, ".css")
codeChanged := fpGo != prevGo
cssChanged := fpCSS != prevCSS
if !codeChanged && !cssChanged {
continue
}
prev = fp
prevGo, prevCSS = fpGo, fpCSS
// Styles only: the cheap path.
if !codeChanged && cfg.BuildCSS != nil {
log.Println("stylesheet changed, recompiling CSS…")
h.broadcast(`{"type":"building"}`)
if out, err := cfg.BuildCSS(); err != nil {
log.Printf("CSS build failed: %v\n%s", err, out)
h.setError(string(out))
continue
}
log.Println("CSS ok — swapping stylesheets")
h.clearError()
h.broadcast(`{"type":"css"}`)
continue
}
log.Println("change detected, rebuilding…")
h.broadcast(`{"type":"building"}`)
if cfg.Build == nil {
@@ -141,12 +180,20 @@ func watchLoop(cfg Config, h *hub) {
}
}
// fingerprint changes whenever any .go file under dirs is modified.
func fingerprint(dirs []string) int64 {
// fingerprintExt changes whenever a file of the given extension under dirs is
// modified.
//
// .css is watched at all for a reason: the build compiles Tailwind, and the entry
// stylesheet (its @theme block, its @font-face rules, any hand-written CSS) is an
// INPUT to that build. Watching only .go meant saving app.css did nothing — no
// rebuild, no reload — and the change simply never reached the browser. Editing some
// unrelated Go file would then sweep it up by accident, which is a maddening way to
// find out.
func fingerprintExt(dirs []string, ext string) int64 {
var fp int64
for _, d := range dirs {
filepath.WalkDir(d, func(path string, e fs.DirEntry, err error) error {
if err != nil || e.IsDir() || !strings.HasSuffix(path, ".go") {
if err != nil || e.IsDir() || !strings.EqualFold(filepath.Ext(path), ext) {
return nil
}
if info, err := e.Info(); err == nil {
@@ -206,6 +253,28 @@ const clientJS = `// Injected by the dev server in watch mode.
start(); // renders synchronously — no await between dispose and first paint
}
// Swap every stylesheet for a freshly-fetched copy — no page reload, so scroll
// position, form state and the running wasm instance all survive a style tweak.
//
// The NEW link is loaded and only then does the old one go, on its onload. Removing
// it first would leave the page unstyled for however long the fetch takes, which
// reads as a flash of naked HTML every time you save.
function swapCSS() {
hideOverlay();
var links = document.querySelectorAll('link[rel="stylesheet"]');
for (var i = 0; i < links.length; i++) {
(function (old) {
var url = old.href.split("?")[0] + "?v=" + Date.now();
var next = old.cloneNode();
next.href = url;
next.onload = function () { if (old.parentNode) old.parentNode.removeChild(old); };
next.onerror = function () { if (next.parentNode) next.parentNode.removeChild(next); };
old.parentNode.insertBefore(next, old.nextSibling);
})(links[i]);
}
console.log("[hot reload] stylesheet updated");
}
// Full-screen overlay showing the Go compiler output when a build fails. The
// app underneath keeps running (and its state), so fixing the code and saving
// clears the overlay and hot-swaps without losing anything.
@@ -246,6 +315,7 @@ const clientJS = `// Injected by the dev server in watch mode.
var msg = {};
try { msg = JSON.parse(e.data); } catch (_) { return; }
if (msg.type === "reload") { hotSwap(); } // build ok: swap in place
else if (msg.type === "css") { swapCSS(); } // styles only: swap the stylesheet
else if (msg.type === "error") { showOverlay(msg.msg); } // build failed: show compiler output
else if (msg.type === "building") { console.log("[hot reload] rebuilding…"); }
};