diff --git a/.publish/ai/package.json b/.publish/ai/package.json index 170794bd..52a3391a 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -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", diff --git a/.publish/authz/package.json b/.publish/authz/package.json index 7a2256a2..c8db34f6 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -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", diff --git a/.publish/cli/package.json b/.publish/cli/package.json index 90ee6a8b..f12c908c 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -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" diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index 1d705191..1722e0a6 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -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", diff --git a/.publish/core/package.json b/.publish/core/package.json index d250d5d3..91fdbe3d 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -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", diff --git a/.publish/csr/package.json b/.publish/csr/package.json index 32bb0907..5b77ef41 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -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" diff --git a/.publish/db/package.json b/.publish/db/package.json index 03711942..4b631296 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -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", diff --git a/.publish/dev-server/README.md b/.publish/dev-server/README.md index 6e45c204..7d910ce3 100644 --- a/.publish/dev-server/README.md +++ b/.publish/dev-server/README.md @@ -64,7 +64,7 @@ interface RunningServer { } ``` -In development, `startServer` also connects `app/db/migrations` (and `app/db//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//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 { diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json index 22849808..1ae3eff0 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -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" diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index ac5da05b..165bfcec 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -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", diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json index 61b82f59..1c143c71 100644 --- a/.publish/helpers/package.json +++ b/.publish/helpers/package.json @@ -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" diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index e5af5b50..5a69394d 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -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" diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index dd27a1d4..ef58102a 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -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", diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json index 7625e7c1..e81b295e 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -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" diff --git a/.publish/native/package.json b/.publish/native/package.json index a955479c..992b0465 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -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", diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json index 77397514..cab596ef 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -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", diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json index 2cd0184b..72d5f762 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -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", diff --git a/.publish/queue/package.json b/.publish/queue/package.json index 3cf321e4..f1596cf9 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -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", diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json index a059c96e..6e9d86ba 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -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", diff --git a/.publish/router/package.json b/.publish/router/package.json index 4e2b6b68..7317eede 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -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" diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 1ac58b71..529c519b 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -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" diff --git a/.publish/styles/package.json b/.publish/styles/package.json index 62d9d81c..306fe2b6 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -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" diff --git a/.publish/test/package.json b/.publish/test/package.json index 50f8d71e..bae8af16 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -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", diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json index bd3b0f5f..27fa0047 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -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", diff --git a/.publish/ui/package.json b/.publish/ui/package.json index 074c516f..b7f3b6b9 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -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", diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index 1e037c07..dc48936c 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -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" diff --git a/.publish/validation/package.json b/.publish/validation/package.json index 08280fd0..4e309def 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -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", diff --git a/packages/ai/package.json b/packages/ai/package.json index 8b91d6d7..b7e40238 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -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.", diff --git a/packages/authz/package.json b/packages/authz/package.json index f771e2c5..ff254dd0 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2017debf..793e4ec1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 5ef1b967..3809d62a 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -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. */ diff --git a/packages/compiler/package.json b/packages/compiler/package.json index cdc412be..8b29e32b 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/package.json b/packages/core/package.json index e22af1d6..04f8adb6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/package.json b/packages/csr/package.json index 46d8a6dc..1c647341 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index 001f53c8..e6ce4522 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/dev-server/README.md b/packages/dev-server/README.md index 6e45c204..7d910ce3 100644 --- a/packages/dev-server/README.md +++ b/packages/dev-server/README.md @@ -64,7 +64,7 @@ interface RunningServer { } ``` -In development, `startServer` also connects `app/db/migrations` (and `app/db//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//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 { diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index e4befd4b..ed6487c0 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/assets.ts b/packages/dev-server/src/assets.ts index 7dfa46da..d8efbe46 100644 --- a/packages/dev-server/src/assets.ts +++ b/packages/dev-server/src/assets.ts @@ -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 { 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 }); diff --git a/packages/dev-server/src/hmr.ts b/packages/dev-server/src/hmr.ts index 7627255d..5859a004 100644 --- a/packages/dev-server/src/hmr.ts +++ b/packages/dev-server/src/hmr.ts @@ -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 }; diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 00855b2f..71c99cd2 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -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 { +/** Build an invalidatable middleware loader for in-process hot updates. */ +function middlewareLoader(router: Router): { + load: () => Promise; + 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 { + const descriptors: Record = {}; + 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 { const appDir = resolve(opts.appDir); const mode: Mode = opts.mode ?? "development"; @@ -106,19 +128,7 @@ export async function startServer(opts: ServeOptions): Promise { 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 = {}; - 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 { ); 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 { security: opts.security, hub, realtimeBus: realtimeBusFromConfig(opts.realtime), - }); + }; + const handlers = createHandlers(runtimeDeps); const server = Bun.serve({ port, @@ -200,34 +212,31 @@ export async function startServer(opts: ServeOptions): Promise { websocket: handlers.websocket, }); - // In-process HMR: CSS edits update live; server edits (pages/components/api) - // request a restart. + let watcher: ReturnType; + + // 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 => { + 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 { hostname, url: `http://${displayHost}:${boundPort}`, router, - stop: () => server.stop(), + stop: () => { + watcher?.close(); + server.stop(); + }, }; } diff --git a/packages/dev-server/src/pipeline.ts b/packages/dev-server/src/pipeline.ts index 4fccbb54..dcabb93d 100644 --- a/packages/dev-server/src/pipeline.ts +++ b/packages/dev-server/src/pipeline.ts @@ -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>>(); +const moduleVersions = new Map(); export function loadModule(file: string): Promise> { 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>; + 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(); } diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index a9770c05..292470ce 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -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 = ` diff --git a/packages/dev-server/src/watch.ts b/packages/dev-server/src/watch.ts index aef5b0ea..9fdb2734 100644 --- a/packages/dev-server/src/watch.ts +++ b/packages/dev-server/src/watch.ts @@ -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; } 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(); + const pendingFiles = new Set(); let timer: ReturnType | 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 diff --git a/packages/dev-server/test/pipeline.test.ts b/packages/dev-server/test/pipeline.test.ts new file mode 100644 index 00000000..91253b31 --- /dev/null +++ b/packages/dev-server/test/pipeline.test.ts @@ -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 {

First

} }\n"); + const first = (await loadModule(file)).default as (ctx: unknown) => unknown; + expect(String(first({}))).toContain("First"); + + writeFileSync(file, "page Home { view {

Second

} }\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 }); + } +}); diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 7b12af0c..d1f247fd 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 0bd7fbf7..53f814ed 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -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.", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 0c466b81..f5454b7c 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/jwt/package.json b/packages/jwt/package.json index 7a3ef6e4..86b2c053 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index de99980e..4bff2aee 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index f41abd71..1ac62da3 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 466c1ab7..fbc3ada2 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index d8142ce0..76576689 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index 788c107b..f2558285 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/reactive/package.json b/packages/reactive/package.json index e65737f4..4f977482 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/router/package.json b/packages/router/package.json index b58389bc..363d5a1e 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/package.json b/packages/ssr/package.json index 3d196338..aefd734c 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/package.json b/packages/styles/package.json index 6f3167a1..4e8fadf8 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.20", + "version": "0.2.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/test/package.json b/packages/test/package.json index ef2a952a..2f5fab51 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/tracking/package.json b/packages/tracking/package.json index 8c1a1958..6a363d42 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index 0c4cecce..d7b5d596 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/uploader/package.json b/packages/uploader/package.json index 0f52bd81..690ac6ff 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/validation/package.json b/packages/validation/package.json index 4c14b44b..192a19ac 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.20", + "version": "0.2.21", "private": true, "type": "module", "main": "src/index.ts",