rename packages, refactor out tailwind compiler,
This commit is contained in:
279
go/webbundler/hmr_client.go
Normal file
279
go/webbundler/hmr_client.go
Normal 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 === '&' ? '&' : c === '<' ? '<' : '>';
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
`
|
||||
Reference in New Issue
Block a user