124 lines
4.0 KiB
TypeScript
124 lines
4.0 KiB
TypeScript
/**
|
|
* 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<void>;
|
|
}
|
|
|
|
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<Kind>();
|
|
const pendingFiles = new Set<string>();
|
|
let timer: ReturnType<typeof setTimeout> | 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();
|
|
},
|
|
};
|
|
}
|