/** * 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; 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(); 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>, publicOrigins: Readonly>, publicOrigin?: string, ): Promise { 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, internalOrigins: Readonly>, ): 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 = { "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 { 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 { 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> = 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> = 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> = 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(); 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({ 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; 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=\`.`, { 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 }; }