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);
+ });
+});