feat: keep dev server alive during HMR updates

This commit is contained in:
2026-07-13 15:17:49 +05:30
parent cf4c3723c5
commit 577c38a965
62 changed files with 281 additions and 169 deletions
+70 -58
View File
@@ -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();
},
};
}