feat: keep dev server alive during HMR updates
This commit is contained in:
@@ -36,6 +36,7 @@ export interface DevStyles {
|
||||
/** A dev asset server also supports invalidating its caches in-process. */
|
||||
export interface DevAssetServer extends AssetServer {
|
||||
invalidateCss(): void;
|
||||
updateSchemas(code: string): void;
|
||||
}
|
||||
|
||||
function jsResponse(code: string): Response {
|
||||
@@ -65,12 +66,17 @@ export function createDevAssetServer(
|
||||
schemasJs?: string,
|
||||
): DevAssetServer {
|
||||
let cssCache: string | null = null;
|
||||
let schemasCode = schemasJs ?? "window.__wireSchemas={};";
|
||||
|
||||
return {
|
||||
invalidateCss() {
|
||||
cssCache = null;
|
||||
},
|
||||
|
||||
updateSchemas(code) {
|
||||
schemasCode = code;
|
||||
},
|
||||
|
||||
async serve(pathname: string): Promise<Response | null> {
|
||||
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime());
|
||||
if (pathname === "/__wrnexus/nav.js") return jsResponse(getNavRuntime());
|
||||
@@ -83,8 +89,7 @@ export function createDevAssetServer(
|
||||
if (pathname.startsWith(UPLOADS_PREFIX)) {
|
||||
return (await serveStoredFile(pathname)) ?? new Response("Not Found", { status: 404 });
|
||||
}
|
||||
if (pathname === "/__wrnexus/schemas.js")
|
||||
return jsResponse(schemasJs ?? "window.__wireSchemas={};");
|
||||
if (pathname === "/__wrnexus/schemas.js") return jsResponse(schemasCode);
|
||||
|
||||
if (pathname === "/__wrnexus/ui.css") {
|
||||
return uiCss ? cssResponse(uiCss) : new Response("Not Found", { status: 404 });
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
* { type: "css" } -> the browser hot-swaps the stylesheet (no reload)
|
||||
* { type: "reload" } -> the browser asks for fresh HTML over the HMR socket
|
||||
*
|
||||
* Server-logic changes (pages/api/middleware/realtime) are NOT broadcast here:
|
||||
* they require a fresh process, so the child exits and the supervisor respawns
|
||||
* it. The browser then reconnects and performs a soft DOM morph automatically.
|
||||
* Page/component/API/middleware/realtime changes invalidate their modules and
|
||||
* broadcast `reload` without closing the server or WebSocket. The browser asks
|
||||
* the same process for fresh HTML and performs a soft DOM morph.
|
||||
*/
|
||||
|
||||
export type HmrMessage = { type: "css"; version: number } | { type: "reload"; version: number };
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @wrnexus/dev-server — the development HTTP + WebSocket server.
|
||||
*
|
||||
* Thin Bun.serve wrapper around the shared runtime (runtime.ts). Dynamic module
|
||||
* loading makes it fast to iterate; the dev supervisor (see @wrnexus/cli)
|
||||
* restarts this process on file changes.
|
||||
* loading and targeted cache invalidation keep page/component/API edits inside
|
||||
* the running process while the HMR socket morphs fresh HTML into the browser.
|
||||
*/
|
||||
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
@@ -23,13 +23,11 @@ import { migrate, setDb, registerDb } from "@wrnexus/db";
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
import { configureStorage, type StorageConfig } from "@wrnexus/uploader";
|
||||
import { realtimeBusFromConfig } from "./realtime-bus.ts";
|
||||
import { loadModule, setCompileCacheDir } from "./pipeline.ts";
|
||||
import { invalidateModule, loadModule, setCompileCacheDir } from "./pipeline.ts";
|
||||
import { createHandlers, type WsData } from "./runtime.ts";
|
||||
import { createDevAssetServer } from "./assets.ts";
|
||||
import { HmrHub } from "./hmr.ts";
|
||||
import { startWatcher } from "./watch.ts";
|
||||
import { RESTART_EXIT_CODE } from "./restart.ts";
|
||||
|
||||
export { RESTART_EXIT_CODE } from "./restart.ts";
|
||||
|
||||
export interface ServeOptions {
|
||||
@@ -73,22 +71,46 @@ export interface RunningServer {
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/** Build a cached middleware loader for a router. */
|
||||
function middlewareLoader(router: Router): () => Promise<Middleware[]> {
|
||||
/** Build an invalidatable middleware loader for in-process hot updates. */
|
||||
function middlewareLoader(router: Router): {
|
||||
load: () => Promise<Middleware[]>;
|
||||
invalidate: () => void;
|
||||
} {
|
||||
let cache: Middleware[] | null = null;
|
||||
return async () => {
|
||||
if (cache) return cache;
|
||||
const out: Middleware[] = [];
|
||||
for (const file of router.middlewareFiles) {
|
||||
const mod = await loadModule(file);
|
||||
if (typeof mod.default === "function") out.push(mod.default as Middleware);
|
||||
else console.warn(`[wrnexus] middleware ${file} has no default export; skipped`);
|
||||
}
|
||||
cache = out;
|
||||
return out;
|
||||
return {
|
||||
async load() {
|
||||
if (cache) return cache;
|
||||
const out: Middleware[] = [];
|
||||
for (const file of router.middlewareFiles) {
|
||||
const mod = await loadModule(file);
|
||||
if (typeof mod.default === "function") out.push(mod.default as Middleware);
|
||||
else console.warn(`[wrnexus] middleware ${file} has no default export; skipped`);
|
||||
}
|
||||
cache = out;
|
||||
return out;
|
||||
},
|
||||
invalidate() {
|
||||
cache = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function schemaRuntime(router: Router): Promise<string> {
|
||||
const descriptors: Record<string, SchemaDescriptor> = {};
|
||||
for (const schemaRef of router.schemas) {
|
||||
try {
|
||||
const mod = await loadModule(schemaRef.file);
|
||||
const schema = mod.default as ObjectSchema | undefined;
|
||||
if (schema && typeof schema.describe === "function") {
|
||||
descriptors[schemaRef.name] = schema.describe();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[wrnexus] schema '${schemaRef.name}' failed to load`, error);
|
||||
}
|
||||
}
|
||||
return renderSchemasScript(descriptors);
|
||||
}
|
||||
|
||||
export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
const appDir = resolve(opts.appDir);
|
||||
const mode: Mode = opts.mode ?? "development";
|
||||
@@ -106,19 +128,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
const theme = resolveThemeConfig(opts.theme);
|
||||
const uiStyles = uiCss();
|
||||
|
||||
// Load validation schemas once at startup and bake their descriptors into the
|
||||
// client script (schemas change → the dev supervisor restarts this process).
|
||||
const descriptors: Record<string, SchemaDescriptor> = {};
|
||||
for (const s of router.schemas) {
|
||||
try {
|
||||
const mod = await loadModule(s.file);
|
||||
const schema = mod.default as ObjectSchema | undefined;
|
||||
if (schema && typeof schema.describe === "function") descriptors[s.name] = schema.describe();
|
||||
} catch (err) {
|
||||
console.warn(`[wrnexus] schema '${s.name}' failed to load`, err);
|
||||
}
|
||||
}
|
||||
const schemasJs = renderSchemasScript(descriptors);
|
||||
const schemasJs = await schemaRuntime(router);
|
||||
|
||||
// i18n is opt-in by the presence of app/locales/*.json.
|
||||
const localeMessages = loadLocales(join(appDir, "locales"));
|
||||
@@ -170,13 +180,14 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
);
|
||||
|
||||
const hub = hmr ? new HmrHub() : undefined;
|
||||
const middleware = middlewareLoader(router);
|
||||
|
||||
const handlers = createHandlers({
|
||||
const runtimeDeps = {
|
||||
mode,
|
||||
hmr,
|
||||
router,
|
||||
loadModule,
|
||||
getMiddleware: middlewareLoader(router),
|
||||
getMiddleware: middleware.load,
|
||||
assets,
|
||||
hasStyles: !!styleEntry,
|
||||
hasUi: true,
|
||||
@@ -189,7 +200,8 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
security: opts.security,
|
||||
hub,
|
||||
realtimeBus: realtimeBusFromConfig(opts.realtime),
|
||||
});
|
||||
};
|
||||
const handlers = createHandlers(runtimeDeps);
|
||||
|
||||
const server = Bun.serve<WsData>({
|
||||
port,
|
||||
@@ -200,34 +212,31 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
websocket: handlers.websocket,
|
||||
});
|
||||
|
||||
// In-process HMR: CSS edits update live; server edits (pages/components/api)
|
||||
// request a restart.
|
||||
let watcher: ReturnType<typeof startWatcher>;
|
||||
|
||||
// In-process HMR: keep the server and socket alive, invalidate only changed
|
||||
// modules, rescan file routes, and ask browsers to morph in fresh HTML.
|
||||
if (hmr && hub) {
|
||||
let restarting = false;
|
||||
const requestRestart = (): void => {
|
||||
if (restarting) return;
|
||||
restarting = true;
|
||||
console.log("[wrnexus] server change — restarting…");
|
||||
// Close the watcher and stop the server FIRST. On Windows a live recursive
|
||||
// fs.watch handle can hang `process.exit`, and stopping the server frees the
|
||||
// port so the freshly-spawned child can rebind immediately (no EADDRINUSE).
|
||||
// Without this the child would print "restarting…" but never actually exit.
|
||||
try {
|
||||
watcher?.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
const hotUpdate = async (files: string[]): Promise<void> => {
|
||||
for (const relative of files) invalidateModule(resolve(appDir, relative));
|
||||
|
||||
Object.assign(router, buildRouter(appDir, { componentDirs: [uiComponentsDir()] }));
|
||||
middleware.invalidate();
|
||||
|
||||
if (files.some((file) => file === "schemas" || file.startsWith("schemas/"))) {
|
||||
assets.updateSchemas(await schemaRuntime(router));
|
||||
}
|
||||
try {
|
||||
server.stop(true); // true = close active connections now, release the socket
|
||||
} catch {
|
||||
/* already stopping */
|
||||
if (files.some((file) => file === "locales" || file.startsWith("locales/"))) {
|
||||
const messages = loadLocales(join(appDir, "locales"));
|
||||
runtimeDeps.i18n = Object.keys(messages).length
|
||||
? resolveI18n(messages, opts.i18n)
|
||||
: undefined;
|
||||
}
|
||||
// Let close callbacks and stdio flush, then force the exit if any handle
|
||||
// remains alive. This is especially important on Windows file watching.
|
||||
process.exitCode = RESTART_EXIT_CODE;
|
||||
setTimeout(() => process.exit(RESTART_EXIT_CODE), 250).unref();
|
||||
|
||||
console.log(`[wrnexus] hot update — ${files.join(", ")}`);
|
||||
hub.reload();
|
||||
};
|
||||
const watcher = startWatcher({ appDir, hub, assets, onServerChange: requestRestart });
|
||||
watcher = startWatcher({ appDir, hub, assets, onHotChange: hotUpdate });
|
||||
}
|
||||
|
||||
const boundPort = server.port ?? port;
|
||||
@@ -236,7 +245,10 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
hostname,
|
||||
url: `http://${displayHost}:${boundPort}`,
|
||||
router,
|
||||
stop: () => server.stop(),
|
||||
stop: () => {
|
||||
watcher?.close();
|
||||
server.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,15 @@
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
|
||||
import { dirname, join, basename } from "node:path";
|
||||
import {
|
||||
copyFileSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, basename, extname } from "node:path";
|
||||
import { compileWireFile } from "@wrnexus/compiler";
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
@@ -40,14 +47,36 @@ export function runMiddleware(
|
||||
* paths discovered during the startup scan — never from request input.
|
||||
*/
|
||||
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
||||
const moduleVersions = new Map<string, number>();
|
||||
|
||||
export function loadModule(file: string): Promise<Record<string, unknown>> {
|
||||
let mod = moduleCache.get(file);
|
||||
if (!mod) {
|
||||
const version = moduleVersions.get(file) ?? 0;
|
||||
// `.wrn` files are compiled to TypeScript first, then imported.
|
||||
const target = file.endsWith(".wrn") ? compileWireToTs(file) : file;
|
||||
let target = file.endsWith(".wrn") ? compileWireToTs(file, version) : file;
|
||||
let temporary = false;
|
||||
// Bun intentionally caches local TS/JS modules by filesystem path and ignores
|
||||
// URL query strings. A short-lived versioned sibling keeps relative imports
|
||||
// correct while giving the changed module a genuinely new import identity.
|
||||
if (version && !file.endsWith(".wrn")) {
|
||||
const extension = extname(file);
|
||||
const stem = basename(file, extension);
|
||||
target = join(dirname(file), `${stem}.wrnexus-hmr-${version}${extension}`);
|
||||
copyFileSync(file, target);
|
||||
temporary = true;
|
||||
}
|
||||
// pathToFileURL handles Windows drive letters and spaces correctly.
|
||||
mod = import(pathToFileURL(target).href) as Promise<Record<string, unknown>>;
|
||||
if (temporary) {
|
||||
mod = mod.finally(() => {
|
||||
try {
|
||||
unlinkSync(target);
|
||||
} catch {
|
||||
/* best-effort cleanup after Bun has loaded the module */
|
||||
}
|
||||
});
|
||||
}
|
||||
moduleCache.set(file, mod);
|
||||
}
|
||||
return mod;
|
||||
@@ -86,10 +115,11 @@ function hashPath(s: string): string {
|
||||
* components) share one cache dir without colliding. Generated modules are
|
||||
* self-contained (no relative imports), so the cache location doesn't affect them.
|
||||
*/
|
||||
function compileWireToTs(file: string): string {
|
||||
function compileWireToTs(file: string, version = 0): string {
|
||||
const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus");
|
||||
const name = basename(file).replace(/\.wrn$/, "");
|
||||
const out = join(cacheDir, `${name}-${hashPath(file)}.wrn.ts`);
|
||||
const suffix = version ? `-hmr-${version}` : "";
|
||||
const out = join(cacheDir, `${name}-${hashPath(file)}${suffix}.wrn.ts`);
|
||||
|
||||
// Skip recompiling when the on-disk cache is already newer than the source
|
||||
// (e.g. reused across dev restarts) — avoids a read + compile + write.
|
||||
@@ -105,7 +135,16 @@ function compileWireToTs(file: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Forget one module and force its next dynamic import to bypass Bun's import cache. */
|
||||
export function invalidateModule(file: string): void {
|
||||
moduleCache.delete(file);
|
||||
moduleVersions.set(file, (moduleVersions.get(file) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/** Forget cached modules (used by build/dev tooling if needed). */
|
||||
export function clearModuleCache(): void {
|
||||
for (const file of moduleCache.keys()) {
|
||||
moduleVersions.set(file, (moduleVersions.get(file) ?? 0) + 1);
|
||||
}
|
||||
moduleCache.clear();
|
||||
}
|
||||
|
||||
@@ -410,8 +410,8 @@ export const STYLES_HREF = "/__wrnexus/styles.css";
|
||||
/**
|
||||
* Inline HMR client (WebSocket). Goals:
|
||||
* - CSS change -> hot-swap the stylesheet, zero reload, zero flash.
|
||||
* - markup/page -> after the server restarts, ask over the HMR WebSocket for
|
||||
* fresh HTML and MORPH the live DOM in place.
|
||||
* - markup/page -> ask over the existing HMR WebSocket for fresh HTML and
|
||||
* MORPH the live DOM in place, without restarting the server.
|
||||
* - island code -> same WebSocket sync path; no location.reload().
|
||||
*/
|
||||
export const HMR_CLIENT_JS = `
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
* cheapest update that still shows the latest page:
|
||||
*
|
||||
* *.css / styles/ -> invalidate CSS cache, push { type: "css" } (instant swap)
|
||||
* anything else -> a server module changed (pages, components, api, …):
|
||||
* it can't be re-imported in process, so request a
|
||||
* restart (the supervisor respawns us; the browser then
|
||||
* morphs in the new HTML).
|
||||
* 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 { watch, type FSWatcher } from "node:fs";
|
||||
@@ -17,14 +16,15 @@ export interface WatchOptions {
|
||||
appDir: string;
|
||||
hub: HmrHub;
|
||||
assets: DevAssetServer;
|
||||
/** Called when a change requires a fresh process. */
|
||||
onServerChange: () => void;
|
||||
/** 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/")
|
||||
);
|
||||
@@ -37,19 +37,22 @@ function classify(rel: string): Kind {
|
||||
return "server";
|
||||
}
|
||||
|
||||
/** Returns the watcher so the caller can close it before a restart (important on
|
||||
* Windows, where a live recursive fs.watch handle can block `process.exit`). */
|
||||
/** Returns the watcher so the running server can close it during shutdown. */
|
||||
export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
|
||||
const { appDir, hub, assets, onServerChange } = opts;
|
||||
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;
|
||||
// A server change always wins (needs a restart).
|
||||
if (pending.has("server")) {
|
||||
pending.clear();
|
||||
onServerChange();
|
||||
const files = [...pendingFiles];
|
||||
pendingFiles.clear();
|
||||
void Promise.resolve(opts.onHotChange(files)).catch((error) => {
|
||||
console.error("[wrnexus] hot update failed", error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pending.has("css")) {
|
||||
@@ -57,6 +60,7 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
|
||||
hub.css();
|
||||
}
|
||||
pending.clear();
|
||||
pendingFiles.clear();
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -64,6 +68,7 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
|
||||
if (!filename) return;
|
||||
const rel = filename.toString().replace(/\\/g, "/");
|
||||
if (isIgnored(rel)) return;
|
||||
pendingFiles.add(rel);
|
||||
pending.add(classify(rel));
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(flush, 60); // debounce editor write bursts
|
||||
|
||||
Reference in New Issue
Block a user