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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT",
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT",
@@ -28,16 +28,16 @@
"wrnexus": "./dist/index.js"
},
"dependencies": {
"@wrnexus/core": "^0.2.20",
"@wrnexus/router": "^0.2.20",
"@wrnexus/csr": "^0.2.20",
"@wrnexus/compiler": "^0.2.20",
"@wrnexus/styles": "^0.2.20",
"@wrnexus/dev-server": "^0.2.20",
"@wrnexus/ui": "^0.2.20",
"@wrnexus/validation": "^0.2.20",
"@wrnexus/i18n": "^0.2.20",
"@wrnexus/db": "^0.2.20"
"@wrnexus/core": "^0.2.21",
"@wrnexus/router": "^0.2.21",
"@wrnexus/csr": "^0.2.21",
"@wrnexus/compiler": "^0.2.21",
"@wrnexus/styles": "^0.2.21",
"@wrnexus/dev-server": "^0.2.21",
"@wrnexus/ui": "^0.2.21",
"@wrnexus/validation": "^0.2.21",
"@wrnexus/i18n": "^0.2.21",
"@wrnexus/db": "^0.2.21"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.20"
"@wrnexus/core": "^0.2.21"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -64,7 +64,7 @@ interface RunningServer {
}
```
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live; any other server change triggers `process.exit(RESTART_EXIT_CODE)` so the dev supervisor (`@wrnexus/cli`) respawns the process with fresh modules.
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running.
### `createHandlers(deps)`
@@ -142,7 +142,7 @@ interface ProdOptions {
### `startGateway(opts)` — multi-app gateway
Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. In development, the gateway supervises every child: editing a page, component, API, or middleware restarts only that app and the HMR client reconnects to display the latest page. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. In development, normal application edits are applied inside the existing child and sent through its existing HMR connection. The child supervisor remains as crash recovery rather than the normal update path. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
```ts
interface GatewayOptions {
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT",
@@ -25,18 +25,18 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.20",
"@wrnexus/router": "^0.2.20",
"@wrnexus/ssr": "^0.2.20",
"@wrnexus/csr": "^0.2.20",
"@wrnexus/compiler": "^0.2.20",
"@wrnexus/styles": "^0.2.20",
"@wrnexus/ui": "^0.2.20",
"@wrnexus/validation": "^0.2.20",
"@wrnexus/i18n": "^0.2.20",
"@wrnexus/db": "^0.2.20",
"@wrnexus/pubsub": "^0.2.20",
"@wrnexus/uploader": "^0.2.20"
"@wrnexus/core": "^0.2.21",
"@wrnexus/router": "^0.2.21",
"@wrnexus/ssr": "^0.2.21",
"@wrnexus/csr": "^0.2.21",
"@wrnexus/compiler": "^0.2.21",
"@wrnexus/styles": "^0.2.21",
"@wrnexus/ui": "^0.2.21",
"@wrnexus/validation": "^0.2.21",
"@wrnexus/i18n": "^0.2.21",
"@wrnexus/db": "^0.2.21",
"@wrnexus/pubsub": "^0.2.21",
"@wrnexus/uploader": "^0.2.21"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.20"
"@wrnexus/core": "^0.2.21"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.20"
"@wrnexus/core": "^0.2.21"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/native": "^0.2.20"
"@wrnexus/native": "^0.2.21"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT",
@@ -21,8 +21,8 @@
}
},
"dependencies": {
"@wrnexus/compiler": "^0.2.20",
"@wrnexus/core": "^0.2.20"
"@wrnexus/compiler": "^0.2.21",
"@wrnexus/core": "^0.2.21"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.20"
"@wrnexus/core": "^0.2.21"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/uploader": "^0.2.20"
"@wrnexus/uploader": "^0.2.21"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT",
@@ -22,7 +22,7 @@
"./ui.css": "./ui.css"
},
"dependencies": {
"@wrnexus/core": "^0.2.20"
"@wrnexus/core": "^0.2.21"
},
"files": [
"dist",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.20"
"@wrnexus/core": "^0.2.21"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+8
View File
@@ -264,6 +264,14 @@ const MIGRATIONS: Migration[] = [
if (!ctx.dryRun) writeFileSync(file, VSCODE_EXTENSIONS, "utf8");
},
},
{
version: "0.2.21",
id: "in-process-hmr-runtime",
description: "No project-file changes; the in-process HMR behavior is runtime-only",
apply() {
// Explicit no-op: updating @wrnexus/dev-server is sufficient.
},
},
];
/** Release tooling uses this to require an explicit migration entry per version. */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+2 -2
View File
@@ -64,7 +64,7 @@ interface RunningServer {
}
```
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live; any other server change triggers `process.exit(RESTART_EXIT_CODE)` so the dev supervisor (`@wrnexus/cli`) respawns the process with fresh modules.
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running.
### `createHandlers(deps)`
@@ -142,7 +142,7 @@ interface ProdOptions {
### `startGateway(opts)` — multi-app gateway
Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. In development, the gateway supervises every child: editing a page, component, API, or middleware restarts only that app and the HMR client reconnects to display the latest page. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. In development, normal application edits are applied inside the existing child and sent through its existing HMR connection. The child supervisor remains as crash recovery rather than the normal update path. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
```ts
interface GatewayOptions {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+7 -2
View File
@@ -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 });
+3 -3
View File
@@ -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 };
+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();
},
};
}
+44 -5
View File
@@ -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();
}
+2 -2
View File
@@ -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 = `
+16 -11
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
import { expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { invalidateModule, loadModule, setCompileCacheDir } from "../src/pipeline.ts";
test("invalidateModule loads changed server modules without restarting the process", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-hmr-"));
const file = join(root, "page.ts");
setCompileCacheDir(join(root, ".wrnexus"));
try {
writeFileSync(file, "export const value = 1;\n");
expect((await loadModule(file)).value).toBe(1);
writeFileSync(file, "export const value = 2;\n");
expect((await loadModule(file)).value).toBe(1);
invalidateModule(file);
expect((await loadModule(file)).value).toBe(2);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("invalidateModule recompiles changed WRN files in-process", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-hmr-"));
const file = join(root, "page.wrn");
setCompileCacheDir(join(root, ".wrnexus"));
try {
writeFileSync(file, "page Home { view { <h1>First</h1> } }\n");
const first = (await loadModule(file)).default as (ctx: unknown) => unknown;
expect(String(first({}))).toContain("First");
writeFileSync(file, "page Home { view { <h1>Second</h1> } }\n");
invalidateModule(file);
const second = (await loadModule(file)).default as (ctx: unknown) => unknown;
expect(String(second({}))).toContain("Second");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.2.20",
"version": "0.2.21",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.20",
"version": "0.2.21",
"private": true,
"type": "module",
"main": "src/index.ts",