first commit
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
/** Per-app access control, enforced at the gateway before proxying. */
|
||||
export interface GatewayAuth {
|
||||
/** HTTP Basic auth — one or more allowed user/password pairs. */
|
||||
basic?: { user: string; pass: string } | Array<{ user: string; pass: string }>;
|
||||
/** Allow only these client IPs (exact match; others get 403). */
|
||||
allowIps?: string[];
|
||||
/**
|
||||
* Forward-auth (SSO): the gateway GETs `url` forwarding the request's cookies +
|
||||
* Authorization; a 2xx allows the request, anything else blocks it (its status
|
||||
* is returned). Point it at your own verify endpoint.
|
||||
*/
|
||||
forward?: { url: string };
|
||||
}
|
||||
|
||||
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[];
|
||||
/** 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";
|
||||
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: (string | ArrayBufferLike | ArrayBufferView)[];
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): 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) {
|
||||
try {
|
||||
const res = await fetch(auth.forward.url, {
|
||||
headers: {
|
||||
cookie: req.headers.get("cookie") ?? "",
|
||||
authorization: req.headers.get("authorization") ?? "",
|
||||
"x-forwarded-host": req.headers.get("host") ?? "",
|
||||
"x-original-uri": new URL(req.url).pathname,
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
if (!res.ok)
|
||||
return new Response("Unauthorized", { status: res.status === 200 ? 401 : res.status });
|
||||
} catch {
|
||||
return new Response("Auth service unavailable", { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 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(origin: string, timeoutMs = 15000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
try {
|
||||
await fetch(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 at ${origin} did not start in time`);
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 hostname = opts.hostname ?? "::";
|
||||
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
|
||||
const mode = opts.mode ?? "development";
|
||||
const serveEntry = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
|
||||
|
||||
const targets: Target[] = opts.apps.map((app, i) => {
|
||||
const appPort = app.port ?? port + 1 + i;
|
||||
const dir = resolve(app.dir);
|
||||
const child =
|
||||
mode === "production"
|
||||
? spawn(process.execPath, [join(dir, "dist", "server.js")], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, PORT: String(appPort) },
|
||||
})
|
||||
: spawn(
|
||||
process.execPath,
|
||||
[serveEntry, join(dir, "app"), String(appPort), mode, "127.0.0.1"],
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
return { ...app, port: appPort, origin: `http://localhost:${appPort}`, child };
|
||||
});
|
||||
|
||||
await Promise.all(targets.map((t) => waitReady(t.origin)));
|
||||
|
||||
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 server = 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);
|
||||
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 = new Headers(req.headers);
|
||||
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);
|
||||
}
|
||||
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", (e) => ws.send(e.data as string | ArrayBufferLike));
|
||||
backend.addEventListener("close", () => ws.close());
|
||||
backend.addEventListener("error", () => ws.close());
|
||||
},
|
||||
message(ws, message) {
|
||||
const backend = ws.data.backend;
|
||||
if (backend && backend.readyState === WebSocket.OPEN) backend.send(message);
|
||||
else ws.data.queue.push(message);
|
||||
},
|
||||
close(ws) {
|
||||
ws.data.backend?.close();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const stop = () => {
|
||||
server.stop();
|
||||
for (const t of targets) t.child.kill();
|
||||
};
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user