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

279
go/webbundler/hmr_client.go Normal file
View File

@@ -0,0 +1,279 @@
//go:build dev
package webbundler
import "net/http"
// hmrUpdate names one boundary module to re-import at a given version.
type hmrUpdate struct {
Path string `json:"path"`
Timestamp int64 `json:"timestamp"`
}
// hmrError describes a compile/transform failure surfaced to the browser as a
// full-screen error overlay (Vite-style). Message is the full formatted error
// text (esbuild carries a code frame; the Solid compiler a plain message).
type hmrError struct {
Message string `json:"message"`
File string `json:"file,omitempty"`
}
// hmrMessage is the WebSocket payload pushed to the browser.
type hmrMessage struct {
Type string `json:"type"` // "update" | "full-reload" | "css-update" | "error"
Updates []hmrUpdate `json:"updates,omitempty"`
Path string `json:"path,omitempty"` // css-update: the stylesheet path
Err *hmrError `json:"err,omitempty"` // error: the compile failure to display
}
// serveClient serves the HMR client runtime as an ES module.
func (d *devServer) serveClient(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Write([]byte(hmrClientJS))
}
// hmrClientJS is the browser runtime: it owns the WebSocket, exposes
// createHotContext (the import.meta.hot the transformed modules bind), and
// applies updates. The accept/dispose/data protocol mirrors Vite's so
// solid-refresh's `esm` path (hot.data + hot.accept(cb) + hot.invalidate) works
// unchanged — a changed boundary is re-imported, and the PREVIOUS instance's
// accept callback runs with the new module namespace, patching the live registry.
//
// It also exports showErrorOverlay/clearErrorOverlay and renders a Vite-style
// full-screen compile-error overlay. A module that fails to transform is served
// as a tiny stub that imports showErrorOverlay and calls it (see errorModule in
// hmr_server.go), so the overlay pops the instant a broken module is imported —
// on first load or on a hot re-import. The overlay auto-clears once a hot-update
// batch completes without any module surfacing an error (see applyUpdates).
const hmrClientJS = `
// --- module-level HMR state, keyed by base module URL --------------------------
const hotModulesMap = new Map(); // id -> { id, callbacks: [{fn}] }
const dataMap = new Map(); // id -> persistent data object (survives reloads)
const disposeMap = new Map(); // id -> dispose callback
const declined = new Set(); // ids that opted out of HMR
export function createHotContext(id) {
if (!dataMap.has(id)) dataMap.set(id, {});
const existing = hotModulesMap.get(id);
// A fresh instance of this module is registering; clear its accept callbacks
// (applyUpdate has already snapshotted the previous instance's).
if (existing) existing.callbacks = [];
function pushAccept(fn) {
let mod = hotModulesMap.get(id);
if (!mod) { mod = { id, callbacks: [] }; hotModulesMap.set(id, mod); }
mod.callbacks.push({ fn });
}
return {
get data() { return dataMap.get(id); },
accept(deps, cb) {
// accept() | accept(fn) | accept(deps, fn) — self-accept in every form we use.
if (typeof deps === 'function' || deps == null) pushAccept(deps);
else pushAccept(cb);
},
dispose(cb) { disposeMap.set(id, cb); },
prune(cb) { disposeMap.set(id, cb); },
invalidate() { fullReload(); },
decline() { declined.add(id); },
on() {}, off() {}, send() {},
};
}
// --- recompile indicator -------------------------------------------------------
// A small, non-blocking badge so a recompile doesn't look like a frozen page:
// shown the moment an update arrives and hidden once the new module is imported
// and applied. The
// await below yields the event loop, so the spinner paints and animates while the
// server compiles.
let hmrBusy = 0;
let hmrEl = null;
function hmrIndicator() {
if (hmrEl || typeof document === 'undefined') return hmrEl;
const head = document.head || document.documentElement;
const style = document.createElement('style');
style.textContent = '@keyframes hmr-spin{to{transform:rotate(360deg)}}';
head.appendChild(style);
hmrEl = document.createElement('div');
hmrEl.setAttribute('style',
'position:fixed;bottom:14px;right:14px;z-index:2147483647;display:none;' +
'align-items:center;gap:8px;padding:7px 12px;border-radius:9px;' +
'font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e5e7eb;' +
'background:rgba(17,24,39,.92);box-shadow:0 6px 18px rgba(0,0,0,.35);' +
'pointer-events:none;user-select:none');
hmrEl.innerHTML =
'<span style="width:11px;height:11px;border-radius:50%;display:inline-block;' +
'border:2px solid rgba(148,163,184,.4);border-top-color:#60a5fa;' +
'animation:hmr-spin .6s linear infinite"></span><span data-hmr-label></span>';
(document.body || document.documentElement).appendChild(hmrEl);
return hmrEl;
}
function hmrShow(label) {
const el = hmrIndicator();
if (!el) return;
const l = el.querySelector('[data-hmr-label]');
if (l) l.textContent = label || 'recompiling…';
el.style.display = 'flex';
}
function hmrBusyStart() { hmrBusy++; hmrShow('recompiling…'); }
function hmrBusyEnd() { hmrBusy = Math.max(0, hmrBusy - 1); if (hmrBusy === 0 && hmrEl) hmrEl.style.display = 'none'; }
// --- compile-error overlay -----------------------------------------------------
// A Vite-style full-screen overlay for compile/transform failures. errorEpoch is
// bumped every time an error surfaces; applyUpdates snapshots it around a hot
// batch and clears the overlay only if the batch introduced no new error, so a
// fixed file dismisses the overlay automatically. The overlay lives in a shadow
// root so the app's stylesheet (Tailwind reset et al.) can't restyle it.
let overlayEl = null;
let errorEpoch = 0;
const HMR_OVERLAY_ID = '__hmr-error-overlay';
function escapeHTML(s) {
return String(s).replace(/[&<>]/g, function (c) {
return c === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;';
});
}
function overlayHTML(message, file) {
const css =
':host{all:initial}' +
'.backdrop{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.66);' +
'display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:6vh 20px;' +
'font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}' +
'.panel{width:100%;max-width:min(1000px,92vw);margin:auto 0;background:#1b1b1f;color:#e6e6e6;' +
'border:1px solid #ff5555;border-radius:10px;box-shadow:0 20px 60px rgba(0,0,0,.5);overflow:hidden}' +
'.head{display:flex;align-items:center;gap:10px;padding:12px 14px;background:#2a1416;' +
'border-bottom:1px solid rgba(255,85,85,.35)}' +
'.badge{color:#ff6b6b;font-weight:700;letter-spacing:.03em;text-transform:uppercase;font-size:12px}' +
'.file{color:#9aa0a6;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
'.close{margin-left:auto;background:transparent;border:0;color:#9aa0a6;cursor:pointer;' +
'font-size:16px;line-height:1;padding:4px 7px;border-radius:6px}' +
'.close:hover{color:#fff;background:rgba(255,255,255,.08)}' +
'.body{margin:0;padding:16px;white-space:pre-wrap;word-break:break-word;color:#ffb4b4;' +
'font-size:13px;max-height:62vh;overflow:auto}' +
'.hint{padding:10px 14px;border-top:1px solid rgba(255,255,255,.06);color:#7c828a;font-size:12px}';
return '<style>' + css + '</style>' +
'<div class="backdrop">' +
'<div class="panel">' +
'<div class="head">' +
'<span class="badge">Compile Error</span>' +
(file ? '<span class="file">' + escapeHTML(file) + '</span>' : '') +
'<button class="close" title="Dismiss (Esc)">✕</button>' +
'</div>' +
'<pre class="body">' + escapeHTML(message) + '</pre>' +
'<div class="hint">Fix the error and save — this overlay clears automatically.</div>' +
'</div>' +
'</div>';
}
export function showErrorOverlay(err) {
errorEpoch++;
if (typeof document === 'undefined') return;
const message = (err && (err.message || err.msg)) || String(err || 'Unknown error');
const file = (err && err.file) || '';
console.error('[hmr] compile error' + (file ? ' in ' + file : '') + '\n' + message);
clearErrorOverlay();
const host = document.createElement('div');
host.id = HMR_OVERLAY_ID;
const root = host.attachShadow ? host.attachShadow({ mode: 'open' }) : host;
root.innerHTML = overlayHTML(message, file);
const closeBtn = root.querySelector('.close');
if (closeBtn) closeBtn.addEventListener('click', clearErrorOverlay);
(document.body || document.documentElement).appendChild(host);
overlayEl = host;
}
export function clearErrorOverlay() {
if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl);
overlayEl = null;
}
if (typeof document !== 'undefined') {
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlayEl) clearErrorOverlay();
});
}
async function applyUpdate(update) {
const id = update.path;
hmrBusyStart();
try {
if (declined.has(id)) return fullReload();
const mod = hotModulesMap.get(id);
if (!mod) return fullReload(); // module not tracked yet — reload to be safe
const callbacks = mod.callbacks; // the live instance's accept callbacks
const disposer = disposeMap.get(id);
if (disposer) { try { await disposer(dataMap.get(id)); } catch (e) { console.error(e); } }
let newMod;
try {
newMod = await import(id + (id.includes('?') ? '&' : '?') + 't=' + update.timestamp);
} catch (e) {
console.error('[hmr] failed to re-import', id, e);
return fullReload();
}
for (const cb of callbacks) {
if (cb.fn) { try { cb.fn(newMod); } catch (e) { console.error(e); } }
}
console.log('[hmr] updated', id);
} finally {
hmrBusyEnd();
}
}
function updateCSS(path) {
const links = document.querySelectorAll('link[rel="stylesheet"]');
for (const link of links) {
const url = new URL(link.href, location.href);
if (url.pathname === path) {
const next = link.cloneNode();
next.href = url.pathname + '?t=' + Date.now();
next.onload = () => link.remove();
link.after(next);
console.log('[hmr] css updated', path);
return;
}
}
// The stylesheet isn't linked on this page — the dev server broadcasts CSS
// updates for both the SPA (/bundle.min.css) and public (/public.bundle.min.css)
// bundles to every client, but each page carries only one. An update for the
// other bundle is simply not applicable here, so ignore it. (Forcing a full
// reload instead would defeat the .tsx component HMR that ran moments earlier.)
}
function fullReload() { hmrShow('reloading…'); location.reload(); }
// applyUpdates runs a hot-update batch, then clears the error overlay iff no
// module surfaced a compile error while importing (errorEpoch unchanged). Module
// evaluation is synchronous within an import(), so a broken module's
// showErrorOverlay() has already run by the time its applyUpdate resolves — the
// check is race-free.
async function applyUpdates(updates) {
const before = errorEpoch;
for (const u of updates) { await applyUpdate(u); }
if (errorEpoch === before) clearErrorOverlay();
}
function handle(raw) {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
switch (msg.type) {
case 'update': applyUpdates(msg.updates || []); break;
case 'css-update': updateCSS(msg.path); break;
case 'full-reload': fullReload(); break;
case 'error': showErrorOverlay(msg.err || {}); break;
}
}
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(proto + '://' + location.host + '/@hmr/ws');
ws.addEventListener('message', (e) => handle(e.data));
ws.addEventListener('open', () => console.log('[hmr] connected'));
ws.addEventListener('close', () => { console.log('[hmr] connection lost, retrying...'); setTimeout(connect, 1000); });
ws.addEventListener('error', () => ws.close());
}
connect();
`