From f93fecd86bc85a2e565c211aeb36ba9303d30eaa Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Tue, 25 Aug 2026 03:23:38 +0700 Subject: [PATCH] fix(dashboard): honour the live WebSocket port the handshake reports (#11331) (#11388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote combinado (batch-0824g) contra o tip de release/v3.8.50: typecheck:core limpo, gates estáticos OK, 62/62 testes focados passando (incluindo tests/unit/live-ws-url-11331.test.ts, 11 casos + mutation-check). Resolve o incidente real do #11331: o handshake já reportava a porta live real, mas o cliente descartava esse campo e ficava preso na porta compilada no bundle. Precedência clara (wsUrl explícito > publicUrl completo > porta/path do handshake aplicados ao default). Obrigado pela contribuição! --- .../fixes/11388-live-ws-handshake-port.md | 1 + src/hooks/useLiveDashboard.ts | 41 ++++---- src/shared/utils/wsPath.ts | 58 +++++++++++ tests/unit/live-ws-url-11331.test.ts | 97 +++++++++++++++++++ 4 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 changelog.d/fixes/11388-live-ws-handshake-port.md create mode 100644 tests/unit/live-ws-url-11331.test.ts diff --git a/changelog.d/fixes/11388-live-ws-handshake-port.md b/changelog.d/fixes/11388-live-ws-handshake-port.md new file mode 100644 index 0000000000..f00f428b01 --- /dev/null +++ b/changelog.d/fixes/11388-live-ws-handshake-port.md @@ -0,0 +1 @@ +- **Live dashboard:** honour the WebSocket port reported by `/api/v1/ws?handshake=1` instead of the port compiled into the bundle, so a `LIVE_WS_PORT` override reaches prebuilt Docker/npm images and Combo Studio Live connects behind a reverse proxy ([#11331](https://github.com/diegosouzapw/OmniRoute/issues/11331)). diff --git a/src/hooks/useLiveDashboard.ts b/src/hooks/useLiveDashboard.ts index d257605237..8f9b23575b 100644 --- a/src/hooks/useLiveDashboard.ts +++ b/src/hooks/useLiveDashboard.ts @@ -13,7 +13,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import type { DashboardChannel, DashboardEventName } from "@/lib/events/types"; -import { deriveLiveWsPath } from "@/shared/utils/wsPath"; +import { deriveLiveWsPath, resolveLiveWsUrl, sanitizeLiveWsPort } from "@/shared/utils/wsPath"; // ── Config ──────────────────────────────────────────────────────────────── @@ -40,14 +40,10 @@ function getDefaultWsUrl(): string { 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 (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}:20132${BUILD_TIME_WS_PATH}`; - } + // The WS server's own port, for loopback and non-loopback alike: the HTTP + // port has no upgrade handler in src/proxy.ts. This is only the starting + // point - the handshake below replaces the port when the server reports a + // different one, and a caller can always pass `wsUrl` outright. return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`; } @@ -113,6 +109,7 @@ export function useLiveDashboard({ const needsHandshake = !wsUrl && !BUILD_TIME_PUBLIC_WS_URL && typeof window !== "undefined"; const [handshakeUrl, setHandshakeUrl] = useState(null); const [handshakePath, setHandshakePath] = useState(null); + const [handshakePort, setHandshakePort] = useState(null); const [wsUrlResolved, setWsUrlResolved] = useState(!needsHandshake); useEffect(() => { @@ -127,6 +124,11 @@ export function useLiveDashboard({ if (typeof body?.live?.path === "string" && body.live.path.startsWith("/")) { setHandshakePath(body.live.path); } + // The live server reports the port it is actually listening on, so a + // LIVE_WS_PORT override reaches a prebuilt image instead of being + // overruled by the compiled-in default (#11331). + const port = sanitizeLiveWsPort(body?.live?.port); + if (port !== null) setHandshakePort(port); }) .catch(() => { // Handshake unavailable — fall back to the default URL. @@ -139,20 +141,13 @@ export function useLiveDashboard({ }; }, [needsHandshake, wsUrlResolved]); - 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 effectiveWsUrl = resolveLiveWsUrl({ + explicit: wsUrl, + handshakeUrl, + handshakePort, + handshakePath: handshakePath !== BUILD_TIME_WS_PATH ? handshakePath : null, + defaultUrl: DEFAULT_WS_URL, + }); const [events, setEvents] = useState([]); const wsRef = useRef(null); diff --git a/src/shared/utils/wsPath.ts b/src/shared/utils/wsPath.ts index 84cd08f445..dfbfba5bc6 100644 --- a/src/shared/utils/wsPath.ts +++ b/src/shared/utils/wsPath.ts @@ -53,3 +53,61 @@ export function resolveLiveWsPublicUrl(env: NodeJS.ProcessEnv = process.env): st export function getLiveWsPath(): string { return deriveLiveWsPath(resolveLiveWsPublicUrl() ?? undefined); } + +/** A port the handshake may report, or null when it is not usable. */ +export function sanitizeLiveWsPort(port: unknown): number | null { + const value = typeof port === "string" ? Number(port) : port; + if (typeof value !== "number" || !Number.isInteger(value)) return null; + return value > 0 && value < 65536 ? value : null; +} + +export interface LiveWsUrlParts { + /** Explicit `wsUrl` passed by the caller - always wins. */ + explicit?: string | null; + /** `live.publicUrl` from the handshake - a complete URL, used as-is. */ + handshakeUrl?: string | null; + /** `live.port` from the handshake, i.e. the running LIVE_WS_PORT. */ + handshakePort?: number | null; + /** `live.path` from the handshake. */ + handshakePath?: string | null; + /** The compiled-in default, used for everything the handshake does not say. */ + defaultUrl: string; +} + +/** + * Resolve the live dashboard WebSocket URL. + * + * The handshake reports the port the live server is actually listening on, but + * the client read only `publicUrl` and `path` from it. An operator who moved + * the server with `LIVE_WS_PORT` still got the compiled-in 20132, and the + * dashboard sat on "Live disabled - WebSocket disconnected" with no way to + * correct it short of rebuilding the image (#11331). + * + * Precedence: an explicit `wsUrl` wins, then a complete `publicUrl` from the + * handshake, then the default URL with whatever port and path the handshake + * reported applied to it. + */ +export function resolveLiveWsUrl({ + explicit, + handshakeUrl, + handshakePort, + handshakePath, + defaultUrl, +}: LiveWsUrlParts): string { + if (explicit) return explicit; + if (handshakeUrl) return handshakeUrl; + + const port = sanitizeLiveWsPort(handshakePort); + const path = + typeof handshakePath === "string" && handshakePath.startsWith("/") ? handshakePath : null; + if (port === null && path === null) return defaultUrl; + + try { + const url = new URL(defaultUrl); + if (port !== null) url.port = String(port); + if (path !== null) url.pathname = path; + return url.toString(); + } catch { + return defaultUrl; + } +} diff --git a/tests/unit/live-ws-url-11331.test.ts b/tests/unit/live-ws-url-11331.test.ts new file mode 100644 index 0000000000..83fc56a216 --- /dev/null +++ b/tests/unit/live-ws-url-11331.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + deriveLiveWsPath, + resolveLiveWsUrl, + sanitizeLiveWsPort, +} from "../../src/shared/utils/wsPath.ts"; + +/** + * The /api/v1/ws?handshake=1 response reports `live.port` — the port the live + * server is actually listening on — but the dashboard client read only + * `publicUrl` and `path`. An operator who moved the server with LIVE_WS_PORT + * still got the compiled-in 20132 and a permanently disconnected Combo Studio + * (#11331). + */ +const DEFAULT_URL = "wss://omniroute.example.tld:20132/live-ws"; + +describe("sanitizeLiveWsPort", () => { + it("accepts a port in range, as a number or a string", () => { + assert.equal(sanitizeLiveWsPort(20140), 20140); + assert.equal(sanitizeLiveWsPort("20140"), 20140); + }); + + it("rejects anything that is not a usable port", () => { + for (const value of [0, -1, 65536, 1.5, "", "abc", null, undefined, {}, NaN]) { + assert.equal(sanitizeLiveWsPort(value), null, `expected null for ${String(value)}`); + } + }); +}); + +describe("resolveLiveWsUrl", () => { + it("uses the port the handshake reports instead of the compiled-in one", () => { + const url = resolveLiveWsUrl({ handshakePort: 20140, defaultUrl: DEFAULT_URL }); + assert.equal(new URL(url).port, "20140"); + assert.equal(new URL(url).hostname, "omniroute.example.tld"); + assert.equal(new URL(url).pathname, "/live-ws"); + }); + + it("keeps the default when the handshake reports nothing", () => { + assert.equal(resolveLiveWsUrl({ defaultUrl: DEFAULT_URL }), DEFAULT_URL); + }); + + it("ignores a port the handshake cannot mean", () => { + assert.equal(resolveLiveWsUrl({ handshakePort: 0, defaultUrl: DEFAULT_URL }), DEFAULT_URL); + assert.equal( + resolveLiveWsUrl({ handshakePort: 70000 as number, defaultUrl: DEFAULT_URL }), + DEFAULT_URL + ); + }); + + it("applies the port and the path together", () => { + const url = new URL( + resolveLiveWsUrl({ handshakePort: 9443, handshakePath: "/ws/live", defaultUrl: DEFAULT_URL }) + ); + assert.equal(url.port, "9443"); + assert.equal(url.pathname, "/ws/live"); + }); + + it("ignores a path that is not a path", () => { + const url = new URL(resolveLiveWsUrl({ handshakePath: "live-ws", defaultUrl: DEFAULT_URL })); + assert.equal(url.pathname, "/live-ws"); + }); + + it("lets a complete publicUrl win over the reported port", () => { + assert.equal( + resolveLiveWsUrl({ + handshakeUrl: "wss://omniroute.example.tld/live-ws", + handshakePort: 20140, + defaultUrl: DEFAULT_URL, + }), + "wss://omniroute.example.tld/live-ws" + ); + }); + + it("lets an explicit wsUrl win over everything", () => { + assert.equal( + resolveLiveWsUrl({ + explicit: "wss://elsewhere.example/socket", + handshakeUrl: "wss://omniroute.example.tld/live-ws", + handshakePort: 20140, + defaultUrl: DEFAULT_URL, + }), + "wss://elsewhere.example/socket" + ); + }); + + it("falls back to the default rather than throwing on an unparseable default", () => { + assert.equal(resolveLiveWsUrl({ handshakePort: 20140, defaultUrl: "not a url" }), "not a url"); + }); + + it("leaves deriveLiveWsPath alone", () => { + assert.equal(deriveLiveWsPath("wss://host:20132/ws/live"), "/ws/live"); + assert.equal(deriveLiveWsPath("wss://host:20132/"), "/live-ws"); + assert.equal(deriveLiveWsPath(undefined), "/live-ws"); + }); +});