Merge PR #140: refactor split ports (npmSteven) with polish fixes

Adds split-port runtime support: API and dashboard can run on separate ports.
- PORT remains canonical/base
- API_PORT optionally overrides API listener
- DASHBOARD_PORT optionally overrides dashboard listener
- API bridge server proxies OpenAI-compatible routes with 30s timeout
- Centralized port resolution in src/lib/runtime/ports.ts
- Unit tests for runtime port resolution (14 tests)
- Healthcheck extracted into scripts/healthcheck.mjs
This commit is contained in:
diegosouzapw
2026-02-27 16:30:17 -03:00
24 changed files with 513 additions and 59 deletions

View File

@@ -23,7 +23,15 @@ 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=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

View File

@@ -27,13 +27,16 @@ 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
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 "fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))"
CMD ["node", "healthcheck.mjs"]
CMD ["node", "server.js"]
CMD ["node", "run-standalone.mjs"]
FROM runner-base AS runner-cli
@@ -45,4 +48,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

View File

@@ -170,12 +170,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:**
@@ -197,7 +207,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
```
---
@@ -985,9 +995,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**

View File

@@ -30,18 +30,18 @@ if (args.includes("--help") || args.includes("-h")) {
\x1b[1mUsage:\x1b[0m
omniroute Start the server
omniroute --port <port> Use custom port (default: 20128)
omniroute --port <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:<port>
API: http://localhost:<port>/v1
Dashboard: http://localhost:<dashboard-port>
API: http://localhost:<api-port>/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:<api-port>/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 cliPort = parsePort(args[portIdx + 1], null);
if (cliPort === null) {
console.error("\x1b[31m✖ Invalid port number\x1b[0m");
process.exit(1);
}
port = cliPort;
}
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 ─────────────────────────────────────────────────
@@ -110,7 +119,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",
};
@@ -168,16 +180,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
`);
@@ -185,7 +198,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
}

View File

@@ -22,21 +22,19 @@ 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:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:20128/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

View File

@@ -20,16 +20,14 @@ 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:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:20128/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
@@ -45,7 +43,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 +57,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 +71,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

View File

@@ -42,10 +42,10 @@
},
"homepage": "https://omniroute.online",
"scripts": {
"dev": "next dev --webpack --port 20128",
"dev": "node scripts/run-next.mjs dev",
"build": "next build --webpack",
"build:cli": "node scripts/prepublish.mjs",
"start": "next start --port 20128",
"start": "node scripts/run-next.mjs start",
"lint": "eslint .",
"test": "node --import tsx/esm --test tests/unit/*.test.mjs",
"test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs",

View File

@@ -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,
},

14
scripts/healthcheck.mjs Normal file
View File

@@ -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));

22
scripts/run-next.mjs Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
import {
resolveRuntimePorts,
withRuntimePortEnv,
spawnWithForwardedSignals,
} from "./runtime-env.mjs";
const mode = process.argv[2] === "start" ? "start" : "dev";
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");
}
spawnWithForwardedSignals(process.execPath, args, {
stdio: "inherit",
env: withRuntimePortEnv(process.env, runtimePorts),
});

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env node
import {
resolveRuntimePorts,
withRuntimePortEnv,
spawnWithForwardedSignals,
} from "./runtime-env.mjs";
const runtimePorts = resolveRuntimePorts();
spawnWithForwardedSignals("node", ["server.js"], {
stdio: "inherit",
env: withRuntimePortEnv(process.env, runtimePorts),
});

43
scripts/runtime-env.mjs Normal file
View File

@@ -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;
}

View File

@@ -32,6 +32,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();
@@ -46,6 +47,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);
@@ -147,6 +154,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;
}

View File

@@ -22,6 +22,7 @@ export default function OnboardingWizard() {
const tc = useTranslations("common");
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("");
@@ -40,11 +41,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;
@@ -387,7 +397,7 @@ export default function OnboardingWizard() {
<p className="text-text-muted">{t("doneDesc")}</p>
<div className="bg-white/[0.03] rounded-xl p-4 border border-white/[0.06] text-left">
<p className="text-xs text-text-muted mb-2 font-medium">{t("yourEndpoint")}</p>
<code className="text-sm text-primary">http://localhost:20128/api/v1</code>
<code className="text-sm text-primary">{apiEndpoint}</code>
</div>
</div>
)}

View File

@@ -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);
@@ -53,24 +55,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:20128") ||
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:${apiPort}`) ||
normalizeApiBase(m.apiBase).includes(`127.0.0.1:${apiPort}`) ||
String(m.apiKey || "")
.toLowerCase()
.includes("sk_omniroute"))
);
if (existingIdx >= 0) {

View File

@@ -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<string> {
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("20128")
return configStr.includes("omniroute") ||
configStr.includes("sk_omniroute") ||
configStr.includes(`localhost:${apiPort}`) ||
configStr.includes(`127.0.0.1:${apiPort}`)
? "configured"
: "not_configured";
default:

View File

@@ -3,6 +3,7 @@ import { getSettings, updateSettings } from "@/lib/localDb";
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
import bcrypt from "bcryptjs";
import { updateSettingsSchema, validateBody } from "@/shared/validation/schemas";
import { getRuntimePorts } from "@/lib/runtime/ports";
export async function GET() {
try {
@@ -10,11 +11,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);

View File

@@ -31,6 +31,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");

112
src/lib/apiBridgeServer.ts Normal file
View File

@@ -0,0 +1,112 @@
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(?:\?|$)/,
/^\/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}`,
},
timeout: PROXY_TIMEOUT_MS,
},
(targetRes) => {
res.writeHead(targetRes.statusCode || 502, targetRes.headers);
targetRes.pipe(res);
}
);
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" });
res.end(
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;
}
export function initApiBridgeServer(): void {
if (globalThis.__omnirouteApiBridgeStarted) return;
const { apiPort, dashboardPort } = getRuntimePorts();
if (apiPort === dashboardPort) return;
const host = process.env.API_HOST || "127.0.0.1";
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}`);
});
}

View File

@@ -5,19 +5,26 @@
* with the running OmniRoute server when saving tokens.
*/
import { getRuntimePorts } from "@/lib/runtime/ports";
interface ServerCredentials {
server: string;
token: string;
userId: string;
}
function getDefaultApiServer() {
const { dashboardPort } = getRuntimePorts();
return `http://localhost:${dashboardPort}`;
}
/**
* 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",
};

32
src/lib/runtime/ports.ts Normal file
View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -13,6 +13,10 @@ declare namespace NodeJS {
PROMPT_CACHE_MAX_SIZE?: string;
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";
}
}

View File

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