| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import http from "node:http";
|
| import net from "node:net";
|
| import type { IncomingMessage } from "node:http";
|
|
|
| import { getSupervisor } from "./registry";
|
| import { getOrCreateApiKey } from "./apiKey";
|
|
|
| const DEFAULT_HOST = "127.0.0.1";
|
| const DEFAULT_PORT = 20131;
|
|
|
|
|
| const MAX_CONNECTIONS_PER_SERVICE = 50;
|
|
|
|
|
| const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
|
|
| const STRIPPED_HEADERS = new Set(["cookie", "authorization", "origin"]);
|
|
|
| declare global {
|
| var __omnirouteEmbedWsStarted: boolean | undefined;
|
| }
|
|
|
| |
| |
| |
|
|
| const activeConnections = new Map<string, Set<net.Socket>>();
|
|
|
|
|
| const PATH_RE = /^\/([^/?#]+)(\/.*)?$/;
|
|
|
| function writeError(socket: net.Socket, status: number, message: string): void {
|
| if (!socket.writable || socket.destroyed) return;
|
| const body = Buffer.from(JSON.stringify({ error: message }), "utf8");
|
| const lines = [
|
| `HTTP/1.1 ${status} ${http.STATUS_CODES[status] ?? "Error"}`,
|
| "Connection: close",
|
| "Content-Type: application/json; charset=utf-8",
|
| `Content-Length: ${body.length}`,
|
| "",
|
| "",
|
| ];
|
| socket.write(lines.join("\r\n"));
|
| socket.end(body);
|
| }
|
|
|
| |
| |
| |
|
|
| function registerConnection(name: string, socket: net.Socket): boolean {
|
| let set = activeConnections.get(name);
|
| if (!set) {
|
| set = new Set();
|
| activeConnections.set(name, set);
|
| }
|
| if (set.size >= MAX_CONNECTIONS_PER_SERVICE) {
|
| writeError(
|
| socket,
|
| 503,
|
| `Service '${name}' connection limit reached (max ${MAX_CONNECTIONS_PER_SERVICE})`
|
| );
|
| return false;
|
| }
|
| set.add(socket);
|
| return true;
|
| }
|
|
|
|
|
| function unregisterConnection(name: string, socket: net.Socket): void {
|
| activeConnections.get(name)?.delete(socket);
|
| }
|
|
|
| |
| |
| |
|
|
| function buildUpstreamHeaders(rawHeaders: string[], port: number, apiKey: string): string[] {
|
| const lines: string[] = [];
|
| let wroteHost = false;
|
|
|
| for (let i = 0; i < rawHeaders.length; i += 2) {
|
| const headerName = rawHeaders[i];
|
| const headerValue = rawHeaders[i + 1] ?? "";
|
| const lower = headerName.toLowerCase();
|
|
|
| if (lower === "host") {
|
| lines.push(`Host: 127.0.0.1:${port}`);
|
| wroteHost = true;
|
| } else if (!STRIPPED_HEADERS.has(lower)) {
|
| lines.push(`${headerName}: ${headerValue}`);
|
| }
|
|
|
| }
|
|
|
| if (!wroteHost) lines.push(`Host: 127.0.0.1:${port}`);
|
|
|
|
|
| lines.push(`Authorization: Bearer ${apiKey}`);
|
|
|
| return lines;
|
| }
|
|
|
| async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> {
|
| const rawUrl = req.url ?? "/";
|
| const match = PATH_RE.exec(rawUrl.split("?")[0]);
|
|
|
| if (!match) {
|
| writeError(socket, 400, "Invalid path");
|
| return;
|
| }
|
|
|
| const [, name, rest = "/"] = match;
|
| const supervisor = getSupervisor(name);
|
|
|
| if (!supervisor) {
|
| writeError(socket, 404, `Service '${name}' not found`);
|
| return;
|
| }
|
|
|
| const { state, port } = supervisor.getStatus();
|
| if (state !== "running") {
|
| writeError(socket, 503, `Service '${name}' is not running (state: ${state})`);
|
| return;
|
| }
|
|
|
|
|
| if (!registerConnection(name, socket)) {
|
|
|
| return;
|
| }
|
|
|
|
|
| socket.once("close", () => unregisterConnection(name, socket));
|
| socket.once("error", () => unregisterConnection(name, socket));
|
|
|
|
|
| const apiKey = await getOrCreateApiKey(name);
|
|
|
|
|
| const search = rawUrl.includes("?") ? rawUrl.slice(rawUrl.indexOf("?")) : "";
|
| const upstreamPath = `${rest}${search}`;
|
|
|
| const upstream = net.connect(port, "127.0.0.1");
|
|
|
|
|
| let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
| function resetIdleTimer(): void {
|
| if (idleTimer !== null) clearTimeout(idleTimer);
|
| idleTimer = setTimeout(() => {
|
| socket.destroy();
|
| upstream.destroy();
|
| }, IDLE_TIMEOUT_MS);
|
| }
|
|
|
| function clearIdleTimer(): void {
|
| if (idleTimer !== null) {
|
| clearTimeout(idleTimer);
|
| idleTimer = null;
|
| }
|
| }
|
|
|
| upstream.once("connect", () => {
|
| const requestLine = `${req.method ?? "GET"} ${upstreamPath} HTTP/${req.httpVersion}`;
|
| const headerLines = buildUpstreamHeaders(req.rawHeaders, port, apiKey);
|
| upstream.write(`${requestLine}\r\n${headerLines.join("\r\n")}\r\n\r\n`);
|
| if (head.length > 0) upstream.write(head);
|
|
|
|
|
| resetIdleTimer();
|
|
|
| socket.on("data", resetIdleTimer);
|
| upstream.on("data", resetIdleTimer);
|
|
|
| socket.pipe(upstream);
|
| upstream.pipe(socket);
|
| });
|
|
|
| upstream.on("error", () => {
|
| clearIdleTimer();
|
| writeError(socket, 502, "Upstream connection error");
|
| });
|
|
|
| socket.on("error", () => {
|
| clearIdleTimer();
|
| upstream.destroy();
|
| });
|
|
|
| socket.on("close", () => {
|
| clearIdleTimer();
|
| upstream.destroy();
|
| });
|
|
|
| upstream.on("close", () => {
|
| clearIdleTimer();
|
| socket.destroy();
|
| });
|
| }
|
|
|
| |
| |
| |
|
|
| export function initEmbedWsProxy(): void {
|
| if (globalThis.__omnirouteEmbedWsStarted) return;
|
|
|
| const host = process.env.EMBED_WS_PROXY_HOST ?? DEFAULT_HOST;
|
| const port = parseInt(process.env.EMBED_WS_PROXY_PORT ?? String(DEFAULT_PORT), 10);
|
|
|
| const server = http.createServer((_req, res) => {
|
| res.writeHead(426, "Upgrade Required", { "content-type": "application/json" });
|
| res.end(JSON.stringify({ error: "upgrade_required", message: "Use WebSocket." }));
|
| });
|
|
|
| server.on("upgrade", (req: IncomingMessage, socket: net.Socket, head: Buffer) => {
|
| proxyUpgrade(req, socket, head).catch((err: unknown) => {
|
| const msg = err instanceof Error ? err.message : String(err);
|
| writeError(socket, 500, `Internal proxy error: ${msg}`);
|
| });
|
| });
|
|
|
| server.on("error", (err: NodeJS.ErrnoException) => {
|
| if (err.code === "EADDRINUSE") {
|
| console.warn(`[EmbedWsProxy] Port ${port} is already in use β embed WS proxy disabled.`);
|
| return;
|
| }
|
| console.warn("[EmbedWsProxy] Failed to start:", err.message);
|
| });
|
|
|
| server.listen(port, host, () => {
|
| globalThis.__omnirouteEmbedWsStarted = true;
|
| console.log(`[EmbedWsProxy] Listening on ${host}:${port}`);
|
| });
|
| }
|
|
|
|
|
|
|
| export {
|
| activeConnections,
|
| registerConnection,
|
| unregisterConnection,
|
| buildUpstreamHeaders,
|
| MAX_CONNECTIONS_PER_SERVICE,
|
| IDLE_TIMEOUT_MS,
|
| };
|
|
|