From 1874bbe8da63358097f44ed77239961979c2d0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sun, 18 Oct 2026 22:02:02 +0000 Subject: [PATCH] Record why the camera player cannot reach a gateway directly Measured against go2rtc 1.9.14: it sends no Access-Control-Allow-Origin on any response, auth or not; the SDP POST needs a preflight anyway because application/sdp is not a CORS-safelisted Content-Type; and go2rtc checks basic auth before CORS, so the preflight - which browsers always send without credentials - gets a 401. So this component has never worked from a browser on another origin, and no credential handed to the frontend could fix it. The gateway's API now requires a password, which closes the hole where anything on the workshop LAN could watch, and changes the symptom here from a CORS error to a 401. Both are the same missing piece: the relay. Also stops the two most misleading errors in this path. A blocked cross-origin request rejects as an opaque TypeError ("Failed to fetch") that reads as the gateway being down, and a 401 reads as a wrong URL or a missing camera. Both now say what they are. --- .../ui/src/components/WebRTCStreamView.vue | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/WebRTCStreamView.vue b/packages/ui/src/components/WebRTCStreamView.vue index ee6e502..5cc46cb 100644 --- a/packages/ui/src/components/WebRTCStreamView.vue +++ b/packages/ui/src/components/WebRTCStreamView.vue @@ -16,9 +16,28 @@ const error = ref(null); * * Note what is deliberately absent: no `iceServers`. Both ends are on the * same LAN - the gateway is a box in the workshop - so host candidates are - * enough and a STUN round-trip would only add latency. The same fact is why - * this component cannot work from outside that network at all; there is no - * relay, and the backend's /printers/:id/stream says so too. + * enough and a STUN round-trip would only add latency. + * + * ## This cannot work from a browser on another origin, and that is measured + * + * Not a caveat about reachability - a flat blocker, against go2rtc 1.9.14: + * + * - go2rtc sends **no** `Access-Control-Allow-Origin` on any response, with + * or without auth configured. A cross-origin response is unreadable. + * - The `POST` below needs a preflight regardless, because `application/sdp` + * is not a CORS-safelisted `Content-Type`. + * - go2rtc checks basic auth *before* CORS, so the preflight - which a + * browser always sends without credentials - gets a 401. Sending the + * gateway's password from here could not fix it even if the password were + * worth handing to every viewer. + * + * So this component works when it is same-origin with go2rtc, or through a + * relay that proxies server-to-server. The gateway's API now requires a + * password (continuum-proxy's camera.rs), which closes the hole where + * anything on the workshop LAN could watch - and which also means a browser + * pointed straight at a gateway now gets a 401 rather than a CORS error. + * Both are the same missing piece. The relay design is in PLAN.md; note it + * cannot carry WebRTC, so whatever lands there will replace this transport. */ async function connect() { status.value = "connecting"; @@ -53,6 +72,15 @@ async function connect() { // whoever is debugging it looking at the wrong layer, when the real // answer is that this printer is not in go2rtc's config. if (!response.ok) { + // 401 has one meaning and it is worth naming: go2rtc's API is + // password-protected, and a browser cannot send that password across + // origins (see above). It is not a wrong URL or a missing camera. + if (response.status === 401) { + throw new Error( + "the gateway requires a password for camera access, which a browser cannot " + + "supply across origins - this needs the relay", + ); + } throw new Error(`the gateway refused the stream request (HTTP ${response.status})`); } @@ -76,7 +104,18 @@ watch( () => { connect().catch((err: unknown) => { status.value = "failed"; - error.value = err instanceof Error ? err.message : String(err); + // A blocked cross-origin request rejects as an opaque TypeError + // ("Failed to fetch") with nothing about CORS in it, which is the + // single most misleading error in this path - it reads as the gateway + // being down. Say the likely cause instead of repeating the browser. + error.value = + err instanceof TypeError + ? "could not reach the gateway's camera API from this origin - either it is " + + "unreachable from here, or the browser blocked the request (see this " + + "component's notes)" + : err instanceof Error + ? err.message + : String(err); }); }, { immediate: true },