From 45310f202d3cd3bcf216cd54d4b887f578a60d05 Mon Sep 17 00:00:00 2001 From: Septianata Rizky Pratama <19322988+ianriizky@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:01:27 +0700 Subject: [PATCH] fix: auto-start WS server in-process and change default port to 20132 (#6072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: change default LIVE_WS_PORT from 20129 to 20132 Update the default WebSocket port for the live dashboard server from 20129 to 20132 across all configuration files, documentation, code comments, and tests. Also consolidate OMNIROUTE_DISABLE_LIVE_WS and OMNIROUTE_ENABLE_LIVE_WS into a single OMNIROUTE_ENABLE_LIVE_WS flag. Wire the live WebSocket server to start in-process via instrumentation-node.ts. * feat: clarify NEXT_PUBLIC_LIVE_WS_PUBLIC_URL path usage and derive upgrade path from URL Update .env.example and ENVIRONMENT.md to document that the pathname portion of NEXT_PUBLIC_LIVE_WS_PUBLIC_URL (e.g. /live-ws) is used as the WebSocket upgrade path by the dev proxy, handshake response, and client connection logic. Extract deriveLiveWsPath() into shared/utils/wsPath.ts and wire it through: - src/app/api/v1/ws/route.ts — handshake response path field - src/hooks/useLiveDashboard.ts — build * fix: use the standard URL API to safely parse and update the effectiveWsUrl * build(docker): expose live WebSocket server port and configure CORS origins Add LIVE_WS_PORT (20132), LIVE_WS_HOST (0.0.0.0), and LIVE_WS_ALLOWED_ORIGINS environment variables to all Docker Compose profiles and expose the WebSocket port mapping. Prevent infinite self-loop in standalone-server-ws.mjs by skipping proxy when the server itself is running on the LiveWS port. * docs(env): fix comment formatting for HOST and HOSTNAME variables --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .env.example | 26 +++++---- config/quality/eslint-suppressions.json | 5 -- docker-compose.prod.yml | 4 ++ docker-compose.yml | 10 ++++ docs/reference/ENVIRONMENT.md | 53 +++++++++---------- .../handlers/chatCore/telemetryHelpers.ts | 4 +- scripts/dev/standalone-server-ws.mjs | 31 +++++++++-- scripts/start-ws-server.mjs | 12 ++--- src/app/api/v1/ws/route.ts | 9 ++-- src/app/docs/lib/openapi.generated.ts | 6 ++- src/hooks/useLiveDashboard.ts | 31 ++++++++--- src/instrumentation-node.ts | 7 ++- src/lib/services/embedWsProxy.ts | 2 +- src/server/ws/liveServer.ts | 4 +- .../constants/featureFlagDefinitions.ts | 2 +- src/shared/utils/wsPath.ts | 29 ++++++++++ tests/unit/chatcore-telemetry-helpers.test.ts | 9 ++-- tests/unit/instrumentation-live-ws.test.ts | 12 +++++ tests/unit/live-ws-public-url.test.ts | 42 ++++++++++++--- .../unit/livews-forward-backoff-4604.test.ts | 4 +- 20 files changed, 209 insertions(+), 93 deletions(-) create mode 100644 src/shared/utils/wsPath.ts create mode 100644 tests/unit/instrumentation-live-ws.test.ts 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