// HMR broadcaster (Phase 3.6.1 Task 8). // // Bridges fs-watch.ts → ws.ts: classifies each change event under the design // root and emits a `canvas-hmr` WS message. The iframe-side client (inlined in // _shell.html) reacts to the message: // // - `mode: "css"` → swap href with a cache-bust query (no module // reload, no React state loss). Sub-150ms latency. // - `mode: "module"` → in-place hot-swap of the canvas module when the canvas // runtime is mounted (re-import + remount only the canvas // content; the iframe — and the live CollabProvider + // presence — stay mounted, so a cross-peer synced edit // updates seamlessly without a presence blink — Phase 30 // / F4). Falls back to location.reload() with no runtime // (gallery thumbnails) or on a re-import with no usable // default export. State (scroll / tool / undo) survives // the hot-swap path. // - `mode: "hard"` → location.reload() (used when canvas-lib.tsx or any // `_lib/**` file changes — every open canvas needs to // re-bundle). // // We deliberately do NOT try to wire Bun's `import.meta.hot.accept(...)`. Bun // supports HMR in `bun dev` mode (HTML import roots), but canvases here are // loaded via the importmap + Bun.build-produced ESM — there's no React Fast // Refresh runtime to register with. Full-reload is the reliable path. import { existsSync } from 'node:fs'; import path from 'node:path'; import type { Context } from './context.ts'; const DEBOUNCE_MS = 50; export interface HmrMessage { type: 'canvas-hmr'; mode: 'css' | 'module' | 'hard' | 'meta' | 'asset'; /** * Canvas-relative path of the file that changed, slash-normalised. Absent * when mode === 'hard' (the change is global — every canvas reloads). * * For mode === 'meta' this is the `.meta.json` path; iframe peels off * the `.meta.json` suffix to compare against its own `canvasRel`. */ file?: string; /** Cache-bust token — etag-like. Caller appends to href. */ version: number; /** Echo of `_lib`-scoped changes for debug + scope reasoning. */ scope?: 'lib' | 'canvas'; /** * The change was written by sync — the project's version landing on this * disk — not by this person's own edit. The iframe may skip a reload it * believes is the echo of an optimistic edit it already shows; it must never * skip one of these, because a teammate's change can ride the same write (a * lost race re-applied onto theirs). */ remote?: boolean; } /** How long after a sync write its file's change still counts as remote. */ export const REMOTE_WRITE_WINDOW_MS = 3_000; export interface HmrBroadcaster { /** Stop subscribing to fs-watch events. */ stop(): void; } /** * Subscribe to fs:any / fs:css events. Debounces same-path changes, classifies * the change, and forwards via `broadcast`. `broadcast` is wired to the * existing ws.ts fanout; tests inject a stub. */ export function createHmrBroadcaster( ctx: Context, broadcast: (msg: HmrMessage) => void ): HmrBroadcaster { let pending: ReturnType | null = null; // RC4 (rca/issue-canvas-hmr-optimistic-update-consistency) — one pending // message PER FILE, plus a shared bucket for file-less messages (`hard` is // global). The old single-slot pendingMsg meant a <50 ms multi-file burst // (an agent turn touching several canvases) broadcast only the LAST file: // every other open canvas missed its reload and sat stale until a manual // hard refresh. const GLOBAL_KEY = '\0global'; // NUL prefix — can't collide with an on-disk rel path const pendingByKey = new Map(); // meta is the lightest signal — it doesn't trigger a reload, just re-fetches // the sidecar. CSS still ranks above it so a same-window CSS write wins over // a meta echo; hard tops everything. const rank: Record = { meta: 0, asset: 0, css: 1, module: 2, hard: 3, }; function flush() { // A pending `hard` supersedes the per-file queue — every open canvas does a // full reload anyway, so the softer messages would be redundant churn. const hard = pendingByKey.get(GLOBAL_KEY); if (hard?.mode === 'hard') { broadcast(hard); } else { for (const msg of pendingByKey.values()) broadcast(msg); } pendingByKey.clear(); pending = null; } function classify(filename: string): HmrMessage | null { return classifyChange(filename, (cssRel) => { const root = ctx.paths?.designRoot; if (!root) return false; // no root resolved → can't probe; keep css swap return existsSync(path.join(root, cssRel.replace(/\.css$/i, '.tsx'))); }); } // Files sync just wrote (see HmrMessage.remote), with the time of the write. const projectedAt = new Map(); const isRemote = (rel: string | undefined): boolean => { if (!rel) return false; const at = projectedAt.get(rel); return at !== undefined && Date.now() - at < REMOTE_WRITE_WINDOW_MS; }; const offProjected = ctx.bus.on('sync:projected', (rel: unknown) => { if (typeof rel !== 'string' || !rel) return; const now = Date.now(); projectedAt.set(rel.replace(/\\/g, '/'), now); if (projectedAt.size > 256) { for (const [k, at] of projectedAt) if (now - at >= REMOTE_WRITE_WINDOW_MS) projectedAt.delete(k); } }); function enqueue(msg: HmrMessage) { const key = msg.mode === 'hard' ? GLOBAL_KEY : (msg.file ?? GLOBAL_KEY); const prev = pendingByKey.get(key); // Coalescing never loses "a teammate's change is in here". if (prev?.remote) msg.remote = true; // Same-key coalescing keeps the strongest mode (refreshing the payload for // equal rank, so the latest version token wins). if (!prev || rank[msg.mode] >= rank[prev.mode]) pendingByKey.set(key, msg); if (pending) clearTimeout(pending); pending = setTimeout(flush, DEBOUNCE_MS); } const offAny = ctx.bus.on('fs:any', (rel: string) => { const msg = classify(rel); if (!msg) return; if (isRemote(rel.replace(/\\/g, '/')) || isRemote(msg.file)) msg.remote = true; enqueue(msg); }); return { stop() { offAny(); offProjected(); if (pending) clearTimeout(pending); pending = null; pendingByKey.clear(); }, }; } // --------------------------------------------------------------------------- // Helpers — exported for tests. export const HMR_DEBOUNCE_MS = DEBOUNCE_MS; /** * Classify a changed design-root-relative path into an HMR message. Pure + * fs-injected (`hasSiblingTsx`) so it unit-tests without touching disk. * * CSS routing is the load-bearing part. A canvas/specimen sibling stylesheet * (e.g. `system/x/preview/motion.css` next to `motion.tsx`, pulled in via * `import './motion.css'`) is INLINED into the built module as a `