/** * In-process file watcher (dev). Classifies each change and chooses the * cheapest update that still shows the latest page: * * *.css / styles/ -> invalidate CSS cache, push { type: "css" } (instant swap) * anything else -> invalidate changed modules, rescan routes, and ask * the connected browser to sync fresh HTML. The server * process and HMR socket stay alive. */ import { existsSync, statSync, watch, type FSWatcher } from "node:fs"; import { isAbsolute, join, relative, resolve } from "node:path"; import type { HmrHub } from "./hmr.ts"; import type { DevAssetServer } from "./assets.ts"; import type { DevToolbarCollector } from "@wrnexus/dev-toolbar/server"; export interface WatchOptions { appDir: string; /** Additional package component/runtime/style directories watched for HMR. */ extraDirs?: string[]; hub: HmrHub; assets: DevAssetServer; devToolbarCollector?: DevToolbarCollector; /** Called with changed app-relative files that need an in-process hot update. */ onHotChange: (files: string[]) => void | Promise; } function isIgnored(rel: string): boolean { return ( rel.includes("node_modules/") || rel.includes(".wrnexus/") || rel.includes(".wrnexus-hmr-") || rel.startsWith("dist/") || rel.includes("/dist/") ); } type Kind = "css" | "server"; function classify(rel: string): Kind { if (rel.endsWith(".css") || rel.startsWith("styles/") || rel.includes("/styles/")) return "css"; return "server"; } export interface WatchHandle { close(): void; } /** Returns a composite watcher so the running server can close every source root. */ export function startWatcher(opts: WatchOptions): WatchHandle | undefined { const { appDir, hub, assets } = opts; const pending = new Set(); const pendingFiles = new Set(); let timer: ReturnType | null = null; const flush = (): void => { timer = null; if (pending.has("server")) { pending.clear(); const files = [...pendingFiles]; pendingFiles.clear(); for (const file of files) opts.devToolbarCollector?.clear(file); void Promise.resolve(opts.onHotChange(files)).catch((error) => { console.error("[wrnexus] hot update failed", error); }); return; } if (pending.has("css")) { assets.invalidateCss(); hub.css(); hub.broadcastJson({ channel: "toolbar", type: "toolbar:scan", reason: "styles-change", files: [...pendingFiles], }); } pending.clear(); pendingFiles.clear(); }; const appRoot = resolve(appDir); const candidates = [appRoot, ...(opts.extraDirs ?? []).map((dir) => resolve(dir))]; const roots = [...new Set(candidates)] .filter((dir) => existsSync(dir) && statSync(dir).isDirectory()) .filter( (dir, index, values) => !values.some((other, otherIndex) => { if (otherIndex >= index) return false; const nested = relative(other, dir); return nested === "" || (!nested.startsWith("..") && !isAbsolute(nested)); }), ); const watchers: FSWatcher[] = []; for (const root of roots) { try { const external = root !== appRoot; watchers.push( watch(root, { recursive: true }, (_event, filename) => { if (!filename) return; const relativeFile = filename.toString().replace(/\\/g, "/"); if (isIgnored(relativeFile)) return; const file = external ? join(root, relativeFile).replace(/\\/g, "/") : relativeFile; pendingFiles.add(file); pending.add(classify(file)); if (timer) clearTimeout(timer); timer = setTimeout(flush, 200); // debounce editor write bursts }), ); } catch (error) { console.warn(`[wrnexus] file watching unavailable for ${root}`, error); } } if (!watchers.length) return undefined; return { close() { if (timer) clearTimeout(timer); for (const watcher of watchers) watcher.close(); }, }; }