Files
WRNexusJS/packages/dev-server/src/gateway.ts
T
2026-07-14 20:01:16 +05:30

583 lines
19 KiB
TypeScript

/**
* The multi-app **gateway** — serves several WrNexus apps behind one port and
* routes each request to the right app by its `Host` header (domain). This is how
* a monorepo becomes a multi-domain SaaS: `app-a.com` → apps/a, `app-b.com` → apps/b.
*
* Each app runs as its own **process** (full isolation — its own database
* registry, pubsub, in-memory state), and the gateway is a thin host-based
* reverse proxy for both HTTP and WebSocket. Apps talk to each other at runtime
* via @wrnexus/pubsub (use the Redis driver so messages cross processes).
*/
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";
export type GatewayForwardAuth = (
| {
url: string;
app?: never;
path?: never;
}
| {
app: string;
path?: string;
url?: never;
}
) & {
headers?: string[];
};
export interface GatewayAuth {
basic?: { user: string; pass: string } | Array<{ user: string; pass: string }>;
allowIps?: string[];
forward?: GatewayForwardAuth;
}
export interface GatewayApp {
/** App id (for logs). */
name: string;
/** Path to the app root (the dir containing `app/` and wrnexus.config.ts). */
dir: string;
/** Host names routed to this app (e.g. ["localhost", "web.localhost"]). */
domains: string[];
publicOrigin?: string;
/** Optional fixed internal port; otherwise assigned from the gateway port. */
port?: number;
/** Access control enforced at the edge for this app. */
auth?: GatewayAuth;
}
/** Gateway-wide security controls, enforced for every app. */
export interface GatewaySecurity {
/** Reject requests whose Host matches no app (404) instead of routing to the first. */
trustedHostsOnly?: boolean;
/** Global rate limit by client IP (429 over the limit). */
rateLimit?: { max: number; windowMs?: number };
/** Add baseline security headers to responses (only where the app didn't set them). */
headers?: boolean;
/** Set X-Forwarded-For/Host/Proto so apps see the real client. Default true. */
forwardedHeaders?: boolean;
/** Log each request (host → app, method, path, status). */
accessLog?: boolean;
}
export interface GatewayOptions {
port?: number;
hostname?: string;
mode?: "development" | "production";
environment?: string;
apps: GatewayApp[];
security?: GatewaySecurity;
}
export interface RunningGateway {
port: number;
url: string;
stop(): void;
}
interface Target extends GatewayApp {
port: number;
origin: string;
child?: ChildProcess;
}
interface WsBridge {
origin: string;
path: string;
backend?: WebSocket;
queue: Array<string | ArrayBuffer>;
}
/** 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 }>();
return (ip: string, now: number): boolean => {
const b = hits.get(ip);
if (!b || now >= b.reset) {
hits.set(ip, { count: 1, reset: now + windowMs });
return true;
}
b.count++;
return b.count <= max;
};
}
/** Constant-time-ish string compare. */
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
/** Preserve an intentional verifier redirect while keeping other failures opaque. */
export function forwardAuthFailure(res: Response, verifierUrl: string): Response {
const location = res.headers.get("location");
if (res.status >= 300 && res.status < 400 && location) {
try {
const redirect = new URL(location, verifierUrl);
if (redirect.protocol === "http:" || redirect.protocol === "https:") {
return new Response(null, { status: res.status, headers: { location: redirect.href } });
}
} catch {
// Malformed or unsafe redirects fail closed below.
}
}
return new Response("Unauthorized", { status: res.status === 200 ? 401 : res.status });
}
/** Describe the original gateway request to a forward-auth verifier. */
export function forwardAuthHeaders(req: Request): Headers {
const original = new URL(req.url);
const host = req.headers.get("host") ?? original.host;
const protocol = original.protocol.replace(":", "");
return new Headers({
cookie: req.headers.get("cookie") ?? "",
authorization: req.headers.get("authorization") ?? "",
"x-forwarded-host": host,
"x-forwarded-proto": protocol,
// These survive when a verifier such as sso.localhost is routed through
// this gateway again. The second hop may replace X-Forwarded-Host with the
// verifier host, but must not lose the protected application's return URL.
"x-original-host": host,
"x-original-proto": protocol,
"x-original-method": req.method,
"x-original-uri": `${original.pathname}${original.search}`,
});
}
/**
* Enforce a per-app auth policy. Returns a Response to block, or null to allow.
* `ip` is the client address (for the IP allowlist).
*/
async function checkAuth(
auth: GatewayAuth | undefined,
req: Request,
ip: string,
internalOrigins: Readonly<Record<string, string>>,
): Promise<Response | null> {
if (!auth) return null;
if (auth.allowIps && !auth.allowIps.includes(ip)) {
return new Response("Forbidden", { status: 403 });
}
if (auth.basic) {
const pairs = Array.isArray(auth.basic) ? auth.basic : [auth.basic];
const header = req.headers.get("authorization") ?? "";
const ok =
header.startsWith("Basic ") &&
(() => {
const [user, pass] = atob(header.slice(6)).split(":", 2);
return pairs.some(
(p) => timingSafeEqual(user ?? "", p.user) && timingSafeEqual(pass ?? "", p.pass),
);
})();
if (!ok) {
return new Response("Authentication required", {
status: 401,
headers: { "www-authenticate": 'Basic realm="Restricted"' },
});
}
}
if (auth.forward) {
const verifyUrl = resolveForwardAuthUrl(auth.forward, internalOrigins);
try {
const res = await fetch(verifyUrl, {
headers: forwardAuthHeaders(req),
redirect: "manual",
});
if (!res.ok) return forwardAuthFailure(res, verifyUrl);
} catch {
return new Response("Auth service unavailable", { status: 503 });
}
}
return null;
}
function resolveForwardAuthUrl(
forward: NonNullable<GatewayAuth["forward"]>,
internalOrigins: Readonly<Record<string, string>>,
): string {
if (typeof forward.app === "string") {
const appName = forward.app;
const origin = internalOrigins[appName];
if (!origin) {
throw new Error(`Forward-auth app "${appName}" was not found.`);
}
const path = forward.path ?? "/api/verify";
if (!path.startsWith("/")) {
throw new Error(`Forward-auth path must start with "/": ${path}`);
}
return new URL(path, `${origin}/`).href;
}
if (typeof forward.url === "string") {
const url = new URL(forward.url);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Forward-auth URL must use HTTP or HTTPS: ${forward.url}`);
}
return url.href;
}
throw new Error("Forward auth requires either `app` or `url`.");
}
/** Baseline edge security headers, only where the app didn't already set them. */
function applyEdgeHeaders(res: Response): Response {
const defaults: Record<string, string> = {
"x-content-type-options": "nosniff",
"x-frame-options": "SAMEORIGIN",
"referrer-policy": "strict-origin-when-cross-origin",
};
const headers = new Headers(res.headers);
for (const [k, v] of Object.entries(defaults)) if (!headers.has(k)) headers.set(k, v);
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
}
async function waitReady(target: Target, timeoutMs = 15000): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (!target.child || target.child.exitCode !== null) {
throw new Error(
`app '${target.name}' exited with code ${target.child?.exitCode ?? "unknown"} during startup`,
);
}
try {
await fetch(target.origin + "/__wrnexus/health-probe", { method: "HEAD" });
return; // any HTTP response (incl. 404) means the server is up
} catch {
if (Date.now() > deadline) {
throw new Error(`app '${target.name}' at ${target.origin} did not start in time`);
}
await new Promise((r) => setTimeout(r, 150));
}
}
}
/** Platform-safe defaults: IPv4 loopback in dev, all IPv4 interfaces in production. */
export function defaultGatewayHostname(mode: "development" | "production"): string {
return mode === "production" ? "0.0.0.0" : "127.0.0.1";
}
export function gatewayProxyHeaders(
req: Request,
url: URL,
ip: string,
forwardedHeaders: boolean,
): Headers {
const headers = new Headers(req.headers);
// Bun's internal fetch transparently decompresses response bodies but preserves
// Content-Encoding. Asking child apps for identity encoding prevents clients from
// trying to decompress an already-decoded proxied body.
headers.set("accept-encoding", "identity");
if (forwardedHeaders) {
headers.set("x-forwarded-host", req.headers.get("host") ?? "");
headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
if (ip) headers.set("x-forwarded-for", ip);
}
return headers;
}
/** Boot every app as a child process, then route by Host on one gateway port. */
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
const port = opts.port ?? 3000;
const mode = opts.mode ?? "development";
const hostname = opts.hostname ?? defaultGatewayHostname(mode);
const environment = opts.environment ?? (mode === "production" ? "production" : "development");
const workspaceOrigins = Object.fromEntries(
opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]),
);
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 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",
env: {
...process.env,
PORT: String(appPort),
WRNEXUS_ENV: environment,
WRNEXUS_APP_NAME: app.name,
WRNEXUS_APP_ORIGIN: app.publicOrigin ?? `http://${app.domains[0]}:${port}`,
WRNEXUS_WORKSPACE_ORIGINS: JSON.stringify(workspaceOrigins),
},
})
: spawn(
process.execPath,
[serveEntry, join(dir, "app"), String(appPort), mode, "127.0.0.1"],
{
stdio: "inherit",
env: {
...process.env,
WRNEXUS_ENV: environment,
WRNEXUS_APP_NAME: app.name,
WRNEXUS_APP_ORIGIN: app.publicOrigin ?? `http://${app.domains[0]}:${port}`,
WRNEXUS_WORKSPACE_ORIGINS: JSON.stringify(workspaceOrigins),
},
},
);
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 internalOrigins: Readonly<Record<string, string>> = Object.freeze(
Object.fromEntries(targets.map((target) => [target.name, target.origin])),
);
const stopChildren = () => {
stopping = true;
for (const target of targets) {
if (target.child?.exitCode === null) target.child.kill();
}
};
try {
await Promise.all(targets.map((target) => waitReady(target)));
} catch (error) {
stopChildren();
throw error;
}
const byHost = new Map<string, Target>();
for (const t of targets) for (const d of t.domains) byHost.set(d.toLowerCase(), t);
const pick = (host: string): Target | null =>
byHost.get((host.split(":")[0] ?? "").toLowerCase()) ?? null;
const sec = opts.security ?? {};
const forwardedHeaders = sec.forwardedHeaders !== false; // default on
const rateLimit = sec.rateLimit
? makeRateLimiter(sec.rateLimit.max, sec.rateLimit.windowMs ?? 60_000)
: null;
const now = () => Date.now();
const createGatewayServer = () =>
Bun.serve<WsBridge>({
port,
hostname,
development: mode === "development",
maxRequestBodySize: 50 * 1024 * 1024,
async fetch(req, srv) {
const url = new URL(req.url);
const ip = srv.requestIP(req)?.address ?? "";
// Health/status endpoint (not proxied).
if (url.pathname === "/__gateway/health") {
return Response.json({
ok: true,
apps: targets.map((t) => ({ name: t.name, domains: t.domains, origin: t.origin })),
});
}
// Edge rate limit (global, by client IP).
if (rateLimit && !rateLimit(ip, now())) {
return new Response("Too Many Requests", {
status: 429,
headers: { "retry-after": "60" },
});
}
// Route by Host. Unknown host → 404 when trustedHostsOnly, else first app.
const target =
pick(req.headers.get("host") ?? "") ?? (sec.trustedHostsOnly ? null : targets[0]!);
if (!target) {
return new Response("Unknown host", { status: 404 });
}
// Per-app access control (basic auth / IP allowlist / forward-auth).
const denied = await checkAuth(target.auth, req, ip, internalOrigins);
if (denied) {
if (sec.accessLog)
console.log(
` ⛔ ${req.headers.get("host")} ${req.method} ${url.pathname}${denied.status} (${target.name})`,
);
return denied;
}
// WebSocket upgrade → proxy the socket to the app's realtime server.
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const ok = srv.upgrade(req, {
data: { origin: target.origin, path: url.pathname + url.search, queue: [] },
});
return ok ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
}
// HTTP → reverse-proxy to the app, preserving method/headers/body.
const headers = gatewayProxyHeaders(req, url, ip, forwardedHeaders);
const body =
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
let res: Response;
try {
res = await fetch(target.origin + url.pathname + url.search, {
method: req.method,
headers,
body,
redirect: "manual",
});
} catch {
res = new Response(`Gateway: app '${target.name}' is unavailable.`, { status: 502 });
}
if (sec.headers) res = applyEdgeHeaders(res);
if (sec.accessLog)
console.log(
` ${req.headers.get("host")} ${req.method} ${url.pathname}${res.status} (${target.name})`,
);
return res;
},
websocket: {
open(ws) {
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
const backend = new WebSocket(backendUrl);
ws.data.backend = backend;
backend.addEventListener("open", () => {
for (const m of ws.data.queue) backend.send(m);
ws.data.queue = [];
});
backend.addEventListener("message", async (event) => {
const data = event.data;
if (typeof data === "string") {
ws.send(data);
return;
}
if (data instanceof Blob) {
const buffer = await data.arrayBuffer();
ws.send(buffer);
return;
}
if (data instanceof ArrayBuffer) {
ws.send(data);
return;
}
if (ArrayBuffer.isView(data)) {
const buffer = data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength,
) as ArrayBuffer;
ws.send(buffer);
}
});
backend.addEventListener("close", () => ws.close());
backend.addEventListener("error", () => ws.close());
},
message(ws, message) {
let data: string | ArrayBuffer;
if (typeof message === "string") {
data = message;
} else if (message instanceof ArrayBuffer) {
data = message;
} else {
const view = message as ArrayBufferView;
data = view.buffer.slice(
view.byteOffset,
view.byteOffset + view.byteLength,
) as ArrayBuffer;
}
const backend = ws.data.backend;
if (backend && backend.readyState === WebSocket.OPEN) {
backend.send(data);
} else {
ws.data.queue.push(data);
}
},
close(ws) {
ws.data.backend?.close();
},
},
});
let server: ReturnType<typeof createGatewayServer>;
try {
server = createGatewayServer();
} catch (error) {
stopChildren();
if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") {
throw new Error(
`Gateway cannot listen on ${hostname}:${port} because the port is already in use. ` +
`Stop the existing process or run \`wrnexus gateway --port=<another-port>\`.`,
{ cause: error },
);
}
throw error;
}
const stop = () => {
server.stop();
stopChildren();
};
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
const url = `http://${displayHost}:${port}`;
console.log(`\n ⚡ WrNexus gateway — ${url}`);
for (const t of targets) {
console.log(` ${t.domains.join(", ")}${t.name} (${t.origin})`);
}
console.log("");
return { port, url, stop };
}