Files
WRNexusJS/packages/dev-server/src/gateway.ts
T
ClintchizandClaude Opus 5 3eec9fd8c6 fix(rpc): close the four final-review blockers on inter-app RPC
- Resolve RPC call origins via a new WRNEXUS_INTERNAL_ORIGINS map (loopback
  origins the gateway hands each child before spawning it), falling back to
  the public appOrigin only when it is absent. Calls previously always went
  to the public gateway origin, which the gateway unconditionally 404s on
  the RPC prefix by design — every real cross-app call failed.
- Stop loadServices() from running ahead of routing and stop memoizing a
  rejected load: one bad file under app/services/ no longer permanently
  breaks every route in the app. A failed load logs loudly, is retried on
  the next RPC request, and the RPC path gets a structured RPC_UNKNOWN
  instead of an unhandled throw.
- Reject a service whose contract.name does not match the filename it is
  mounted under, naming both, instead of silently mounting under the
  filename while the typed client calls by contract name.
- Let ServiceError accept an explicit retryable and have the client pass the
  wire value through, instead of recomputing (and silently flipping) it from
  the error code alone.
- Document the gateway/X-Forwarded-* deployment requirement in the RPC
  README.

Each of the three code blockers has a new/extended test that was verified to
fail when its fix was reverted (rpc/test/integration.test.ts,
dev-server/test/rpc-services-loading.test.ts).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:36:31 +05:30

872 lines
30 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 { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX } from "@wrnexus/rpc";
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 GatewayRequestLimits {
maxUrlLength?: number;
maxHeaderCount?: number;
maxHeaderBytes?: number;
maxQueryParameters?: number;
maxBodyBytes?: number;
timeoutMs?: number;
maxConcurrent?: number;
fetchMetadata?: boolean;
}
export interface GatewayWebSocketSecurity {
maxMessageBytes?: number;
maxQueuedMessages?: number;
allowedOrigins?: string[];
}
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;
/** URL, header, body, timeout, concurrency, and Fetch Metadata limits. */
requestLimits?: GatewayRequestLimits;
/** WebSocket origin, payload, and pre-connect queue limits. */
websocket?: GatewayWebSocketSecurity;
}
export interface GatewayOptions {
port?: number;
hostname?: string;
mode?: "development" | "production";
environment?: string;
hmr?: boolean;
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>;
maxMessageBytes: number;
maxQueuedMessages: number;
}
/** 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;
};
}
function requestHeaderBytes(headers: Headers): number {
let total = 0;
headers.forEach((value, name) => {
total += name.length + value.length + 4;
});
return total;
}
function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): number {
if (typeof value === "string") return new TextEncoder().encode(value).byteLength;
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
}
function gatewayWebSocketOriginAllowed(
req: Request,
target: Target,
configured: string[],
): boolean {
const origin = req.headers.get("origin");
if (!origin) return true;
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
return false;
}
if (configured.includes(origin)) return true;
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
}
/**
* Constant-time string compare. Length is folded into the accumulator rather
* than short-circuiting, so a wrong guess cannot be distinguished from a
* wrong-length guess by timing.
*/
function timingSafeEqual(a: string, b: string): boolean {
let diff = a.length ^ b.length;
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
return diff === 0;
}
/**
* Verify an HTTP Basic `Authorization` header against the configured pairs.
* Malformed base64 fails closed rather than throwing, and the username and
* password are split on the FIRST colon so passwords may contain colons.
*/
export function verifyBasicAuth(
header: string | null | undefined,
pairs: readonly { user: string; pass: string }[],
): boolean {
if (!header?.startsWith("Basic ")) return false;
let decoded: string;
try {
decoded = atob(header.slice(6));
} catch {
return false;
}
const separator = decoded.indexOf(":");
if (separator === -1) return false;
const user = decoded.slice(0, separator);
const pass = decoded.slice(separator + 1);
// Evaluate every pair so the number of configured credentials is not
// observable through response timing.
return pairs.reduce(
(ok, p) => (timingSafeEqual(user, p.user) && timingSafeEqual(pass, p.pass)) || ok,
false,
);
}
export function internalError(res: Response): string | null {
const encoded = res.headers.get("x-wrnexus-internal-error");
if (!encoded) return null;
try {
return decodeURIComponent(encoded);
} catch {
return "Malformed internal error diagnostic";
}
}
export function stripInternalError(res: Response): Response {
if (!res.headers.has("x-wrnexus-internal-error")) return res;
const headers = new Headers(res.headers);
headers.delete("x-wrnexus-internal-error");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
/** Preserve an intentional verifier redirect while keeping other failures opaque. */
export function forwardAuthFailure(
res: Response,
verifierUrl: string,
verifierPublicOrigin = verifierUrl,
): Response {
const location = res.headers.get("location");
if (res.status >= 300 && res.status < 400 && location) {
try {
const redirect = new URL(location, verifierPublicOrigin);
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, publicOrigin?: string): Headers {
const original = new URL(req.url);
const host = req.headers.get("host") ?? original.host;
const protocol = (publicOrigin ? new URL(publicOrigin).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>>,
publicOrigins: Readonly<Record<string, string>>,
publicOrigin?: 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 ok = verifyBasicAuth(req.headers.get("authorization"), pairs);
if (!ok) {
return new Response("Authentication required", {
status: 401,
headers: { "www-authenticate": 'Basic realm="Restricted"' },
});
}
}
if (auth.forward) {
const verifyUrl = resolveForwardAuthUrl(auth.forward, internalOrigins);
const verifierPublicOrigin =
typeof auth.forward.app === "string"
? publicOrigins[auth.forward.app]
: new URL(auth.forward.url).origin;
try {
const res = await fetch(verifyUrl, {
headers: forwardAuthHeaders(req, publicOrigin),
redirect: "manual",
});
if (!res.ok) {
if (res.status >= 500) {
const requestUrl = new URL(req.url);
const diagnostic = internalError(res);
console.error(
`[wrnexus] forward-auth verifier error: ${req.method} ${requestUrl.pathname} via ${verifyUrl} returned ${res.status}${diagnostic ? ` — ${diagnostic}` : ""}`,
);
}
return forwardAuthFailure(res, verifyUrl, verifierPublicOrigin);
}
} catch (error) {
console.error(
`[wrnexus] forward-auth verifier unavailable: ${verifyUrl}`,
error instanceof Error ? (error.stack ?? error.message) : error,
);
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;
}
/** Remove headers that only a direct workspace-to-app request may supply. */
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
const sanitized = new Headers(headers);
sanitized.delete(RPC_INTERNAL_HEADER);
return sanitized;
}
/**
* The reserved inter-app RPC namespace is refused at the gateway edge, before
* any proxying — it is only ever mounted by a child app's own dev-server and
* must never be reachable from outside the workspace.
*/
export function isRpcGatewayPath(pathname: string): boolean {
return pathname === RPC_PATH_PREFIX || pathname.startsWith(`${RPC_PATH_PREFIX}/`);
}
/** 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: Readonly<Record<string, string>> = Object.freeze(
Object.fromEntries(
opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]),
),
);
// Loopback-only origins, computed up front (ports are assigned by index
// before any child spawns) so every child can reach every other child
// directly — bypassing the gateway, which 404s the RPC prefix by design.
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
Object.fromEntries(
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
),
);
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
// When the CLI is executed directly from a framework checkout, keep child
// apps on that same source tree. Resolving the package name from an external
// workspace can otherwise select its older installed release on restart.
const sourceServeEntry = fileURLToPath(new URL("./serve-entry.ts", import.meta.url));
const usesSourceServeEntry = existsSync(sourceServeEntry);
const serveEntry = usesSourceServeEntry
? sourceServeEntry
: fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
const frameworkRoot = resolve(dirname(sourceServeEntry), "../../..");
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),
// App ports are private gateway internals. Loopback prevents
// clients from bypassing gateway auth and edge middleware.
WRNEXUS_HOSTNAME: "127.0.0.1",
WRNEXUS_ENV: environment,
WRNEXUS_APP_NAME: app.name,
WRNEXUS_APP_ORIGIN: app.publicOrigin ?? `http://${app.domains[0]}:${port}`,
WRNEXUS_WORKSPACE_ORIGINS: JSON.stringify(workspaceOrigins),
WRNEXUS_INTERNAL_ORIGINS: JSON.stringify(internalOriginsEnv),
},
})
: spawn(
process.execPath,
[
serveEntry,
join(dir, "app"),
String(appPort),
mode,
"127.0.0.1",
String(opts.hmr ?? true),
],
{
stdio: "inherit",
// Bun can otherwise resolve bare @wrnexus imports against the
// external application's node_modules after an HMR restart.
// Keep source-checkout children anchored to the same framework
// checkout as the gateway command.
cwd: usesSourceServeEntry ? frameworkRoot : dir,
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),
WRNEXUS_INTERNAL_ORIGINS: JSON.stringify(internalOriginsEnv),
},
},
);
target.child = child;
child.once("exit", (code, signal) => {
if (stopping || target.child !== child) return;
const delay = gatewayRestartDelay(mode, code, signal);
if (delay === null) {
if (code !== 0 || signal) {
console.error(
`[wrnexus] app '${target.name}' stopped unexpectedly (${signal ? `signal ${signal}` : `code ${code}`})`,
);
}
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 limits = sec.requestLimits ?? {};
const websocketSecurity = sec.websocket ?? {};
const maxBodyBytes = limits.maxBodyBytes ?? 10 * 1024 * 1024;
const maxConcurrent = limits.maxConcurrent ?? 1_000;
let activeRequests = 0;
const createGatewayServer = () =>
Bun.serve<WsBridge>({
port,
hostname,
development: mode === "development",
maxRequestBodySize: maxBodyBytes,
async fetch(req, srv) {
const url = new URL(req.url);
const ip = srv.requestIP(req)?.address ?? "";
if (req.url.length > (limits.maxUrlLength ?? 8_192)) {
return new Response("URI Too Long", { status: 414 });
}
if ([...req.headers].length > (limits.maxHeaderCount ?? 100)) {
return new Response("Too Many Headers", { status: 431 });
}
if (requestHeaderBytes(req.headers) > (limits.maxHeaderBytes ?? 32 * 1024)) {
return new Response("Request Headers Too Large", { status: 431 });
}
if ([...url.searchParams].length > (limits.maxQueryParameters ?? 100)) {
return new Response("Too Many Query Parameters", { status: 400 });
}
const declaredLength = Number(req.headers.get("content-length") ?? 0);
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
return new Response("Payload Too Large", { status: 413 });
}
if (limits.fetchMetadata !== false) {
const site = req.headers.get("sec-fetch-site");
const mode = req.headers.get("sec-fetch-mode");
if (
site === "cross-site" &&
!["GET", "HEAD", "OPTIONS"].includes(req.method) &&
mode !== "cors"
) {
return new Response("Cross-site request denied", { status: 403 });
}
}
// 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 })),
});
}
if (isRpcGatewayPath(url.pathname)) {
return new Response("Not found", { status: 404 });
}
// 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,
workspaceOrigins,
target.publicOrigin,
);
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") {
if (!gatewayWebSocketOriginAllowed(req, target, websocketSecurity.allowedOrigins ?? [])) {
return new Response("WebSocket origin denied", { status: 403 });
}
const ok = srv.upgrade(req, {
data: {
origin: target.origin,
path: url.pathname + url.search,
queue: [],
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
},
});
return ok ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
}
// HTTP → reverse-proxy to the app, preserving method/headers/body.
const headers = stripUntrustedInternalHeaders(
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
);
const body =
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
let res: Response;
if (activeRequests >= maxConcurrent) {
return new Response("Gateway overloaded", {
status: 503,
headers: { "retry-after": "1" },
});
}
activeRequests += 1;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), limits.timeoutMs ?? 30_000);
try {
res = await fetch(target.origin + url.pathname + url.search, {
method: req.method,
headers,
body,
redirect: "manual",
signal: controller.signal,
});
} catch (error) {
console.error(
`[wrnexus] gateway proxy error: ${req.method} ${url.pathname}${target.name} (${target.origin})`,
error instanceof Error ? (error.stack ?? error.message) : error,
);
res = new Response(`Gateway: app '${target.name}' is unavailable.`, {
status: error instanceof DOMException && error.name === "AbortError" ? 504 : 502,
});
} finally {
clearTimeout(timeout);
activeRequests -= 1;
}
const diagnostic = internalError(res);
if (res.status >= 500 && diagnostic) {
console.error(
`[wrnexus] app response error: ${target.name} ${req.method} ${url.pathname}${res.status}${diagnostic}`,
);
}
res = stripInternalError(res);
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;
const size =
typeof data === "string"
? new TextEncoder().encode(data).byteLength
: data instanceof Blob
? data.size
: data instanceof ArrayBuffer
? data.byteLength
: ArrayBuffer.isView(data)
? data.byteLength
: 0;
if (size > ws.data.maxMessageBytes) {
ws.close(1009, "Message too large");
backend.close(1009, "Message too large");
return;
}
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) {
if (requestMessageBytes(message) > ws.data.maxMessageBytes) {
ws.close(1009, "Message too large");
ws.data.backend?.close(1009, "Message too large");
return;
}
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 if (ws.data.queue.length < ws.data.maxQueuedMessages) {
ws.data.queue.push(data);
} else {
ws.close(1013, "WebSocket queue limit exceeded");
ws.data.backend?.close(1013, "WebSocket queue limit exceeded");
}
},
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 };
}