mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 08:32:11 +03:00
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!
This commit is contained in:
1
changelog.d/fixes/11388-live-ws-handshake-port.md
Normal file
1
changelog.d/fixes/11388-live-ws-handshake-port.md
Normal file
@@ -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)).
|
||||
@@ -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<string | null>(null);
|
||||
const [handshakePath, setHandshakePath] = useState<string | null>(null);
|
||||
const [handshakePort, setHandshakePort] = useState<number | null>(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<WsEventPayload[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
97
tests/unit/live-ws-url-11331.test.ts
Normal file
97
tests/unit/live-ws-url-11331.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user