|
|
|
@@ -170,25 +170,56 @@ function applyEdgeHeaders(res: Response): Response {
|
|
|
|
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitReady(origin: string, timeoutMs = 15000): Promise<void> {
|
|
|
|
|
async function waitReady(target: Target, timeoutMs = 15000): Promise<void> {
|
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
|
for (;;) {
|
|
|
|
|
if (target.child.exitCode !== null) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`app '${target.name}' exited with code ${target.child.exitCode} during startup`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await fetch(origin + "/__wrnexus/health-probe", { method: "HEAD" });
|
|
|
|
|
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 at ${origin} did not start in time`);
|
|
|
|
|
if (Date.now() > deadline) {
|
|
|
|
|
throw new Error(`app '${target.name}' at ${target.origin} did not start in time`);
|
|
|
|
|
}
|
|
|
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Platform-safe defaults: IPv4 loopback in dev, all IPv4 interfaces in production. */
|
|
|
|
|
export function defaultGatewayHostname(mode: "development" | "production"): string {
|
|
|
|
|
return mode === "production" ? "0.0.0.0" : "127.0.0.1";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function gatewayProxyHeaders(
|
|
|
|
|
req: Request,
|
|
|
|
|
url: URL,
|
|
|
|
|
ip: string,
|
|
|
|
|
forwardedHeaders: boolean,
|
|
|
|
|
): Headers {
|
|
|
|
|
const headers = new Headers(req.headers);
|
|
|
|
|
// Bun's internal fetch transparently decompresses response bodies but preserves
|
|
|
|
|
// Content-Encoding. Asking child apps for identity encoding prevents clients from
|
|
|
|
|
// trying to decompress an already-decoded proxied body.
|
|
|
|
|
headers.set("accept-encoding", "identity");
|
|
|
|
|
if (forwardedHeaders) {
|
|
|
|
|
headers.set("x-forwarded-host", req.headers.get("host") ?? "");
|
|
|
|
|
headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
|
|
|
|
|
if (ip) headers.set("x-forwarded-for", ip);
|
|
|
|
|
}
|
|
|
|
|
return headers;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Boot every app as a child process, then route by Host on one gateway port. */
|
|
|
|
|
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
|
|
|
|
const port = opts.port ?? 3000;
|
|
|
|
|
const hostname = opts.hostname ?? "::";
|
|
|
|
|
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
|
|
|
|
|
const mode = opts.mode ?? "development";
|
|
|
|
|
const hostname = opts.hostname ?? defaultGatewayHostname(mode);
|
|
|
|
|
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
|
|
|
|
|
const serveEntry = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
|
|
|
|
|
|
|
|
|
|
const targets: Target[] = opts.apps.map((app, i) => {
|
|
|
|
@@ -207,10 +238,21 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|
|
|
|
stdio: "inherit",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
return { ...app, port: appPort, origin: `http://localhost:${appPort}`, child };
|
|
|
|
|
return { ...app, port: appPort, origin: `http://127.0.0.1:${appPort}`, child };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await Promise.all(targets.map((t) => waitReady(t.origin)));
|
|
|
|
|
const stopChildren = () => {
|
|
|
|
|
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);
|
|
|
|
@@ -224,107 +266,121 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|
|
|
|
: 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 ?? "";
|
|
|
|
|
const createGatewayServer = () =>
|
|
|
|
|
Bun.serve<WsBridge>({
|
|
|
|
|
port,
|
|
|
|
|
hostname,
|
|
|
|
|
development: mode === "development",
|
|
|
|
|
maxRequestBodySize: 50 * 1024 * 1024,
|
|
|
|
|
async fetch(req, srv) {
|
|
|
|
|
const url = new URL(req.url);
|
|
|
|
|
const ip = srv.requestIP(req)?.address ?? "";
|
|
|
|
|
|
|
|
|
|
// Health/status endpoint (not proxied).
|
|
|
|
|
if (url.pathname === "/__gateway/health") {
|
|
|
|
|
return Response.json({
|
|
|
|
|
ok: true,
|
|
|
|
|
apps: targets.map((t) => ({ name: t.name, domains: t.domains, origin: t.origin })),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// 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" } });
|
|
|
|
|
}
|
|
|
|
|
// 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 });
|
|
|
|
|
}
|
|
|
|
|
// 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) {
|
|
|
|
|
// 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 = gatewayProxyHeaders(req, url, ip, forwardedHeaders);
|
|
|
|
|
const body =
|
|
|
|
|
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
|
|
|
|
let res: Response;
|
|
|
|
|
try {
|
|
|
|
|
res = await fetch(target.origin + url.pathname + url.search, {
|
|
|
|
|
method: req.method,
|
|
|
|
|
headers,
|
|
|
|
|
body,
|
|
|
|
|
redirect: "manual",
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
res = new Response(`Gateway: app '${target.name}' is unavailable.`, { status: 502 });
|
|
|
|
|
}
|
|
|
|
|
if (sec.headers) res = applyEdgeHeaders(res);
|
|
|
|
|
if (sec.accessLog)
|
|
|
|
|
console.log(
|
|
|
|
|
` ⛔ ${req.headers.get("host")} ${req.method} ${url.pathname} → ${denied.status} (${target.name})`,
|
|
|
|
|
` ${req.headers.get("host")} ${req.method} ${url.pathname} → ${res.status} (${target.name})`,
|
|
|
|
|
);
|
|
|
|
|
return denied;
|
|
|
|
|
}
|
|
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
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();
|
|
|
|
|
for (const t of targets) t.child.kill();
|
|
|
|
|
stopChildren();
|
|
|
|
|
};
|
|
|
|
|
process.on("SIGINT", stop);
|
|
|
|
|
process.on("SIGTERM", stop);
|
|
|
|
|