280 lines
12 KiB
Go
280 lines
12 KiB
Go
//go:build dev
|
|
|
|
package bundler
|
|
|
|
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();
|
|
`
|