Counted from source: the 22 remaining outputs sit in 9 components, not 11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
877 lines
30 KiB
TypeScript
877 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, RPC_STREAM_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}/`) ||
|
|
pathname === RPC_STREAM_PATH_PREFIX ||
|
|
pathname.startsWith(`${RPC_STREAM_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 };
|
|
}
|