diff --git a/.env.example b/.env.example index 8f3a700dde..d06f164c34 100644 --- a/.env.example +++ b/.env.example @@ -86,8 +86,8 @@ PORT=20128 # Port for the real-time WebSocket live monitoring server. # Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts -# Default: 20129 -# LIVE_WS_PORT=20129 +# Default: 20132 +# LIVE_WS_PORT=20132 # Bind address for the live WebSocket server. # Default: 127.0.0.1 (loopback only). Set to 0.0.0.0 to expose on LAN — @@ -112,16 +112,14 @@ PORT=20128 # Public URL for the live dashboard WebSocket (client-side, browser only). # Set this when fronting the WS server with a reverse proxy or Cloudflare Tunnel. -# The browser will connect to this URL instead of ws://hostname:20129. -# The /live-ws path is already proxied from the main app (port 20128) to the -# live WS server (port 20129) by scripts/dev/standalone-server-ws.mjs. -# Used by: src/hooks/useLiveDashboard.ts +# The browser will connect to this URL instead of ws://hostname:20132. +# The path portion of this URL (e.g. ws://localhost:20132/live-ws -> /live-ws) is also used by the dev proxy +# (scripts/dev/standalone-server-ws.mjs) and the handshake response to route +# WebSocket upgrades. Default path: /live-ws. +# Used by: src/hooks/useLiveDashboard.ts, src/app/api/v1/ws/route.ts, +# scripts/dev/standalone-server-ws.mjs, and scripts/start-ws-server.mjs. # Example: NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=wss://ws.my-ai.com/live-ws -# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL= - -# Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs. -# Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle). -# OMNIROUTE_DISABLE_LIVE_WS=0 +# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=ws://localhost:20132/live-ws # Enable the real-time dashboard WebSocket server. # Used by: src/server/ws/liveServer.ts, scripts/start-ws-server.mjs @@ -197,9 +195,9 @@ OMNIROUTE_USE_TURBOPACK=1 # the machine name by bash/zsh. The .env loader cannot override it (first-wins # semantics). Use OMNIROUTE_SERVER_HOST instead for `omniroute serve`. # See: https://github.com/diegosouzapw/OmniRoute/issues/6194 -#HOST=0.0.0.0 -#HOSTNAME=127.0.0.1 -#OMNIROUTE_SERVER_HOST=0.0.0.0 +# HOST=0.0.0.0 +# HOSTNAME=127.0.0.1 +# OMNIROUTE_SERVER_HOST=0.0.0.0 # Environment mode — affects Next.js behavior, logging verbosity, and caching. # Values: production | development | Default: production diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 9d3c854412..a3006bcdf3 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1502,11 +1502,6 @@ "count": 1 } }, - "tests/unit/live-ws-public-url.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, "tests/unit/lmarena-provider.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 9 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b442de5f10..3c995fa15e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -58,12 +58,16 @@ services: - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} - API_PORT=${API_PORT:-20129} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:${PROD_DASHBOARD_PORT:-20130},http://127.0.0.1:${PROD_DASHBOARD_PORT:-20130}} - API_HOST=${API_HOST:-0.0.0.0} - HOSTNAME=0.0.0.0 - DATA_DIR=/app/data ports: - "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}" - "${PROD_API_PORT:-20131}:${API_PORT:-20129}" + - "${PROD_LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - omniroute-prod-data:/app/data healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 2560644527..9b3add8ee3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,9 @@ x-common: &common - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - REDIS_URL=${REDIS_URL:-redis://redis:6379} volumes: - ./data:/app/data @@ -75,6 +78,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" profiles: - base @@ -92,6 +96,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" profiles: - web @@ -106,6 +111,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - ./data:/app/data - /var/run/docker.sock:/var/run/docker.sock @@ -125,12 +131,16 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" environment: - DATA_DIR=/app/data - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - CLI_MODE=host - CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin - CLI_CONFIG_HOME=/host-home diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 1963c24302..f4ba64fdbf 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -115,33 +115,32 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari ## 3. Network & Ports -| Variable | Default | Source File | Description | -| ------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | -| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | -| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | -| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | -| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | -| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | -| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | -| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | -| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | Port for the real-time WebSocket live monitoring server. | -| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | Bind address for the live WebSocket server. Set to `0.0.0.0` to expose on LAN (also configure `LIVE_WS_ALLOWED_ORIGINS`). | -| `LIVE_WS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/ws/liveServer.ts` | Comma-separated extra origins allowed to open a live WebSocket. Loopback dashboard origins are already permitted by default. | -| `LIVE_WS_ALLOWED_HOSTS` | _(unset)_ | `src/server/ws/liveServerAllowList.ts` | Comma-separated extra hostnames allowed for live WebSocket origins. Unlike `LIVE_WS_ALLOWED_ORIGINS` (full origin URLs), matches only the host portion — useful for LAN/Tailscale setups. | -| `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` | _(unset)_ | `src/hooks/useLiveDashboard.ts` | Public URL for the live dashboard WebSocket (browser-side). Set when fronting the WS server with a reverse proxy or Cloudflare Tunnel (e.g. `wss://ws.my-ai.com/live-ws`); the browser connects there instead of `ws://hostname:20129`. | -| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` | Set to `0` or `false` to disable the real-time WebSocket server (enabled by default, loopback-bound). | -| `OMNIROUTE_DISABLE_LIVE_WS` | `false` | `scripts/start-ws-server.mjs` | CI/harness toggle that disables the standalone live WebSocket helper script. | -| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | Per-(token, IP) relay rate limit, requests/minute. In-memory, per instance. `0` or negative disables the IP-dimension gate (per-token DB limit still applies). | -| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | -| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows or when running into native binding / bundler-compat incompatibilities. | -| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | -| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). | -| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. | -| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. | -| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Bind address for the Next.js dev/start server. Overrides the default `0.0.0.0` when set. | -| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. **Do not use for `omniroute serve`** — use `OMNIROUTE_SERVER_HOST` instead (POSIX shells auto-set `HOSTNAME` to the machine name; `.env` cannot override it). | -| `OMNIROUTE_SERVER_HOST` | `0.0.0.0` | `bin/cli/commands/serve.mjs` | Bind address for `omniroute serve`. Avoids collision with the POSIX shell `HOSTNAME` variable (always set to the machine name by bash/zsh). Falls back to `0.0.0.0` when unset. (#6194) | +| Variable | Default | Source File | Description | +| ------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | +| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | +| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | +| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | +| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | +| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | +| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | +| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | +| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | Port for the real-time WebSocket live monitoring server. | +| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | Bind address for the live WebSocket server. Set to `0.0.0.0` to expose on LAN (also configure `LIVE_WS_ALLOWED_ORIGINS`). | +| `LIVE_WS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/ws/liveServer.ts` | Comma-separated extra origins allowed to open a live WebSocket. Loopback dashboard origins are already permitted by default. | +| `LIVE_WS_ALLOWED_HOSTS` | _(unset)_ | `src/server/ws/liveServerAllowList.ts` | Comma-separated extra hostnames allowed for live WebSocket origins. Unlike `LIVE_WS_ALLOWED_ORIGINS` (full origin URLs), matches only the host portion — useful for LAN/Tailscale setups. | +| `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` | _(unset)_ | `src/hooks/useLiveDashboard.ts` | Public URL for the live dashboard WebSocket (browser-side). Set when fronting the WS server with a reverse proxy or Cloudflare Tunnel (e.g. `wss://ws.my-ai.com/live-ws`); the browser connects there instead of `ws://hostname:20132`. The pathname portion is also used as the WebSocket upgrade path (default: `/live-ws`). | +| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` and `scripts/start-ws-server.mjs` | Set to `0` or `false` to disable the real-time WebSocket server (enabled by default, loopback-bound). CI/harness toggle that disables the standalone live WebSocket helper script. | +| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | Per-(token, IP) relay rate limit, requests/minute. In-memory, per instance. `0` or negative disables the IP-dimension gate (per-token DB limit still applies). | +| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | +| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows or when running into native binding / bundler-compat incompatibilities. | +| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | +| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). | +| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. | +| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. | +| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Bind address for the Next.js dev/start server. Overrides the default `0.0.0.0` when set. | +| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. **Do not use for `omniroute serve`** — use `OMNIROUTE_SERVER_HOST` instead (POSIX shells auto-set `HOSTNAME` to the machine name; `.env` cannot override it). | +| `OMNIROUTE_SERVER_HOST` | `0.0.0.0` | `bin/cli/commands/serve.mjs` | Bind address for `omniroute serve`. Avoids collision with the POSIX shell `HOSTNAME` variable (always set to the machine name by bash/zsh). Falls back to `0.0.0.0` when unset. (#6194) | ### Port Modes diff --git a/open-sse/handlers/chatCore/telemetryHelpers.ts b/open-sse/handlers/chatCore/telemetryHelpers.ts index c6b92e683c..805acbbd8c 100644 --- a/open-sse/handlers/chatCore/telemetryHelpers.ts +++ b/open-sse/handlers/chatCore/telemetryHelpers.ts @@ -2,7 +2,7 @@ import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits"; import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; // #4604 — Lazy backoff for the best-effort live-WS sidecar bridge. In single-port -// deployments the sidecar (port 20129) is not running, so every compression event +// deployments the sidecar (port 20132) is not running, so every compression event // POST failed with ECONNREFUSED; because the global fetch is proxyFetch, each // failure logged a "[ProxyFetch] Undici dispatcher failed" warning (272× in 42min). // After a few consecutive failures we stop attempting for a cooldown window (then @@ -28,7 +28,7 @@ export async function forwardDashboardEventToLiveWs( // Skip while the bridge is in a cooldown window after repeated failures. if (liveWsDisabledUntil > now()) return; - const port = process.env.LIVE_WS_PORT || "20129"; + const port = process.env.LIVE_WS_PORT || "20132"; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1_500); try { diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index eb59d9d50a..c96c9624ef 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -19,9 +19,7 @@ const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; // TLS). Absent or misconfigured → null → identical plain-HTTP behavior as before. const tlsOptions = resolveTlsOptions(process.env); if (tlsOptions) { - console.log( - `[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}` - ); + console.log(`[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}`); } process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID(); @@ -51,8 +49,23 @@ function getProxy(server) { return proxy; } +function deriveLiveWsPath() { + const publicUrl = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + if (!publicUrl) return "/live-ws"; + if (!publicUrl.startsWith("ws://") && !publicUrl.startsWith("wss://")) return "/live-ws"; + try { + const parsed = new URL(publicUrl); + const pathname = parsed.pathname; + return pathname && pathname !== "/" ? pathname : "/live-ws"; + } catch { + return "/live-ws"; + } +} + +const LIVE_WS_PATH = deriveLiveWsPath(); + function proxyLiveWs(req, socket, head) { - const targetPort = parseInt(process.env.LIVE_WS_PORT || "20129", 10); + const targetPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10); const targetSocket = net.connect(targetPort, "127.0.0.1", () => { let rawRequest = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; for (const [key, val] of Object.entries(req.headers)) { @@ -76,8 +89,16 @@ function proxyLiveWs(req, socket, head) { function wrapUpgradeListener(server, listener) { return async function responsesWsAwareUpgrade(req, socket, head) { try { + // If this server IS the LiveWS server (port 20132), the ws library's + // own upgrade handler should process the request directly — proxying + // /live-ws back to 127.0.0.1:20132 would create an infinite self-loop. + const liveWsPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10); + if (getPort(server) === liveWsPort) { + return listener.call(this, req, socket, head); + } + const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); - if (url.pathname === "/live-ws" || url.pathname.startsWith("/live-ws")) { + if (url.pathname === LIVE_WS_PATH || url.pathname.startsWith(LIVE_WS_PATH + "/")) { proxyLiveWs(req, socket, head); return; } diff --git a/scripts/start-ws-server.mjs b/scripts/start-ws-server.mjs index 9b0d1068c3..00b4a62377 100644 --- a/scripts/start-ws-server.mjs +++ b/scripts/start-ws-server.mjs @@ -7,9 +7,9 @@ * node scripts/start-ws-server.mjs * * Environment variables: - * LIVE_WS_PORT — WebSocket server port (default: 20129) + * LIVE_WS_PORT — WebSocket server port (default: 20132) * LIVE_WS_HOST — WebSocket server host (default: 127.0.0.1) - * OMNIROUTE_DISABLE_LIVE_WS — Set to "1" or "true" to disable + * OMNIROUTE_ENABLE_LIVE_WS — Set to "0" or "false" to disable */ import { spawnSync } from "node:child_process"; @@ -60,10 +60,10 @@ export function buildSidecarSpawn(scriptUrl, env = process.env) { async function main() { if ( - process.env.OMNIROUTE_DISABLE_LIVE_WS === "1" || - process.env.OMNIROUTE_DISABLE_LIVE_WS === "true" + process.env.OMNIROUTE_ENABLE_LIVE_WS === "0" || + process.env.OMNIROUTE_ENABLE_LIVE_WS?.toLowerCase() === "false" ) { - console.log("[LiveWS] Disabled via OMNIROUTE_DISABLE_LIVE_WS"); + console.log("[LiveWS] Disabled via OMNIROUTE_ENABLE_LIVE_WS"); process.exit(0); } @@ -80,7 +80,7 @@ async function main() { const { startLiveDashboardServer } = await import("../src/server/ws/liveServer.ts"); - const port = parseInt(process.env.LIVE_WS_PORT || "20129", 10); + const port = parseInt(process.env.LIVE_WS_PORT || "20132", 10); const host = process.env.LIVE_WS_HOST || "127.0.0.1"; console.log(`[LiveWS] Starting dashboard WebSocket server on ${host}:${port}...`); diff --git a/src/app/api/v1/ws/route.ts b/src/app/api/v1/ws/route.ts index bdd629578d..fb85cc50fc 100644 --- a/src/app/api/v1/ws/route.ts +++ b/src/app/api/v1/ws/route.ts @@ -1,4 +1,5 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; +import { getLiveWsPath } from "@/shared/utils/wsPath"; import { authorizeWebSocketHandshake } from "@/lib/ws/handshake"; const WS_HANDSHAKE_HEADERS = { @@ -26,9 +27,9 @@ function getWsProtocol() { }, cancel: { type: "cancel", id: "req-1" }, live: { - port: parseInt(process.env.LIVE_WS_PORT || "20129", 10), + port: parseInt(process.env.LIVE_WS_PORT || "20132", 10), publicUrl: getLivePublicUrl(), - path: "/live", + path: getLiveWsPath(), protocol: "json", channels: ["requests", "combo", "credentials"], auth: "api-key", @@ -82,9 +83,9 @@ export async function GET(request: Request) { authType: auth.authType, protocol: getWsProtocol(), live: { - port: parseInt(process.env.LIVE_WS_PORT || "20129", 10), + port: parseInt(process.env.LIVE_WS_PORT || "20132", 10), publicUrl: getLivePublicUrl(), - path: "/live", + path: getLiveWsPath(), protocol: "json", channels: ["requests", "combo", "credentials"], auth: "api-key", diff --git a/src/app/docs/lib/openapi.generated.ts b/src/app/docs/lib/openapi.generated.ts index 7f0053095e..53876ec347 100644 --- a/src/app/docs/lib/openapi.generated.ts +++ b/src/app/docs/lib/openapi.generated.ts @@ -173,7 +173,8 @@ export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [ path: "/api/v1/providers/{provider}/models", method: "GET", summary: "List models for a specific provider", - description: "Returns only models for the selected provider with provider prefix removed from each model id.", + description: + "Returns only models for the selected provider with provider prefix removed from each model id.", tag: "Models", tags: ["Models"], requiresAuth: true, @@ -203,7 +204,8 @@ export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [ path: "/api/v1/ws", method: "GET", summary: "Chat completion over WebSocket (handshake + upgrade)", - description: "OpenAI-compatible chat over a WebSocket connection. `GET` with `?handshake=1` returns the connection descriptor (auth path, message protocol and live-event channels) as JSON; a plain `GET` without an Upgrade returns `426 Upgrade Required`. After upgrading, the client exchanges JSON frames — `{type:\"request\", id, payload:{model, messages}}` to start a completion and `{type:\"cancel\", id}` to abort it. A separate live channel (default port `LIVE_WS_PORT=20129`, path `/live`) streams dashboard events on the `requests`, `combo` and `credentials` topics with a 15s heartbeat. Requires an API key.", + description: + 'OpenAI-compatible chat over a WebSocket connection. `GET` with `?handshake=1` returns the connection descriptor (auth path, message protocol and live-event channels) as JSON; a plain `GET` without an Upgrade returns `426 Upgrade Required`. After upgrading, the client exchanges JSON frames — `{type:"request", id, payload:{model, messages}}` to start a completion and `{type:"cancel", id}` to abort it. A separate live channel (default port `LIVE_WS_PORT=20132`, path `/live`) streams dashboard events on the `requests`, `combo` and `credentials` topics with a 15s heartbeat. Requires an API key.', tag: "Chat", tags: ["Chat"], requiresAuth: true, diff --git a/src/hooks/useLiveDashboard.ts b/src/hooks/useLiveDashboard.ts index b31c4be047..05c991a31f 100644 --- a/src/hooks/useLiveDashboard.ts +++ b/src/hooks/useLiveDashboard.ts @@ -13,6 +13,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import type { DashboardChannel, DashboardEventName } from "@/lib/events/types"; +import { deriveLiveWsPath } from "@/shared/utils/wsPath"; // ── Config ──────────────────────────────────────────────────────────────── @@ -27,21 +28,22 @@ function sanitizeWsPublicUrl(url: unknown): string | null { // Build-time inlined value (Docker/npm prebuilt images won't have this — the // runtime value is discovered via the /api/v1/ws?handshake=1 handshake below). const BUILD_TIME_PUBLIC_WS_URL = sanitizeWsPublicUrl(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL); +const BUILD_TIME_WS_PATH = deriveLiveWsPath(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL); function getDefaultWsUrl(): string { if (BUILD_TIME_PUBLIC_WS_URL) return BUILD_TIME_PUBLIC_WS_URL; - if (typeof window === "undefined") return "ws://localhost:20129"; + if (typeof window === "undefined") return `ws://localhost:20132${BUILD_TIME_WS_PATH}`; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const { hostname } = window.location; - // Bug #1 fix: Use the WS server's actual port (20129) for both loopback + // Bug #1 fix: Use the WS server's actual port (20132) for both loopback // and non-loopback clients. Previously the non-loopback branch tried to // upgrade the HTTP port (window.location.host) which has no upgrade // handler in src/proxy.ts. If the user wants the upgrade to go through // Next.js (same-origin), they should explicitly pass `wsUrl`. if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") { - return `${protocol}//${hostname}:20129`; + return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`; } - return `${protocol}//${hostname}:20129`; + return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`; } const DEFAULT_WS_URL = getDefaultWsUrl(); @@ -65,7 +67,7 @@ export interface DashboardConnectionState { // ── Core Hook ───────────────────────────────────────────────────────────── export interface UseLiveDashboardOptions { - /** WebSocket URL (default: ws://hostname:20129) */ + /** WebSocket URL (default: ws://hostname:20132) */ wsUrl?: string; /** Whether the WebSocket connection should be active (default: true) */ enabled?: boolean; @@ -105,6 +107,7 @@ export function useLiveDashboard({ // Skipped when the caller passes an explicit wsUrl or the env was inlined. const needsHandshake = !wsUrl && !BUILD_TIME_PUBLIC_WS_URL && typeof window !== "undefined"; const [handshakeUrl, setHandshakeUrl] = useState(null); + const [handshakePath, setHandshakePath] = useState(null); const [wsUrlResolved, setWsUrlResolved] = useState(!needsHandshake); useEffect(() => { @@ -116,6 +119,9 @@ export function useLiveDashboard({ if (cancelled) return; const publicUrl = sanitizeWsPublicUrl(body?.live?.publicUrl); if (publicUrl) setHandshakeUrl(publicUrl); + if (typeof body?.live?.path === "string" && body.live.path.startsWith("/")) { + setHandshakePath(body.live.path); + } }) .catch(() => { // Handshake unavailable — fall back to the default URL. @@ -128,7 +134,20 @@ export function useLiveDashboard({ }; }, [needsHandshake, wsUrlResolved]); - const effectiveWsUrl = wsUrl ?? handshakeUrl ?? DEFAULT_WS_URL; + const effectiveWsUrl = (() => { + if (wsUrl) return wsUrl; + if (handshakeUrl) return handshakeUrl; + if (handshakePath && handshakePath !== BUILD_TIME_WS_PATH) { + try { + const url = new URL(DEFAULT_WS_URL); + url.pathname = handshakePath; + return url.toString(); + } catch { + return DEFAULT_WS_URL; + } + } + return DEFAULT_WS_URL; + })(); const [events, setEvents] = useState([]); const wsRef = useRef(null); diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 20d96240a4..4cc92e1d7f 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -276,9 +276,8 @@ export async function registerNodejs(): Promise { // without this the dashboard mode (auto/custom/adaptive) silently reverts to // the passthrough default on every restart. Previously this was only wired into // the unused `server-init.ts`, so it never ran in production. - const { hydrateThinkingBudgetConfig } = await import( - "@omniroute/open-sse/services/thinkingBudget.ts" - ); + const { hydrateThinkingBudgetConfig } = + await import("@omniroute/open-sse/services/thinkingBudget.ts"); if (hydrateThinkingBudgetConfig(settings)) { console.log("[STARTUP] Thinking-Budget config restored from settings"); } @@ -441,7 +440,7 @@ export async function registerNodejs(): Promise { console.warn("[STARTUP] memory decay sweep failed to start (non-fatal):", msg); } - // Real-time dashboard WebSocket daemon (port 20129): powers Combo Studio Live, + // Real-time dashboard WebSocket daemon (port 20132): powers Combo Studio Live, // the Home live-pulse, and Live Compression. liveServer.ts auto-starts the // daemon on import (gated by OMNIROUTE_ENABLE_LIVE_WS, default ON) — but NOTHING // imported it in the packaged standalone/PM2 runtime. Only the unused diff --git a/src/lib/services/embedWsProxy.ts b/src/lib/services/embedWsProxy.ts index 8ab9052545..b2b1e10370 100644 --- a/src/lib/services/embedWsProxy.ts +++ b/src/lib/services/embedWsProxy.ts @@ -227,7 +227,7 @@ async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buff * * `EMBED_WS_PROXY_HOST` takes precedence, but we fall back to `LIVE_WS_HOST` * so a single env var exposes BOTH WebSocket sockets (the Live dashboard server - * on :20129 and this embed proxy on :20131) in Docker / behind a reverse proxy + * on :20132 and this embed proxy on :20131) in Docker / behind a reverse proxy * or tunnel. Without this fallback the embed proxy stayed bound to 127.0.0.1 * even when the operator set `LIVE_WS_HOST=0.0.0.0`, so the Live view was * permanently "disconnected" in headless deployments (#5110). Defaults to diff --git a/src/server/ws/liveServer.ts b/src/server/ws/liveServer.ts index c3bbccdc89..a4a910a8fb 100644 --- a/src/server/ws/liveServer.ts +++ b/src/server/ws/liveServer.ts @@ -1,7 +1,7 @@ /** * Live Dashboard WebSocket Server * - * Separate process (runs alongside Next.js on port 20129). + * Separate process (runs alongside Next.js on port 20132). * Forwards EventBus events to subscribed dashboard clients. * * Protocol: @@ -36,7 +36,7 @@ import { // ── Config ──────────────────────────────────────────────────────────────── -const DEFAULT_PORT = 20129; +const DEFAULT_PORT = 20132; // Loopback by default. Opt-in to LAN exposure via LIVE_WS_HOST=0.0.0.0 — the // caller is then responsible for fronting it with a TLS terminator + origin // allow-list. Mirrors the route guard "local-only by default" posture. diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index b42c26506a..166338f321 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -306,7 +306,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ key: "OMNIROUTE_ENABLE_LIVE_WS", label: "Live Dashboard WebSocket", description: - "Start the real-time dashboard WebSocket server on import (port 20129, loopback-bound by default). Default: enabled. Set to '0' or 'false' to disable. LAN exposure requires LIVE_WS_HOST=0.0.0.0 + LIVE_WS_ALLOWED_ORIGINS.", + "Start the real-time dashboard WebSocket server on import (port 20132, loopback-bound by default). Default: enabled. Set to '0' or 'false' to disable. LAN exposure requires LIVE_WS_HOST=0.0.0.0 + LIVE_WS_ALLOWED_ORIGINS.", descriptionI18nKey: "featureFlagOmnirouteEnableLiveWsDescription", category: "runtime", defaultValue: "true", diff --git a/src/shared/utils/wsPath.ts b/src/shared/utils/wsPath.ts new file mode 100644 index 0000000000..b1a47d47db --- /dev/null +++ b/src/shared/utils/wsPath.ts @@ -0,0 +1,29 @@ +/** + * Derive the live WebSocket path from `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`. + * + * Only `ws://` or `wss://` URLs are accepted (mirrors the scheme guard in + * `getLivePublicUrl()`). The pathname is extracted and used as the WS upgrade + * path; if the URL has no pathname (or is `/`), falls back to `/live-ws`. + * + * Used by: + * - `src/app/api/v1/ws/route.ts` — handshake response `path` field + * - `src/hooks/useLiveDashboard.ts` — build-time path constant + runtime discovery + * + * No env var is introduced — this reads the existing `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`. + */ +export function deriveLiveWsPath(publicUrl?: string): string { + if (!publicUrl) return "/live-ws"; + if (!publicUrl.startsWith("ws://") && !publicUrl.startsWith("wss://")) return "/live-ws"; + try { + const parsed = new URL(publicUrl); + const pathname = parsed.pathname; + return pathname && pathname !== "/" ? pathname : "/live-ws"; + } catch { + return "/live-ws"; + } +} + +/** Convenience: read the env var at call time and derive the path. */ +export function getLiveWsPath(): string { + return deriveLiveWsPath(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL); +} diff --git a/tests/unit/chatcore-telemetry-helpers.test.ts b/tests/unit/chatcore-telemetry-helpers.test.ts index a155da8f7c..e7bc72debc 100644 --- a/tests/unit/chatcore-telemetry-helpers.test.ts +++ b/tests/unit/chatcore-telemetry-helpers.test.ts @@ -9,9 +9,8 @@ import path from "node:path"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-telemetry-")); process.env.DATA_DIR = TEST_DATA_DIR; -const { forwardDashboardEventToLiveWs, maybeSyncClaudeExtraUsageState } = await import( - "../../open-sse/handlers/chatCore/telemetryHelpers.ts" -); +const { forwardDashboardEventToLiveWs, maybeSyncClaudeExtraUsageState } = + await import("../../open-sse/handlers/chatCore/telemetryHelpers.ts"); const core = await import("../../src/lib/db/core.ts"); const originalFetch = globalThis.fetch; @@ -48,8 +47,8 @@ test("forwardDashboardEventToLiveWs POSTs event+payload+timestamp as JSON to the await forwardDashboardEventToLiveWs("my-event", { foo: "bar" }); const after = Date.now(); - // Default port is 20129 when LIVE_WS_PORT is unset. - assert.equal(capturedUrl, "http://127.0.0.1:20129/__omniroute_event"); + // Default port is 20132 when LIVE_WS_PORT is unset. + assert.equal(capturedUrl, "http://127.0.0.1:20132/__omniroute_event"); assert.equal(capturedInit?.method, "POST"); assert.equal( (capturedInit?.headers as Record)["content-type"], diff --git a/tests/unit/instrumentation-live-ws.test.ts b/tests/unit/instrumentation-live-ws.test.ts new file mode 100644 index 0000000000..ef8cb8462b --- /dev/null +++ b/tests/unit/instrumentation-live-ws.test.ts @@ -0,0 +1,12 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +test("instrumentation-node.ts imports liveServer for in-process WS auto-start", () => { + const source = readFileSync(resolve("src/instrumentation-node.ts"), "utf8"); + assert.ok( + source.includes("server/ws/liveServer"), + "instrumentation-node.ts should import @/server/ws/liveServer" + ); +}); diff --git a/tests/unit/live-ws-public-url.test.ts b/tests/unit/live-ws-public-url.test.ts index 3595e43370..deff8cb926 100644 --- a/tests/unit/live-ws-public-url.test.ts +++ b/tests/unit/live-ws-public-url.test.ts @@ -68,7 +68,7 @@ test("handshake response includes publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL ); assert.equal(response.status, 200); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.live.publicUrl, "wss://ws.my-ai.com/live-ws"); }); @@ -82,7 +82,7 @@ test("handshake response includes null publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC ); assert.equal(response.status, 200); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.live.publicUrl, null); }); @@ -92,7 +92,7 @@ test("protocol.live.publicUrl reflects env set after module import (lazy read)", const response = await wsRoute.GET(new Request("http://localhost/api/v1/ws")); assert.equal(response.status, 426); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.protocol.live.publicUrl, "wss://custom.example.com/ws"); }); @@ -106,13 +106,13 @@ test("publicUrl with non-WebSocket scheme is rejected (null)", async () => { ); assert.equal(response.status, 200); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.live.publicUrl, null); assert.equal(body.protocol.live.publicUrl, null); }); test("publicUrl with ws:// scheme is accepted", async () => { - process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "ws://lan-host:20129/live-ws"; + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "ws://lan-host:20132/live-ws"; const response = await wsRoute.GET( new Request("http://localhost/api/v1/ws?handshake=1", { @@ -121,6 +121,34 @@ test("publicUrl with ws:// scheme is accepted", async () => { ); assert.equal(response.status, 200); - const body = (await response.json()) as any; - assert.equal(body.live.publicUrl, "ws://lan-host:20129/live-ws"); + const body = await response.json(); + assert.equal(body.live.publicUrl, "ws://lan-host:20132/live-ws"); +}); + +test("handshake path is derived from NEXT_PUBLIC_LIVE_WS_PUBLIC_URL pathname", async () => { + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "wss://ws.my-ai.com/my-custom-ws"; + + const response = await wsRoute.GET( + new Request("http://localhost/api/v1/ws?handshake=1", { + headers: { origin: "http://localhost" }, + }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.live.path, "/my-custom-ws"); +}); + +test("handshake path defaults to /live-ws when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL is unset", async () => { + delete process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + + const response = await wsRoute.GET( + new Request("http://localhost/api/v1/ws?handshake=1", { + headers: { origin: "http://localhost" }, + }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.live.path, "/live-ws"); }); diff --git a/tests/unit/livews-forward-backoff-4604.test.ts b/tests/unit/livews-forward-backoff-4604.test.ts index d7fe6a2026..9b2e100585 100644 --- a/tests/unit/livews-forward-backoff-4604.test.ts +++ b/tests/unit/livews-forward-backoff-4604.test.ts @@ -6,7 +6,7 @@ import { __resetLiveWsForwardingState, } from "../../open-sse/handlers/chatCore/telemetryHelpers.ts"; -// #4604 — In single-port Docker deployments the live-WS sidecar (port 20129) is +// #4604 — In single-port Docker deployments the live-WS sidecar (port 20132) is // not running, but forwardDashboardEventToLiveWs POSTed to it on every compression // event. Because the global fetch is proxyFetch, each ECONNREFUSED logged a // "[ProxyFetch] Undici dispatcher failed" warning — 272 times in 42 minutes. The @@ -35,7 +35,7 @@ test("backs off after consecutive failures and stops calling fetch", async () => let calls = 0; const fail = async () => { calls++; - throw new Error("connect ECONNREFUSED 127.0.0.1:20129"); + throw new Error("connect ECONNREFUSED 127.0.0.1:20132"); }; const clock = makeClock(); // First N attempts go through (and fail); after the threshold the forwarder