From 01c1bbfe298afa2dcdafebbd39ea39d0ce8c1e7c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 27 Feb 2026 16:29:58 -0300 Subject: [PATCH] fix: polish split-port implementation for merge - Add 30s timeout to API bridge proxy requests to prevent resource exhaustion - Extract healthcheck.mjs script (replaces inline node -e in Dockerfile + compose files) - Add unit tests for runtime port resolution (14 tests, parsePort + resolveRuntimePorts) - Fix formatting in declare global block --- Dockerfile | 3 +- docker-compose.prod.yml | 8 +-- docker-compose.yml | 8 +-- scripts/healthcheck.mjs | 14 ++++ src/lib/apiBridgeServer.ts | 15 +++++ tests/unit/runtime-ports.test.mjs | 108 ++++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 scripts/healthcheck.mjs create mode 100644 tests/unit/runtime-ports.test.mjs diff --git a/Dockerfile b/Dockerfile index 6b4472cf..2658f636 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,11 +29,12 @@ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs +COPY --from=builder /app/scripts/healthcheck.mjs ./healthcheck.mjs EXPOSE 20128 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD node -e "const p=process.env.DASHBOARD_PORT||process.env.PORT||'20128';fetch('http://127.0.0.1:'+p+'/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))" + CMD ["node", "healthcheck.mjs"] CMD ["node", "run-standalone.mjs"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0b1a11fa..a58a8a50 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -34,13 +34,7 @@ services: volumes: - omniroute-prod-data:/app/data healthcheck: - test: - [ - "CMD", - "node", - "-e", - "const p=process.env.DASHBOARD_PORT||process.env.PORT||'20128';fetch('http://127.0.0.1:'+p+'/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))", - ] + test: ["CMD", "node", "healthcheck.mjs"] interval: 30s timeout: 5s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index d13768bc..e1ee53f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,13 +27,7 @@ x-common: &common volumes: - omniroute-data:/app/data healthcheck: - test: - [ - "CMD", - "node", - "-e", - "const p=process.env.DASHBOARD_PORT||process.env.PORT||'20128';fetch('http://127.0.0.1:'+p+'/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))", - ] + test: ["CMD", "node", "healthcheck.mjs"] interval: 30s timeout: 5s retries: 3 diff --git a/scripts/healthcheck.mjs b/scripts/healthcheck.mjs new file mode 100644 index 00000000..92d3e3e2 --- /dev/null +++ b/scripts/healthcheck.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +/** + * Docker healthcheck script for OmniRoute. + * Checks the /api/settings endpoint on the dashboard port. + * Used by Dockerfile and docker-compose files. + */ +const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128"; + +fetch(`http://127.0.0.1:${port}/api/settings`) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + }) + .catch(() => process.exit(1)); diff --git a/src/lib/apiBridgeServer.ts b/src/lib/apiBridgeServer.ts index 61566a84..b8786ab0 100644 --- a/src/lib/apiBridgeServer.ts +++ b/src/lib/apiBridgeServer.ts @@ -2,6 +2,8 @@ import http from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http"; import { getRuntimePorts } from "@/lib/runtime/ports"; +const PROXY_TIMEOUT_MS = 30_000; // 30s timeout to prevent resource exhaustion + const OPENAI_COMPAT_PATHS = [ /^\/v1(?:\/|$)/, /^\/chat\/completions(?:\?|$)/, @@ -25,6 +27,7 @@ function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: ...req.headers, host: `127.0.0.1:${dashboardPort}`, }, + timeout: PROXY_TIMEOUT_MS, }, (targetRes) => { res.writeHead(targetRes.statusCode || 502, targetRes.headers); @@ -32,6 +35,18 @@ function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: } ); + targetReq.on("timeout", () => { + targetReq.destroy(); + if (res.headersSent) return; + res.writeHead(504, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "api_bridge_timeout", + detail: `Proxy request timed out after ${PROXY_TIMEOUT_MS}ms`, + }) + ); + }); + targetReq.on("error", (error) => { if (res.headersSent) return; res.writeHead(502, { "content-type": "application/json" }); diff --git a/tests/unit/runtime-ports.test.mjs b/tests/unit/runtime-ports.test.mjs new file mode 100644 index 00000000..fedea0a3 --- /dev/null +++ b/tests/unit/runtime-ports.test.mjs @@ -0,0 +1,108 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +// We test the standalone (scripts/) version of port resolution since +// the src/ version uses @/ alias that requires the full Next.js build. +import { parsePort, resolveRuntimePorts } from "../../scripts/runtime-env.mjs"; + +describe("parsePort", () => { + it("parses a valid port number", () => { + assert.equal(parsePort("3000", 20128), 3000); + }); + + it("returns fallback for undefined", () => { + assert.equal(parsePort(undefined, 20128), 20128); + }); + + it("returns fallback for non-numeric string", () => { + assert.equal(parsePort("abc", 20128), 20128); + }); + + it("returns fallback for port 0", () => { + assert.equal(parsePort("0", 20128), 20128); + }); + + it("returns fallback for port > 65535", () => { + assert.equal(parsePort("70000", 20128), 20128); + }); + + it("returns fallback for negative port", () => { + assert.equal(parsePort("-1", 20128), 20128); + }); + + it("accepts port 1", () => { + assert.equal(parsePort("1", 20128), 1); + }); + + it("accepts port 65535", () => { + assert.equal(parsePort("65535", 20128), 65535); + }); +}); + +describe("resolveRuntimePorts", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.PORT; + delete process.env.API_PORT; + delete process.env.DASHBOARD_PORT; + delete process.env.OMNIROUTE_PORT; + }); + + afterEach(() => { + // Restore original env + Object.keys(process.env).forEach((key) => { + if (!(key in originalEnv)) delete process.env[key]; + }); + Object.assign(process.env, originalEnv); + }); + + it("returns default ports when no env vars set", () => { + const ports = resolveRuntimePorts(); + assert.equal(ports.basePort, 20128); + assert.equal(ports.apiPort, 20128); + assert.equal(ports.dashboardPort, 20128); + }); + + it("uses PORT as base for all ports", () => { + process.env.PORT = "3000"; + const ports = resolveRuntimePorts(); + assert.equal(ports.basePort, 3000); + assert.equal(ports.apiPort, 3000); + assert.equal(ports.dashboardPort, 3000); + }); + + it("splits ports when API_PORT is set", () => { + process.env.PORT = "3000"; + process.env.API_PORT = "3001"; + const ports = resolveRuntimePorts(); + assert.equal(ports.basePort, 3000); + assert.equal(ports.apiPort, 3001); + assert.equal(ports.dashboardPort, 3000); + }); + + it("splits ports when DASHBOARD_PORT is set", () => { + process.env.PORT = "3000"; + process.env.DASHBOARD_PORT = "3002"; + const ports = resolveRuntimePorts(); + assert.equal(ports.basePort, 3000); + assert.equal(ports.apiPort, 3000); + assert.equal(ports.dashboardPort, 3002); + }); + + it("supports full split (API + DASHBOARD)", () => { + process.env.PORT = "3000"; + process.env.API_PORT = "3001"; + process.env.DASHBOARD_PORT = "3002"; + const ports = resolveRuntimePorts(); + assert.equal(ports.basePort, 3000); + assert.equal(ports.apiPort, 3001); + assert.equal(ports.dashboardPort, 3002); + }); + + it("ignores invalid port values and falls back", () => { + process.env.PORT = "abc"; + const ports = resolveRuntimePorts(); + assert.equal(ports.basePort, 20128); + }); +});