Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/live-ws-public-url-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(live-ws):** the Live dashboard socket can now be pointed at a reverse proxy without rebuilding the image. `NEXT_PUBLIC_*` is inlined at BUILD time, so a prebuilt Docker or npm image never carries an operator's `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` — which is exactly why the browser discovers the socket through `/api/v1/ws?handshake=1` instead. The server side of that handshake, however, read only the `NEXT_PUBLIC_`-prefixed name, so it had nothing to echo: behind Traefik the dashboard kept dialling `wss://<host>:20132/live-ws` and sat on "Live disabled — WebSocket disconnected. Showing last known state." `LIVE_WS_PUBLIC_URL` is now read at runtime alongside the existing `LIVE_WS_HOST` / `LIVE_WS_PORT`, and the prefixed name stays supported as the fallback, so deployments that already set it are unaffected. Only `ws://` and `wss://` values are accepted, matching the guard the client already applies. (#11331)
8 changes: 4 additions & 4 deletions src/app/api/v1/ws/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CORS_HEADERS } from "@/shared/utils/cors";
import { getLiveWsPath } from "@/shared/utils/wsPath";
import { getLiveWsPath, resolveLiveWsPublicUrl } from "@/shared/utils/wsPath";
import { authorizeWebSocketHandshake } from "@/lib/ws/handshake";

const WS_HANDSHAKE_HEADERS = {
Expand All @@ -13,9 +13,9 @@ const WS_HANDSHAKE_HEADERS = {
* env changes are honored, and only echoed when it is a ws:// or wss:// URL.
*/
function getLivePublicUrl(): string | null {
const publicUrl = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL;
if (!publicUrl) return null;
return publicUrl.startsWith("ws://") || publicUrl.startsWith("wss://") ? publicUrl : null;
// Runtime-resolved: a prebuilt image never carries a build-time NEXT_PUBLIC_*
// value, and this handshake is what the browser reads instead (#11331).
return resolveLiveWsPublicUrl();
}

function getWsProtocol() {
Expand Down
28 changes: 27 additions & 1 deletion src/shared/utils/wsPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,33 @@ export function deriveLiveWsPath(publicUrl?: string): string {
}
}

/**
* The operator-declared public WebSocket URL, resolved at RUNTIME.
*
* `NEXT_PUBLIC_*` is inlined into the client bundle at BUILD time, so a prebuilt
* Docker or npm image can never carry an operator's value — which is exactly why
* the server echoes this in `/api/v1/ws?handshake=1` for the client to discover.
* Reading only the `NEXT_PUBLIC_`-prefixed name on the server made that echo
* unreachable too: behind a reverse proxy the dashboard kept dialling
* `wss://<host>:20132/live-ws` and reported "Live disabled" (#11331).
*
* `LIVE_WS_PUBLIC_URL` is the runtime name, alongside the existing runtime
* `LIVE_WS_HOST` / `LIVE_WS_PORT`. The prefixed name still wins nothing and loses
* nothing — it stays supported as the fallback so existing deployments that set it
* (build-time or in the container) keep working.
*/
export function resolveLiveWsPublicUrl(env: NodeJS.ProcessEnv = process.env): string | null {
const candidates = [env.LIVE_WS_PUBLIC_URL, env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL];
for (const candidate of candidates) {
if (typeof candidate !== "string") continue;
const trimmed = candidate.trim();
if (!trimmed) continue;
if (trimmed.startsWith("ws://") || trimmed.startsWith("wss://")) return trimmed;
}
return null;
}

/** 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);
return deriveLiveWsPath(resolveLiveWsPublicUrl() ?? undefined);
}
80 changes: 80 additions & 0 deletions tests/unit/live-ws-public-url-11331.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import { deriveLiveWsPath, resolveLiveWsPublicUrl } from "../../src/shared/utils/wsPath.ts";

// #11331 — behind a reverse proxy the Combo Studio dashboard kept dialling
// `wss://<host>:20132/live-ws` and reported "Live disabled — WebSocket
// disconnected", ignoring the container's environment.
//
// The runtime-discovery path already existed: the browser reads
// `/api/v1/ws?handshake=1` precisely because `NEXT_PUBLIC_*` is inlined at BUILD
// time and a prebuilt Docker/npm image can never carry an operator's value. But
// the server side of that handshake read only the `NEXT_PUBLIC_`-prefixed name,
// so the echo had nothing to echo and the client fell back to the hardcoded port.

test("#11331 the runtime name is honoured", () => {
assert.equal(
resolveLiveWsPublicUrl({ LIVE_WS_PUBLIC_URL: "wss://omniroute.example.tld/live-ws" }),
"wss://omniroute.example.tld/live-ws"
);
});

test("#11331 the build-time name still works, and the runtime name wins", () => {
assert.equal(
resolveLiveWsPublicUrl({ NEXT_PUBLIC_LIVE_WS_PUBLIC_URL: "ws://built-in:20132/live-ws" }),
"ws://built-in:20132/live-ws"
);
assert.equal(
resolveLiveWsPublicUrl({
LIVE_WS_PUBLIC_URL: "wss://proxy.example.tld/live-ws",
NEXT_PUBLIC_LIVE_WS_PUBLIC_URL: "ws://built-in:20132/live-ws",
}),
"wss://proxy.example.tld/live-ws"
);
});

test("#11331 only ws:// and wss:// are accepted", () => {
assert.equal(resolveLiveWsPublicUrl({ LIVE_WS_PUBLIC_URL: "https://proxy.example.tld" }), null);
assert.equal(resolveLiveWsPublicUrl({ LIVE_WS_PUBLIC_URL: "javascript:alert(1)" }), null);
assert.equal(resolveLiveWsPublicUrl({ LIVE_WS_PUBLIC_URL: "proxy.example.tld:443" }), null);
});

test("#11331 blank and missing values fall through", () => {
assert.equal(resolveLiveWsPublicUrl({}), null);
assert.equal(resolveLiveWsPublicUrl({ LIVE_WS_PUBLIC_URL: "" }), null);
assert.equal(resolveLiveWsPublicUrl({ LIVE_WS_PUBLIC_URL: " " }), null);
assert.equal(
resolveLiveWsPublicUrl({
LIVE_WS_PUBLIC_URL: " ",
NEXT_PUBLIC_LIVE_WS_PUBLIC_URL: "wss://b/live-ws",
}),
"wss://b/live-ws"
);
});

test("#11331 a surrounding-whitespace value is trimmed, not rejected", () => {
assert.equal(
resolveLiveWsPublicUrl({ LIVE_WS_PUBLIC_URL: " wss://proxy.example.tld/live-ws " }),
"wss://proxy.example.tld/live-ws"
);
});

test("#11331 the path follows the resolved URL", () => {
assert.equal(deriveLiveWsPath("wss://proxy.example.tld/omniroute/live"), "/omniroute/live");
assert.equal(deriveLiveWsPath("wss://proxy.example.tld"), "/live-ws");
assert.equal(deriveLiveWsPath(undefined), "/live-ws");
});

test("#11331 the handshake route resolves the URL at runtime", () => {
const src = fs.readFileSync(new URL("../../src/app/api/v1/ws/route.ts", import.meta.url), "utf8");
assert.ok(
src.includes("resolveLiveWsPublicUrl()"),
"the handshake must resolve the public URL at runtime"
);
assert.equal(
/process\.env\.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL/.test(src),
false,
"the route must not read the build-time-only name directly"
);
});
Loading