From d0138a503741c3758431fc48e403b13f1ccb6a98 Mon Sep 17 00:00:00 2001 From: Steven Rafferty Date: Thu, 26 Feb 2026 15:11:40 +0000 Subject: [PATCH 1/4] feat: enhance port configuration and API bridge support - Updated .env.example to include optional split ports for API and dashboard. - Modified docker-compose files to dynamically use the configured ports. - Introduced a new script (run-standalone.mjs) for running the server with environment-specific ports. - Implemented an API bridge server to handle OpenAI-compatible routes when using split ports. - Updated README and CLI tool documentation to reflect changes in port usage and configuration. - Enhanced various components to utilize the new port configuration, ensuring backward compatibility. --- .env.example | 4 + Dockerfile | 6 +- README.md | 31 +++++-- bin/omniroute.mjs | 55 ++++++----- docker-compose.prod.yml | 2 +- docker-compose.yml | 2 +- package.json | 4 +- playwright.config.ts | 7 +- scripts/run-standalone.mjs | 34 +++++++ .../cli-tools/CLIToolsPageClient.tsx | 10 ++ .../(dashboard)/dashboard/onboarding/page.tsx | 12 ++- .../guide-settings/[toolId]/route.ts | 4 +- src/app/api/cli-tools/status/route.ts | 2 +- src/app/api/settings/route.ts | 5 + src/instrumentation.ts | 3 + src/lib/apiBridgeServer.ts | 91 +++++++++++++++++++ src/lib/oauth/config/index.ts | 10 +- src/lib/runtime/ports.ts | 32 +++++++ src/shared/services/cloudSyncScheduler.ts | 5 +- src/types/global.d.ts | 3 + 20 files changed, 275 insertions(+), 47 deletions(-) create mode 100644 scripts/run-standalone.mjs create mode 100644 src/lib/apiBridgeServer.ts create mode 100644 src/lib/runtime/ports.ts diff --git a/.env.example b/.env.example index 15fceaea12..379c72b5f9 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,11 @@ SQLITE_MAX_SIZE_MB=2048 SQLITE_CLEAN_LEGACY_FILES=true # Recommended runtime variables +# Canonical/base port (keeps backward compatibility) PORT=20128 +# Optional split ports: +# API_PORT=20128 +# DASHBOARD_PORT=20128 NODE_ENV=production INSTANCE_NAME=omniroute diff --git a/Dockerfile b/Dockerfile index 308389163a..ffb9c07364 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,13 +27,14 @@ RUN mkdir -p /app/data COPY --from=builder /app/public ./public 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 EXPOSE 20128 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))" + 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", "server.js"] +CMD ["node", "run-standalone.mjs"] FROM runner-base AS runner-cli @@ -45,4 +46,3 @@ RUN apt-get update \ # Install CLI tools globally. Separate layer from apt for better cache reuse. RUN npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest - diff --git a/README.md b/README.md index 8d4c466b82..51619853fd 100644 --- a/README.md +++ b/README.md @@ -168,12 +168,22 @@ omniroute πŸŽ‰ Dashboard opens at `http://localhost:20128` -| Command | Description | -| ----------------------- | --------------------------------- | -| `omniroute` | Start server (default port 20128) | -| `omniroute --port 3000` | Use custom port | -| `omniroute --no-open` | Don't auto-open browser | -| `omniroute --help` | Show help | +| Command | Description | +| ----------------------- | ----------------------------------------------------------- | +| `omniroute` | Start server (`PORT=20128`, API and dashboard on same port) | +| `omniroute --port 3000` | Set canonical/API port to 3000 | +| `omniroute --no-open` | Don't auto-open browser | +| `omniroute --help` | Show help | + +Optional split-port mode: + +```bash +PORT=20128 DASHBOARD_PORT=20129 omniroute +# API: http://localhost:20128/v1 +# Dashboard: http://localhost:20129 +``` + +When ports are split, the API port serves only OpenAI-compatible routes (`/v1`, `/chat/completions`, `/responses`, `/models`, `/codex/*`). **2. Connect a FREE provider:** @@ -195,7 +205,7 @@ Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline Settings: ```bash cp .env.example .env npm install -PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm run dev ``` --- @@ -978,9 +988,12 @@ Se nΓ£o quiser criar credenciais prΓ³prias agora, ainda Γ© possΓ­vel usar o flux - Switch primary model to GLM/MiniMax - Use free tier (Gemini CLI, iFlow) for non-critical tasks -**Dashboard opens on wrong port** +**Dashboard/API ports are wrong** -- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` +- `PORT` is the canonical base port (and API port by default) +- `API_PORT` overrides only OpenAI-compatible API listener +- `DASHBOARD_PORT` overrides only dashboard/Next.js listener +- Set `NEXT_PUBLIC_BASE_URL` to your dashboard/public URL (for OAuth callbacks) **Cloud sync errors** diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index a655e248d1..e9f1bc94cf 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -30,18 +30,18 @@ if (args.includes("--help") || args.includes("-h")) { \x1b[1mUsage:\x1b[0m omniroute Start the server - omniroute --port Use custom port (default: 20128) + omniroute --port Use custom API port (default: 20128) omniroute --no-open Don't open browser automatically omniroute --help Show this help omniroute --version Show version \x1b[1mAfter starting:\x1b[0m - Dashboard: http://localhost: - API: http://localhost:/v1 + Dashboard: http://localhost: + API: http://localhost:/v1 \x1b[1mConnect your tools:\x1b[0m Set your CLI tool (Cursor, Cline, Codex, etc.) to use: - \x1b[33mhttp://localhost:20128/v1\x1b[0m + \x1b[33mhttp://localhost:/v1\x1b[0m `); process.exit(0); } @@ -58,17 +58,26 @@ if (args.includes("--version") || args.includes("-v")) { process.exit(0); } -// Parse --port -let port = 20128; +function parsePort(value, fallback) { + const parsed = parseInt(String(value), 10); + return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; +} + +// Parse --port (canonical/base port) +let port = parsePort(process.env.PORT || "20128", 20128); const portIdx = args.indexOf("--port"); if (portIdx !== -1 && args[portIdx + 1]) { - port = parseInt(args[portIdx + 1], 10); - if (isNaN(port)) { + const parsed = parseInt(args[portIdx + 1], 10); + if (isNaN(parsed)) { console.error("\x1b[31mβœ– Invalid port number\x1b[0m"); process.exit(1); } + port = parsed; } +const apiPort = parsePort(process.env.API_PORT || String(port), port); +const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port); + const noOpen = args.includes("--no-open"); // ── Banner ───────────────────────────────────────────────── @@ -85,13 +94,8 @@ console.log(` const serverJs = join(APP_DIR, "server.js"); if (!existsSync(serverJs)) { - console.error( - "\x1b[31mβœ– Server not found at:\x1b[0m", - serverJs, - ); - console.error( - " This usually means the package was not built correctly.", - ); + console.error("\x1b[31mβœ– Server not found at:\x1b[0m", serverJs); + console.error(" This usually means the package was not built correctly."); console.error(" Try reinstalling: npm install -g omniroute"); process.exit(1); } @@ -101,7 +105,10 @@ console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`); const env = { ...process.env, - PORT: String(port), + OMNIROUTE_PORT: String(port), + PORT: String(dashboardPort), + DASHBOARD_PORT: String(dashboardPort), + API_PORT: String(apiPort), HOSTNAME: "0.0.0.0", NODE_ENV: "production", }; @@ -119,7 +126,10 @@ server.stdout.on("data", (data) => { process.stdout.write(text); // Detect server ready - if (!started && (text.includes("Ready") || text.includes("started") || text.includes("listening"))) { + if ( + !started && + (text.includes("Ready") || text.includes("started") || text.includes("listening")) + ) { started = true; onReady(); } @@ -156,16 +166,17 @@ process.on("SIGTERM", shutdown); // ── On ready ─────────────────────────────────────────────── async function onReady() { - const url = `http://localhost:${port}`; + const dashboardUrl = `http://localhost:${dashboardPort}`; + const apiUrl = `http://localhost:${apiPort}`; console.log(` \x1b[32mβœ” OmniRoute is running!\x1b[0m - \x1b[1m Dashboard:\x1b[0m ${url} - \x1b[1m API Base:\x1b[0m ${url}/v1 + \x1b[1m Dashboard:\x1b[0m ${dashboardUrl} + \x1b[1m API Base:\x1b[0m ${apiUrl}/v1 \x1b[2m Point your CLI tool (Cursor, Cline, Codex) to:\x1b[0m - \x1b[33m ${url}/v1\x1b[0m + \x1b[33m ${apiUrl}/v1\x1b[0m \x1b[2m Press Ctrl+C to stop\x1b[0m `); @@ -173,7 +184,7 @@ async function onReady() { if (!noOpen) { try { const open = await import("open"); - await open.default(url); + await open.default(dashboardUrl); } catch { // open is optional β€” if not available, just skip } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 486afaafeb..f129888275 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -35,7 +35,7 @@ services: "CMD", "node", "-e", - "fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))", + "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))", ] interval: 30s timeout: 5s diff --git a/docker-compose.yml b/docker-compose.yml index 0b936ffb08..40441ace65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ x-common: &common "CMD", "node", "-e", - "fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))", + "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))", ] interval: 30s timeout: 5s diff --git a/package.json b/package.json index 1116ed6770..b57b582870 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,10 @@ }, "homepage": "https://omniroute.online", "scripts": { - "dev": "next dev --webpack --port 20128", + "dev": "OMNIROUTE_PORT=${PORT:-20128} next dev --webpack --port ${DASHBOARD_PORT:-${PORT:-20128}}", "build": "next build --webpack", "build:cli": "node scripts/prepublish.mjs", - "start": "next start --port 20128", + "start": "OMNIROUTE_PORT=${PORT:-20128} next start --port ${DASHBOARD_PORT:-${PORT:-20128}}", "lint": "eslint .", "test": "node --test tests/unit/*.test.mjs", "test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index 491b3eb6f1..15e3706b82 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,8 @@ import { defineConfig, devices } from "@playwright/test"; +const dashboardPort = process.env.DASHBOARD_PORT || process.env.PORT || "20128"; +const dashboardBaseUrl = `http://localhost:${dashboardPort}`; + export default defineConfig({ testDir: "./tests/e2e", fullyParallel: true, @@ -8,7 +11,7 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: process.env.CI ? "github" : "html", use: { - baseURL: "http://localhost:20128", + baseURL: dashboardBaseUrl, trace: "on-first-retry", screenshot: "only-on-failure", }, @@ -20,7 +23,7 @@ export default defineConfig({ ], webServer: { command: process.env.CI ? "npm start" : "npm run dev", - url: "http://localhost:20128", + url: dashboardBaseUrl, reuseExistingServer: !process.env.CI, timeout: 120_000, }, diff --git a/scripts/run-standalone.mjs b/scripts/run-standalone.mjs new file mode 100644 index 0000000000..b48ccf3eb2 --- /dev/null +++ b/scripts/run-standalone.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; + +function parsePort(value, fallback) { + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; +} + +const basePort = parsePort(process.env.PORT || "20128", 20128); +const apiPort = parsePort(process.env.API_PORT || String(basePort), basePort); +const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(basePort), basePort); + +const child = spawn("node", ["server.js"], { + stdio: "inherit", + env: { + ...process.env, + OMNIROUTE_PORT: String(basePort), + PORT: String(dashboardPort), + DASHBOARD_PORT: String(dashboardPort), + API_PORT: String(apiPort), + }, +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); + +process.on("SIGINT", () => child.kill("SIGINT")); +process.on("SIGTERM", () => child.kill("SIGTERM")); diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx index 89ba789269..8daa857bec 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx @@ -30,6 +30,7 @@ export default function CLIToolsPageClient({ machineId }) { const [apiKeys, setApiKeys] = useState([]); const [toolStatuses, setToolStatuses] = useState({}); const [statusesLoaded, setStatusesLoaded] = useState(false); + const [apiBaseUrl, setApiBaseUrl] = useState(""); useEffect(() => { fetchConnections(); @@ -44,6 +45,12 @@ export default function CLIToolsPageClient({ machineId }) { if (res.ok) { const data = await res.json(); setCloudEnabled(data.cloudEnabled || false); + if (typeof window !== "undefined") { + const protocol = window.location.protocol; + const hostname = window.location.hostname; + const apiPort = data?.apiPort || 20128; + setApiBaseUrl(`${protocol}//${hostname}:${apiPort}`); + } } } catch (error) { console.log("Error loading cloud settings:", error); @@ -141,6 +148,9 @@ export default function CLIToolsPageClient({ machineId }) { if (cloudEnabled && CLOUD_URL) { return CLOUD_URL; } + if (apiBaseUrl) { + return apiBaseUrl; + } if (typeof window !== "undefined") { return window.location.origin; } diff --git a/src/app/(dashboard)/dashboard/onboarding/page.tsx b/src/app/(dashboard)/dashboard/onboarding/page.tsx index a782f63964..7d18120183 100644 --- a/src/app/(dashboard)/dashboard/onboarding/page.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/page.tsx @@ -24,6 +24,7 @@ export default function OnboardingWizard() { const router = useRouter(); const [step, setStep] = useState(0); const [loading, setLoading] = useState(true); + const [apiEndpoint, setApiEndpoint] = useState("http://localhost:20128/api/v1"); // Security step state const [password, setPassword] = useState(""); @@ -42,11 +43,20 @@ export default function OnboardingWizard() { // Check if setup is already complete useEffect(() => { + const resolveApiEndpoint = (apiPort) => { + if (typeof window === "undefined") return; + const protocol = window.location.protocol; + const hostname = window.location.hostname; + const effectiveApiPort = apiPort || 20128; + setApiEndpoint(`${protocol}//${hostname}:${effectiveApiPort}/api/v1`); + }; + const checkSetup = async () => { try { const res = await fetch("/api/settings"); if (res.ok) { const settings = await res.json(); + resolveApiEndpoint(settings?.apiPort); if (settings.setupComplete) { router.replace("/dashboard"); return; @@ -396,7 +406,7 @@ export default function OnboardingWizard() {

Your endpoint:

- http://localhost:20128/api/v1 + {apiEndpoint}
)} diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index 5984681b78..38fca04b98 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -68,9 +68,7 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) { const existingIdx = models.findIndex( (m) => m.apiBase && - (m.apiBase.includes("localhost:20128") || - m.apiBase.includes("omniroute") || - m.title === model) + (m.apiBase.includes("localhost") || m.apiBase.includes("omniroute") || m.title === model) ); if (existingIdx >= 0) { diff --git a/src/app/api/cli-tools/status/route.ts b/src/app/api/cli-tools/status/route.ts index 610ba837f5..1465d7f66d 100644 --- a/src/app/api/cli-tools/status/route.ts +++ b/src/app/api/cli-tools/status/route.ts @@ -33,7 +33,7 @@ async function checkToolConfigStatus(toolId: string): Promise { case "kilo": // Generic check: look for any OmniRoute-related URL in the config const configStr = JSON.stringify(config).toLowerCase(); - return configStr.includes("omniroute") || configStr.includes("20128") + return configStr.includes("omniroute") || configStr.includes("localhost") ? "configured" : "not_configured"; default: diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 9e427f1639..d5dbe3da2d 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getSettings, updateSettings } from "@/lib/localDb"; import bcrypt from "bcryptjs"; import { updateSettingsSchema, validateBody } from "@/shared/validation/schemas"; +import { getRuntimePorts } from "@/lib/runtime/ports"; export async function GET() { try { @@ -9,11 +10,15 @@ export async function GET() { const { password, ...safeSettings } = settings; const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true"; + const runtimePorts = getRuntimePorts(); return NextResponse.json({ ...safeSettings, enableRequestLogs, hasPassword: !!password || !!process.env.INITIAL_PASSWORD, + runtimePorts, + apiPort: runtimePorts.apiPort, + dashboardPort: runtimePorts.dashboardPort, }); } catch (error) { console.log("Error getting settings:", error); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index d537bd4680..4ebbaef73c 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -29,6 +29,9 @@ export async function register() { const { initGracefulShutdown } = await import("@/lib/gracefulShutdown"); initGracefulShutdown(); + const { initApiBridgeServer } = await import("@/lib/apiBridgeServer"); + initApiBridgeServer(); + // Compliance: Initialize audit_log table + cleanup expired logs try { const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index"); diff --git a/src/lib/apiBridgeServer.ts b/src/lib/apiBridgeServer.ts new file mode 100644 index 0000000000..1b57568d1d --- /dev/null +++ b/src/lib/apiBridgeServer.ts @@ -0,0 +1,91 @@ +import http from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { getRuntimePorts } from "@/lib/runtime/ports"; + +const OPENAI_COMPAT_PATHS = [ + /^\/v1(?:\/|$)/, + /^\/chat\/completions(?:\?|$)/, + /^\/responses(?:\?|$)/, + /^\/models(?:\?|$)/, + /^\/codex(?:\/|\?|$)/, +]; + +function isOpenAiCompatiblePath(pathname: string): boolean { + return OPENAI_COMPAT_PATHS.some((pattern) => pattern.test(pathname)); +} + +function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: number): void { + const targetReq = http.request( + { + hostname: "127.0.0.1", + port: dashboardPort, + method: req.method, + path: req.url, + headers: { + ...req.headers, + host: `127.0.0.1:${dashboardPort}`, + }, + }, + (targetRes) => { + res.writeHead(targetRes.statusCode || 502, targetRes.headers); + targetRes.pipe(res); + } + ); + + targetReq.on("error", (error) => { + if (res.headersSent) return; + res.writeHead(502, { "content-type": "application/json" }); + res.end( + JSON.stringify({ error: "api_bridge_unavailable", detail: String(error.message || error) }) + ); + }); + + req.pipe(targetReq); +} + +declare global { + // eslint-disable-next-line no-var + var __omnirouteApiBridgeStarted: boolean | undefined; +} + +export function initApiBridgeServer(): void { + if (globalThis.__omnirouteApiBridgeStarted) return; + + const { apiPort, dashboardPort } = getRuntimePorts(); + if (apiPort === dashboardPort) return; + + const host = process.env.HOSTNAME || "0.0.0.0"; + + const server = http.createServer((req, res) => { + const rawUrl = req.url || "/"; + const pathname = rawUrl.split("?")[0] || "/"; + + if (!isOpenAiCompatiblePath(pathname)) { + res.writeHead(404, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "not_found", + message: "API port only serves OpenAI-compatible routes.", + }) + ); + return; + } + + proxyRequest(req, res, dashboardPort); + }); + + server.on("error", (error: NodeJS.ErrnoException) => { + if (error?.code === "EADDRINUSE") { + console.warn( + `[API Bridge] Port ${apiPort} is already in use. API bridge disabled. (dashboard: ${dashboardPort})` + ); + return; + } + console.warn("[API Bridge] Failed to start:", error?.message || error); + }); + + server.listen(apiPort, host, () => { + globalThis.__omnirouteApiBridgeStarted = true; + console.log(`[API Bridge] Listening on ${host}:${apiPort} -> dashboard:${dashboardPort}`); + }); +} diff --git a/src/lib/oauth/config/index.ts b/src/lib/oauth/config/index.ts index 03291185ab..d6ae85cc4b 100644 --- a/src/lib/oauth/config/index.ts +++ b/src/lib/oauth/config/index.ts @@ -11,13 +11,21 @@ interface ServerCredentials { userId: string; } +function getDefaultApiServer() { + const basePort = Number.parseInt(process.env.OMNIROUTE_PORT || process.env.PORT || "20128", 10); + const fallbackPort = Number.isFinite(basePort) ? basePort : 20128; + const apiPort = Number.parseInt(process.env.API_PORT || String(fallbackPort), 10); + const effectivePort = Number.isFinite(apiPort) ? apiPort : fallbackPort; + return `http://localhost:${effectivePort}`; +} + /** * Get server credentials from environment variables. * Used by OAuth CLI services to save tokens to the running server. */ export function getServerCredentials(): ServerCredentials { return { - server: process.env.OMNIROUTE_SERVER || process.env.SERVER_URL || "http://localhost:20128", + server: process.env.OMNIROUTE_SERVER || process.env.SERVER_URL || getDefaultApiServer(), token: process.env.OMNIROUTE_TOKEN || process.env.CLI_TOKEN || "", userId: process.env.OMNIROUTE_USER_ID || process.env.CLI_USER_ID || "cli", }; diff --git a/src/lib/runtime/ports.ts b/src/lib/runtime/ports.ts new file mode 100644 index 0000000000..af880aa8dc --- /dev/null +++ b/src/lib/runtime/ports.ts @@ -0,0 +1,32 @@ +const DEFAULT_PORT = 20128; + +function parsePort(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) return fallback; + return parsed; +} + +export type RuntimePorts = { + port: number; + apiPort: number; + dashboardPort: number; + apiPortExplicit: boolean; + dashboardPortExplicit: boolean; +}; + +export function getRuntimePorts(): RuntimePorts { + // OMNIROUTE_PORT preserves the user's canonical PORT in wrapped runtimes + // where Next.js requires process.env.PORT to be the dashboard listener port. + const basePort = parsePort(process.env.OMNIROUTE_PORT || process.env.PORT, DEFAULT_PORT); + const apiPortExplicit = !!process.env.API_PORT; + const dashboardPortExplicit = !!process.env.DASHBOARD_PORT; + + return { + port: basePort, + apiPort: parsePort(process.env.API_PORT, basePort), + dashboardPort: parsePort(process.env.DASHBOARD_PORT, basePort), + apiPortExplicit, + dashboardPortExplicit, + }; +} diff --git a/src/shared/services/cloudSyncScheduler.ts b/src/shared/services/cloudSyncScheduler.ts index 30e3a21eae..84becf0ae1 100644 --- a/src/shared/services/cloudSyncScheduler.ts +++ b/src/shared/services/cloudSyncScheduler.ts @@ -1,11 +1,14 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { isCloudEnabled } from "@/lib/localDb"; +import { getRuntimePorts } from "@/lib/runtime/ports"; + +const { dashboardPort } = getRuntimePorts(); const INTERNAL_BASE_URL = process.env.BASE_URL || process.env.NEXT_PUBLIC_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || - "http://localhost:20128"; + `http://localhost:${dashboardPort}`; /** * Cloud sync scheduler diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 53de65b5b7..7a89d27969 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -13,6 +13,9 @@ declare namespace NodeJS { PROMPT_CACHE_MAX_SIZE?: string; PROMPT_CACHE_TTL_MS?: string; NEXT_PUBLIC_CLOUD_URL?: string; + API_PORT?: string; + DASHBOARD_PORT?: string; + OMNIROUTE_PORT?: string; NODE_ENV?: "development" | "production" | "test"; } } From fb597c677e12713221e7b6850a804c0e990a2ebb Mon Sep 17 00:00:00 2001 From: Steven Rafferty Date: Thu, 26 Feb 2026 15:15:35 +0000 Subject: [PATCH 2/4] feat: improve API configuration and script execution - Added API_HOST variable to .env.example for enhanced host configuration. - Updated package.json scripts to utilize a new run-next.mjs script for development and production. - Introduced run-next.mjs to manage Next.js server execution with dynamic port handling. - Enhanced route.ts files to normalize API base URLs and improve configuration checks. - Updated apiBridgeServer.ts to use API_HOST for server initialization. - Expanded global TypeScript definitions to include API_HOST. --- .env.example | 1 + package.json | 4 +- scripts/run-next.mjs | 41 +++++++++++++++++++ .../guide-settings/[toolId]/route.ts | 24 +++++++++-- src/app/api/cli-tools/status/route.ts | 10 ++++- src/lib/apiBridgeServer.ts | 4 +- src/types/global.d.ts | 1 + 7 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 scripts/run-next.mjs diff --git a/.env.example b/.env.example index 379c72b5f9..0ed1391ba2 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,7 @@ SQLITE_CLEAN_LEGACY_FILES=true PORT=20128 # Optional split ports: # API_PORT=20128 +# API_HOST=0.0.0.0 # DASHBOARD_PORT=20128 NODE_ENV=production INSTANCE_NAME=omniroute diff --git a/package.json b/package.json index b57b582870..78532795e1 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,10 @@ }, "homepage": "https://omniroute.online", "scripts": { - "dev": "OMNIROUTE_PORT=${PORT:-20128} next dev --webpack --port ${DASHBOARD_PORT:-${PORT:-20128}}", + "dev": "node scripts/run-next.mjs dev", "build": "next build --webpack", "build:cli": "node scripts/prepublish.mjs", - "start": "OMNIROUTE_PORT=${PORT:-20128} next start --port ${DASHBOARD_PORT:-${PORT:-20128}}", + "start": "node scripts/run-next.mjs start", "lint": "eslint .", "test": "node --test tests/unit/*.test.mjs", "test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs", diff --git a/scripts/run-next.mjs b/scripts/run-next.mjs new file mode 100644 index 0000000000..0214bed3b3 --- /dev/null +++ b/scripts/run-next.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; + +function parsePort(value, fallback) { + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; +} + +const mode = process.argv[2] === "start" ? "start" : "dev"; + +const basePort = parsePort(process.env.PORT || "20128", 20128); +const apiPort = parsePort(process.env.API_PORT || String(basePort), basePort); +const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(basePort), basePort); + +const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)]; +if (mode === "dev") { + args.splice(2, 0, "--webpack"); +} + +const child = spawn(process.execPath, args, { + stdio: "inherit", + env: { + ...process.env, + OMNIROUTE_PORT: String(basePort), + PORT: String(dashboardPort), + DASHBOARD_PORT: String(dashboardPort), + API_PORT: String(apiPort), + }, +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); + +process.on("SIGINT", () => child.kill("SIGINT")); +process.on("SIGTERM", () => child.kill("SIGTERM")); diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index 38fca04b98..fd52913c94 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -53,22 +53,40 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) { } // Build the OmniRoute model entry + const normalizedBaseUrl = String(baseUrl || "") + .trim() + .replace(/\/+$/, ""); const routerModel = { - apiBase: baseUrl, + apiBase: normalizedBaseUrl, title: model, model: model, provider: "openai", apiKey: apiKey || "sk_omniroute", + omnirouteManaged: true, }; // Merge into existing models array const models = existingConfig.models || []; + function normalizeApiBase(value: unknown): string { + return String(value || "") + .trim() + .replace(/\/+$/, "") + .toLowerCase(); + } + // Check if OmniRoute entry already exists and update it, or add new const existingIdx = models.findIndex( (m) => - m.apiBase && - (m.apiBase.includes("localhost") || m.apiBase.includes("omniroute") || m.title === model) + m && + (m.omnirouteManaged === true || + normalizeApiBase(m.apiBase) === normalizedBaseUrl.toLowerCase() || + normalizeApiBase(m.apiBase).includes("omniroute") || + normalizeApiBase(m.apiBase).includes("localhost:20128") || + normalizeApiBase(m.apiBase).includes("127.0.0.1:20128") || + String(m.apiKey || "") + .toLowerCase() + .includes("sk_omniroute")) ); if (existingIdx >= 0) { diff --git a/src/app/api/cli-tools/status/route.ts b/src/app/api/cli-tools/status/route.ts index 1465d7f66d..aca9dcb80f 100644 --- a/src/app/api/cli-tools/status/route.ts +++ b/src/app/api/cli-tools/status/route.ts @@ -8,6 +8,9 @@ import { getCliPrimaryConfigPath, } from "@/shared/services/cliRuntime"; import { getAllCliToolLastConfigured } from "@/lib/db/cliToolState"; +import { getRuntimePorts } from "@/lib/runtime/ports"; + +const { apiPort } = getRuntimePorts(); // Check if a tool has OmniRoute configured by reading its config file directly // This replaces the expensive self-referential HTTP calls to /api/cli-tools/*-settings @@ -31,9 +34,12 @@ async function checkToolConfigStatus(toolId: string): Promise { case "openclaw": case "cline": case "kilo": - // Generic check: look for any OmniRoute-related URL in the config + // Generic check: look for OmniRoute-specific markers in the config const configStr = JSON.stringify(config).toLowerCase(); - return configStr.includes("omniroute") || configStr.includes("localhost") + return configStr.includes("omniroute") || + configStr.includes("sk_omniroute") || + configStr.includes(`localhost:${apiPort}`) || + configStr.includes(`127.0.0.1:${apiPort}`) ? "configured" : "not_configured"; default: diff --git a/src/lib/apiBridgeServer.ts b/src/lib/apiBridgeServer.ts index 1b57568d1d..1271481b9c 100644 --- a/src/lib/apiBridgeServer.ts +++ b/src/lib/apiBridgeServer.ts @@ -44,7 +44,7 @@ function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: } declare global { - // eslint-disable-next-line no-var + var __omnirouteApiBridgeStarted: boolean | undefined; } @@ -54,7 +54,7 @@ export function initApiBridgeServer(): void { const { apiPort, dashboardPort } = getRuntimePorts(); if (apiPort === dashboardPort) return; - const host = process.env.HOSTNAME || "0.0.0.0"; + const host = process.env.API_HOST || "0.0.0.0"; const server = http.createServer((req, res) => { const rawUrl = req.url || "/"; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 7a89d27969..b711270e5b 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -14,6 +14,7 @@ declare namespace NodeJS { PROMPT_CACHE_TTL_MS?: string; NEXT_PUBLIC_CLOUD_URL?: string; API_PORT?: string; + API_HOST?: string; DASHBOARD_PORT?: string; OMNIROUTE_PORT?: string; NODE_ENV?: "development" | "production" | "test"; From 344e602b262c53ad92e519fbee7ff37eb524c9ca Mon Sep 17 00:00:00 2001 From: Steven Rafferty Date: Thu, 26 Feb 2026 15:43:02 +0000 Subject: [PATCH 3/4] feat: enhance runtime port management and configuration - Updated .env.example to include optional production ports for API and dashboard. - Modified docker-compose files to utilize dynamic port configuration. - Introduced runtime-env.mjs for centralized port resolution and environment variable management. - Refactored run-next.mjs and run-standalone.mjs to leverage new runtime port handling. - Enhanced route.ts and apiBridgeServer.ts to utilize dynamic ports for improved API integration. - Updated OAuth configuration to reflect changes in port management. --- .env.example | 5 ++- Dockerfile | 1 + bin/omniroute.mjs | 6 +-- docker-compose.prod.yml | 8 +++- docker-compose.yml | 18 ++++++-- scripts/run-next.mjs | 37 ++++------------ scripts/run-standalone.mjs | 36 ++++------------ scripts/runtime-env.mjs | 43 +++++++++++++++++++ .../guide-settings/[toolId]/route.ts | 6 ++- src/lib/apiBridgeServer.ts | 12 ++++-- src/lib/oauth/config/index.ts | 9 ++-- 11 files changed, 106 insertions(+), 75 deletions(-) create mode 100644 scripts/runtime-env.mjs diff --git a/.env.example b/.env.example index 0ed1391ba2..4abc1cbd02 100644 --- a/.env.example +++ b/.env.example @@ -26,9 +26,12 @@ SQLITE_CLEAN_LEGACY_FILES=true # Canonical/base port (keeps backward compatibility) PORT=20128 # Optional split ports: -# API_PORT=20128 +# API_PORT=20129 # API_HOST=0.0.0.0 # DASHBOARD_PORT=20128 +# Optional Docker production host publish ports: +# PROD_DASHBOARD_PORT=20130 +# PROD_API_PORT=20131 NODE_ENV=production INSTANCE_NAME=omniroute diff --git a/Dockerfile b/Dockerfile index ffb9c07364..6b4472cfe6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ COPY --from=builder /app/public ./public 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 EXPOSE 20128 diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index a7e74a4dbd..7bf3ad527c 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -67,12 +67,12 @@ function parsePort(value, fallback) { let port = parsePort(process.env.PORT || "20128", 20128); const portIdx = args.indexOf("--port"); if (portIdx !== -1 && args[portIdx + 1]) { - const parsed = parseInt(args[portIdx + 1], 10); - if (isNaN(parsed)) { + const cliPort = parsePort(args[portIdx + 1], null); + if (cliPort === null) { console.error("\x1b[31mβœ– Invalid port number\x1b[0m"); process.exit(1); } - port = parsed; + port = cliPort; } const apiPort = parsePort(process.env.API_PORT || String(port), port); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f129888275..0b1a11fa4c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -22,11 +22,15 @@ services: env_file: .env environment: - NODE_ENV=production - - PORT=20128 + - PORT=${PORT:-20128} + - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} + - API_PORT=${API_PORT:-20129} + - API_HOST=${API_HOST:-0.0.0.0} - HOSTNAME=0.0.0.0 - DATA_DIR=/app/data ports: - - "20130:20128" + - "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${PROD_API_PORT:-20131}:${API_PORT:-20129}" volumes: - omniroute-prod-data:/app/data healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 40441ace65..d13768bc82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,10 @@ x-common: &common env_file: .env environment: - DATA_DIR=/app/data # Must match the volume mount below + - PORT=${PORT:-20128} + - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} + - API_PORT=${API_PORT:-20129} + - API_HOST=${API_HOST:-0.0.0.0} volumes: - omniroute-data:/app/data healthcheck: @@ -45,7 +49,8 @@ services: target: runner-base image: omniroute:base ports: - - "${PORT:-20128}:20128" + - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${API_PORT:-20129}:${API_PORT:-20129}" profiles: - base @@ -58,7 +63,8 @@ services: target: runner-cli image: omniroute:cli ports: - - "${PORT:-20128}:20128" + - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${API_PORT:-20129}:${API_PORT:-20129}" profiles: - cli @@ -71,8 +77,14 @@ services: target: runner-base image: omniroute:base ports: - - "${PORT:-20128}:20128" + - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${API_PORT:-20129}:${API_PORT:-20129}" environment: + - DATA_DIR=/app/data + - PORT=${PORT:-20128} + - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} + - API_PORT=${API_PORT:-20129} + - API_HOST=${API_HOST:-0.0.0.0} - CLI_MODE=host - CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin - CLI_CONFIG_HOME=/host-home diff --git a/scripts/run-next.mjs b/scripts/run-next.mjs index 0214bed3b3..c56c8455f0 100644 --- a/scripts/run-next.mjs +++ b/scripts/run-next.mjs @@ -1,41 +1,22 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; - -function parsePort(value, fallback) { - const parsed = Number.parseInt(String(value), 10); - return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; -} +import { + resolveRuntimePorts, + withRuntimePortEnv, + spawnWithForwardedSignals, +} from "./runtime-env.mjs"; const mode = process.argv[2] === "start" ? "start" : "dev"; -const basePort = parsePort(process.env.PORT || "20128", 20128); -const apiPort = parsePort(process.env.API_PORT || String(basePort), basePort); -const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(basePort), basePort); +const runtimePorts = resolveRuntimePorts(); +const { dashboardPort } = runtimePorts; const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)]; if (mode === "dev") { args.splice(2, 0, "--webpack"); } -const child = spawn(process.execPath, args, { +spawnWithForwardedSignals(process.execPath, args, { stdio: "inherit", - env: { - ...process.env, - OMNIROUTE_PORT: String(basePort), - PORT: String(dashboardPort), - DASHBOARD_PORT: String(dashboardPort), - API_PORT: String(apiPort), - }, + env: withRuntimePortEnv(process.env, runtimePorts), }); - -child.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exit(code ?? 0); -}); - -process.on("SIGINT", () => child.kill("SIGINT")); -process.on("SIGTERM", () => child.kill("SIGTERM")); diff --git a/scripts/run-standalone.mjs b/scripts/run-standalone.mjs index b48ccf3eb2..1510cdc83a 100644 --- a/scripts/run-standalone.mjs +++ b/scripts/run-standalone.mjs @@ -1,34 +1,14 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; +import { + resolveRuntimePorts, + withRuntimePortEnv, + spawnWithForwardedSignals, +} from "./runtime-env.mjs"; -function parsePort(value, fallback) { - const parsed = Number.parseInt(String(value), 10); - return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; -} +const runtimePorts = resolveRuntimePorts(); -const basePort = parsePort(process.env.PORT || "20128", 20128); -const apiPort = parsePort(process.env.API_PORT || String(basePort), basePort); -const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(basePort), basePort); - -const child = spawn("node", ["server.js"], { +spawnWithForwardedSignals("node", ["server.js"], { stdio: "inherit", - env: { - ...process.env, - OMNIROUTE_PORT: String(basePort), - PORT: String(dashboardPort), - DASHBOARD_PORT: String(dashboardPort), - API_PORT: String(apiPort), - }, + env: withRuntimePortEnv(process.env, runtimePorts), }); - -child.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exit(code ?? 0); -}); - -process.on("SIGINT", () => child.kill("SIGINT")); -process.on("SIGTERM", () => child.kill("SIGTERM")); diff --git a/scripts/runtime-env.mjs b/scripts/runtime-env.mjs new file mode 100644 index 0000000000..489685d830 --- /dev/null +++ b/scripts/runtime-env.mjs @@ -0,0 +1,43 @@ +import { spawn } from "node:child_process"; + +export function parsePort(value, fallback) { + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; +} + +export function resolveRuntimePorts() { + const basePort = parsePort(process.env.PORT || "20128", 20128); + const apiPort = parsePort(process.env.API_PORT || String(basePort), basePort); + const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(basePort), basePort); + + return { basePort, apiPort, dashboardPort }; +} + +export function withRuntimePortEnv(env, runtimePorts) { + const { basePort, apiPort, dashboardPort } = runtimePorts; + + return { + ...env, + OMNIROUTE_PORT: String(basePort), + PORT: String(dashboardPort), + DASHBOARD_PORT: String(dashboardPort), + API_PORT: String(apiPort), + }; +} + +export function spawnWithForwardedSignals(command, args, options = {}) { + const child = spawn(command, args, options); + + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); + + process.on("SIGINT", () => child.kill("SIGINT")); + process.on("SIGTERM", () => child.kill("SIGTERM")); + + return child; +} diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index fd52913c94..8b65f7b6a6 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { getRuntimePorts } from "@/lib/runtime/ports"; /** * POST /api/cli-tools/guide-settings/:toolId @@ -37,6 +38,7 @@ export async function POST(request, { params }) { * Merges with existing config if present. */ async function saveContinueConfig({ baseUrl, apiKey, model }) { + const { apiPort } = getRuntimePorts(); const configPath = path.join(os.homedir(), ".continue", "config.json"); const configDir = path.dirname(configPath); @@ -82,8 +84,8 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) { (m.omnirouteManaged === true || normalizeApiBase(m.apiBase) === normalizedBaseUrl.toLowerCase() || normalizeApiBase(m.apiBase).includes("omniroute") || - normalizeApiBase(m.apiBase).includes("localhost:20128") || - normalizeApiBase(m.apiBase).includes("127.0.0.1:20128") || + normalizeApiBase(m.apiBase).includes(`localhost:${apiPort}`) || + normalizeApiBase(m.apiBase).includes(`127.0.0.1:${apiPort}`) || String(m.apiKey || "") .toLowerCase() .includes("sk_omniroute")) diff --git a/src/lib/apiBridgeServer.ts b/src/lib/apiBridgeServer.ts index 1271481b9c..61566a84ec 100644 --- a/src/lib/apiBridgeServer.ts +++ b/src/lib/apiBridgeServer.ts @@ -36,15 +36,21 @@ function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: if (res.headersSent) return; res.writeHead(502, { "content-type": "application/json" }); res.end( - JSON.stringify({ error: "api_bridge_unavailable", detail: String(error.message || error) }) + JSON.stringify({ + error: "api_bridge_unavailable", + detail: String(error.message || error), + }) ); }); + req.on("aborted", () => { + targetReq.destroy(); + }); + req.pipe(targetReq); } declare global { - var __omnirouteApiBridgeStarted: boolean | undefined; } @@ -54,7 +60,7 @@ export function initApiBridgeServer(): void { const { apiPort, dashboardPort } = getRuntimePorts(); if (apiPort === dashboardPort) return; - const host = process.env.API_HOST || "0.0.0.0"; + const host = process.env.API_HOST || "127.0.0.1"; const server = http.createServer((req, res) => { const rawUrl = req.url || "/"; diff --git a/src/lib/oauth/config/index.ts b/src/lib/oauth/config/index.ts index d6ae85cc4b..7484e1baf4 100644 --- a/src/lib/oauth/config/index.ts +++ b/src/lib/oauth/config/index.ts @@ -5,6 +5,8 @@ * with the running OmniRoute server when saving tokens. */ +import { getRuntimePorts } from "@/lib/runtime/ports"; + interface ServerCredentials { server: string; token: string; @@ -12,11 +14,8 @@ interface ServerCredentials { } function getDefaultApiServer() { - const basePort = Number.parseInt(process.env.OMNIROUTE_PORT || process.env.PORT || "20128", 10); - const fallbackPort = Number.isFinite(basePort) ? basePort : 20128; - const apiPort = Number.parseInt(process.env.API_PORT || String(fallbackPort), 10); - const effectivePort = Number.isFinite(apiPort) ? apiPort : fallbackPort; - return `http://localhost:${effectivePort}`; + const { dashboardPort } = getRuntimePorts(); + return `http://localhost:${dashboardPort}`; } /** From 01c1bbfe298afa2dcdafebbd39ea39d0ce8c1e7c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 27 Feb 2026 16:29:58 -0300 Subject: [PATCH 4/4] 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 6b4472cfe6..2658f63645 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 0b1a11fa4c..a58a8a505a 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 d13768bc82..e1ee53f450 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 0000000000..92d3e3e28d --- /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 61566a84ec..b8786ab081 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 0000000000..fedea0a3b5 --- /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); + }); +});