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.
This commit is contained in:
2026-10-18 22:02:02 +00:00
parent 35f39ae502
commit 1874bbe8da
@@ -16,9 +16,28 @@ const error = ref<string | null>(null);
* *
* Note what is deliberately absent: no `iceServers`. Both ends are on the * 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 * 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 * enough and a STUN round-trip would only add latency.
* this component cannot work from outside that network at all; there is no *
* relay, and the backend's /printers/:id/stream says so too. * ## 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() { async function connect() {
status.value = "connecting"; status.value = "connecting";
@@ -53,6 +72,15 @@ async function connect() {
// whoever is debugging it looking at the wrong layer, when the real // whoever is debugging it looking at the wrong layer, when the real
// answer is that this printer is not in go2rtc's config. // answer is that this printer is not in go2rtc's config.
if (!response.ok) { 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})`); throw new Error(`the gateway refused the stream request (HTTP ${response.status})`);
} }
@@ -76,7 +104,18 @@ watch(
() => { () => {
connect().catch((err: unknown) => { connect().catch((err: unknown) => {
status.value = "failed"; 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 }, { immediate: true },