feat: restore gateway HMR and add WRN formatting
This commit is contained in:
@@ -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. 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, 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).
|
||||
|
||||
```ts
|
||||
interface GatewayOptions {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.2.18",
|
||||
"version": "0.2.19",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { RESTART_EXIT_CODE } from "./restart.ts";
|
||||
|
||||
/** Per-app access control, enforced at the gateway before proxying. */
|
||||
export interface GatewayAuth {
|
||||
@@ -71,7 +72,7 @@ export interface RunningGateway {
|
||||
interface Target extends GatewayApp {
|
||||
port: number;
|
||||
origin: string;
|
||||
child: ChildProcess;
|
||||
child?: ChildProcess;
|
||||
}
|
||||
|
||||
interface WsBridge {
|
||||
@@ -81,6 +82,18 @@ interface WsBridge {
|
||||
queue: (string | ArrayBufferLike | ArrayBufferView)[];
|
||||
}
|
||||
|
||||
/** Decide whether a gateway child should be relaunched after it exits. */
|
||||
export function gatewayRestartDelay(
|
||||
mode: "development" | "production",
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): number | null {
|
||||
if (mode !== "development" || signal) return null;
|
||||
if (code === RESTART_EXIT_CODE) return 0;
|
||||
if (code && code !== 0) return 1200;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fixed-window rate limiter keyed by client IP. */
|
||||
function makeRateLimiter(max: number, windowMs: number) {
|
||||
const hits = new Map<string, { count: number; reset: number }>();
|
||||
@@ -196,9 +209,9 @@ function applyEdgeHeaders(res: Response): Response {
|
||||
async function waitReady(target: Target, timeoutMs = 15000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
if (target.child.exitCode !== null) {
|
||||
if (!target.child || target.child.exitCode !== null) {
|
||||
throw new Error(
|
||||
`app '${target.name}' exited with code ${target.child.exitCode} during startup`,
|
||||
`app '${target.name}' exited with code ${target.child?.exitCode ?? "unknown"} during startup`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -245,28 +258,52 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
||||
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
|
||||
const serveEntry = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
|
||||
|
||||
let stopping = false;
|
||||
const targets: Target[] = opts.apps.map((app, i) => {
|
||||
const appPort = app.port ?? port + 1 + i;
|
||||
const dir = resolve(app.dir);
|
||||
const child =
|
||||
mode === "production"
|
||||
? spawn(process.execPath, [join(dir, "dist", "server.js")], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, PORT: String(appPort) },
|
||||
})
|
||||
: spawn(
|
||||
process.execPath,
|
||||
[serveEntry, join(dir, "app"), String(appPort), mode, "127.0.0.1"],
|
||||
{
|
||||
const target: Target = {
|
||||
...app,
|
||||
port: appPort,
|
||||
origin: `http://127.0.0.1:${appPort}`,
|
||||
};
|
||||
|
||||
const launch = (): void => {
|
||||
if (stopping) return;
|
||||
const child =
|
||||
mode === "production"
|
||||
? spawn(process.execPath, [join(dir, "dist", "server.js")], {
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
return { ...app, port: appPort, origin: `http://127.0.0.1:${appPort}`, child };
|
||||
env: { ...process.env, PORT: String(appPort) },
|
||||
})
|
||||
: spawn(
|
||||
process.execPath,
|
||||
[serveEntry, join(dir, "app"), String(appPort), mode, "127.0.0.1"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
target.child = child;
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
if (stopping || target.child !== child) return;
|
||||
const delay = gatewayRestartDelay(mode, code, signal);
|
||||
if (delay === null) return;
|
||||
if (delay > 0) {
|
||||
console.error(`[wrnexus] app '${target.name}' exited (code ${code}); retrying in 1.2s…`);
|
||||
setTimeout(launch, delay);
|
||||
} else {
|
||||
launch();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
launch();
|
||||
return target;
|
||||
});
|
||||
|
||||
const stopChildren = () => {
|
||||
stopping = true;
|
||||
for (const target of targets) {
|
||||
if (target.child.exitCode === null) target.child.kill();
|
||||
if (target.child?.exitCode === null) target.child.kill();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ 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";
|
||||
|
||||
/** Exit code the child uses to ask the dev supervisor for a fresh process. */
|
||||
export const RESTART_EXIT_CODE = 97;
|
||||
export { RESTART_EXIT_CODE } from "./restart.ts";
|
||||
|
||||
export interface ServeOptions {
|
||||
appDir: string;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Exit code a dev-server child uses to request a clean supervisor restart. */
|
||||
export const RESTART_EXIT_CODE = 97;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
forwardAuthFailure,
|
||||
forwardAuthHeaders,
|
||||
gatewayProxyHeaders,
|
||||
gatewayRestartDelay,
|
||||
} from "../src/gateway.ts";
|
||||
|
||||
test("gateway uses platform-safe hostname defaults", () => {
|
||||
@@ -56,3 +57,11 @@ test("forward auth describes the original gateway request", () => {
|
||||
expect(headers.get("cookie")).toBe("session=abc");
|
||||
expect(headers.get("authorization")).toBe("Bearer token");
|
||||
});
|
||||
|
||||
test("gateway respawns development apps after an HMR restart exit", () => {
|
||||
expect(gatewayRestartDelay("development", 97, null)).toBe(0);
|
||||
expect(gatewayRestartDelay("development", 1, null)).toBe(1200);
|
||||
expect(gatewayRestartDelay("development", 0, null)).toBeNull();
|
||||
expect(gatewayRestartDelay("development", 97, "SIGTERM")).toBeNull();
|
||||
expect(gatewayRestartDelay("production", 97, null)).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user