Relay camera signalling and mint TURN credentials

Makes the printer cameras playable from a hosted console, which they
were not: GET /printers/:id/stream handed out the gateway's own LAN
address, and a browser cannot use it. go2rtc sends no CORS headers and
checks its API password before CORS, so a browser's unauthenticated
preflight gets a 401 (measured against 1.9.14), and a gateway is behind
a workshop NAT anyway. That endpoint could only ever have worked for an
operator standing on the same network, and offered everyone else a
player that silently never loaded.

So /:id/stream now returns a camera name and short-lived ICE servers,
and POST /:id/webrtc relays the offer to the gateway over the socket it
already holds open and returns go2rtc's answer. Media travels
browser-to-gateway through the TURN relay, DTLS-SRTP encrypted, so
neither this process nor Cloudflare can read it - which is why this is
signalling rather than proxying fMP4 through the API. That alternative
also needed a credential in the URL, because <video> cannot send an
Authorization header.

Only the browser gets TURN credentials. It is pinned to relay-only, so
it allocates the relay and go2rtc sends to it; one side is enough. That
keeps rotation on a per-viewer request instead of in go2rtc's config,
which is read once at startup - rotating there would restart it and drop
every viewer on a timer.

Unconfigured TURN is supported rather than fatal, unlike R2: a viewer on
the gateway's LAN connects on host candidates. turnConfigured is
returned alongside, because empty iceServers means "not set up" in one
case and "minting failed" in the other, and those need different
answers.

Camera negotiations are failed immediately when a gateway disconnects,
rather than waiting out the 12s timeout - a browser is holding a request
open on each one.

getStreamBaseUrl is kept but no longer served to anyone: gateways still
report the field and it is the only record of where one thinks it is
reachable, which is useful when diagnosing one.

e2e-edge.sh asserts the new shape, that no streamUrl comes back, and
drives a real offer through the relay - asserting the refusal came from
go2rtc rather than from a timeout, since that is what proves the round
trip happened.
This commit is contained in:
2026-10-19 00:33:18 +00:00
parent 0d87c8cbce
commit f02c9897c9
7 changed files with 411 additions and 40 deletions
+19
View File
@@ -25,6 +25,25 @@ R2_ENDPOINT=
R2_ACCESS_KEY_ID= R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY= R2_SECRET_ACCESS_KEY=
# Cloudflare Realtime TURN, for relaying printer camera video.
#
# Optional, and unset is a supported state rather than a broken one: with no
# TURN the console can still play a camera for a viewer on the gateway's own
# network, over host candidates. Remote viewers need this.
#
# Why a relay at all: a browser cannot reach a gateway directly. go2rtc sends
# no CORS headers and checks its API password before CORS, so a browser's
# unauthenticated preflight gets a 401 - and a gateway is behind a workshop
# NAT besides. Only the SDP exchange is relayed by this backend; the media
# goes browser-to-gateway through TURN, DTLS-SRTP encrypted, so neither this
# process nor Cloudflare can read it.
#
# The browser is pinned to relay-only when these are set, so video never goes
# peer-to-peer and behaves the same on every customer network. Credentials
# are minted per request with a 1 hour TTL (src/camera/ice.ts).
# CLOUDFLARE_TURN_KEY_ID=
# CLOUDFLARE_TURN_API_TOKEN=
# Supabase Auth. Access tokens are verified against this project's published # Supabase Auth. Access tokens are verified against this project's published
# JWKS (RS256), so no signing secret is needed - or wanted - here. # JWKS (RS256), so no signing secret is needed - or wanted - here.
SUPABASE_URL= SUPABASE_URL=
+30 -4
View File
@@ -325,11 +325,37 @@ else
|| fail "GET /printers/$CAM_UUID/stream failed for a printer that has a camera" || fail "GET /printers/$CAM_UUID/stream failed for a printer that has a camera"
echo "$STREAM" | grep -q "\"cameraId\":\"$WITH_CAMERA\"" \ echo "$STREAM" | grep -q "\"cameraId\":\"$WITH_CAMERA\"" \
|| fail "wrong cameraId: $STREAM" || fail "wrong cameraId: $STREAM"
# The farm's stream base URL has to survive the hello and come back out of # Deliberately NOT a URL any more. A browser cannot play from a gateway's
# an in-memory connection, which is the part with no unit test behind it. # own address - go2rtc sends no CORS headers and 401s an unauthenticated
echo "$STREAM" | grep -q "\"streamUrl\":\"$STREAM_BASE_URL\"" \ # preflight - so serving one offered a player that could never load. If
|| fail "streamUrl did not come back from the gateway's hello: $STREAM" # this reappears, something has regressed to the pre-relay design.
echo "$STREAM" | grep -q '"streamUrl"' \
&& fail "the stream endpoint is serving a gateway URL again: $STREAM"
# No Cloudflare credentials in this test, so TURN is off and iceServers is
# empty. Both are asserted, because an empty list with turnConfigured true
# would mean minting silently failed.
echo "$STREAM" | grep -q '"turnConfigured":false' \
|| fail "expected TURN to be unconfigured in the test env: $STREAM"
echo "$STREAM" | grep -q '"iceServers":\[\]' \
|| fail "expected no ICE servers when TURN is unconfigured: $STREAM"
ok "GET .../stream: $STREAM" ok "GET .../stream: $STREAM"
# The signalling relay, end to end through the real gateway: an offer goes
# cloud -> uplink -> gateway -> its own go2rtc. go2rtc is not running in
# this test (see CONTINUUM_GO2RTC_BIN above), so the honest expectation is
# a 503 whose reason came back *from the gateway* rather than from a
# timeout - that is what proves the round trip happened at all.
OFFER_CODE="$(curl -s -o "$WORK/offer.json" -w '%{http_code}' -X POST \
-H "$AUTH_HEADER" -H 'Content-Type: application/json' \
-d '{"sdp":"v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n"}' \
"$BASE/printers/$CAM_UUID/webrtc")"
if [ "$OFFER_CODE" = "503" ] && grep -q 'go2rtc\|connection refused\|refused the offer' "$WORK/offer.json"; then
ok "the WebRTC offer reached the gateway and its go2rtc answered (as a refusal): $(head -c 160 "$WORK/offer.json")"
elif [ "$OFFER_CODE" = "503" ] && grep -q 'did not answer within' "$WORK/offer.json"; then
fail "the gateway never answered the offer - the relay did not round-trip: $(cat "$WORK/offer.json")"
else
fail "unexpected answer to a WebRTC offer (HTTP $OFFER_CODE): $(cat "$WORK/offer.json")"
fi
fi fi
# A printer with no camera must 404, and say which half is missing. This is # A printer with no camera must 404, and say which half is missing. This is
+106
View File
@@ -0,0 +1,106 @@
/**
* ICE servers for a browser about to watch a printer camera.
*
* ## Why the browser needs these at all
*
* A viewer's browser cannot reach a gateway directly. The gateway is behind a
* workshop NAT, and go2rtc will not talk to a browser cross-origin anyway -
* measured against 1.9.14, it sends no CORS headers and checks its API
* password before CORS, so a browser's unauthenticated preflight gets a 401.
* The SDP exchange is therefore relayed through this backend, and the media
* itself goes over a TURN relay.
*
* ## Why only the browser gets credentials
*
* The browser is pinned to `iceTransportPolicy: "relay"`, so it gathers a
* relay candidate and go2rtc simply sends to it from its own candidate. Only
* one side of a WebRTC session has to hold a relay for media to traverse one.
*
* That asymmetry is load-bearing rather than tidy: go2rtc reads its config
* exactly once at startup, so TURN credentials on the gateway would have to
* be rotated by restarting go2rtc and dropping every viewer on a timer.
* Browser-side, rotation lands on a request that is already per-viewer.
*
* ## What this buys over proxying the video
*
* WebRTC media is DTLS-SRTP encrypted between the browser and the gateway, so
* the relay forwards packets it cannot decrypt. The alternative considered -
* tunnelling fMP4 up the gateway's uplink and streaming it from this process -
* would have put plaintext video through the API, and `<video>` cannot send
* an `Authorization` header, so it would have needed a credential in the URL.
* That is the pattern `/ws/telemetry` deliberately rejected.
*
* ## Unconfigured is a supported state
*
* No Cloudflare credentials means no TURN, and that is not a boot failure the
* way R2 is: a farm with no cameras, or one whose operator is on the same LAN
* as the gateway, works without it. The endpoint says so rather than
* pretending, because "no iceServers" and "TURN is broken" need different
* answers from whoever is looking.
*/
const KEY_ID = process.env.CLOUDFLARE_TURN_KEY_ID;
const API_TOKEN = process.env.CLOUDFLARE_TURN_API_TOKEN;
/**
* Short on purpose. These are handed to a browser, so the window in which a
* leaked one is useful is the window this sets. An hour outlasts setting up a
* session comfortably; a viewer watching longer than that has their relay
* drop and the player renegotiates, which costs a reconnect and gets fresh
* credentials - the right trade against a credential that stays good all day.
*/
const TTL_SECONDS = 3600;
/** Whether TURN is configured at all. */
export function turnConfigured(): boolean {
return Boolean(KEY_ID && API_TOKEN);
}
export type IceServer = {
urls: string | string[];
username?: string;
credential?: string;
};
/**
* Mints a short-lived set of ICE servers, or returns null when TURN is not
* configured.
*
* Throws only when TURN *is* configured and Cloudflare refused: that is a
* real fault and must not be reported as "no TURN here", which would send a
* viewer into a session that cannot carry media.
*/
export async function mintIceServers(): Promise<IceServer[] | null> {
if (!KEY_ID || !API_TOKEN) return null;
const response = await fetch(
`https://rtc.live.cloudflare.com/v1/turn/keys/${encodeURIComponent(KEY_ID)}/credentials/generate-ice-servers`,
{
method: "POST",
headers: {
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ttl: TTL_SECONDS }),
},
);
// 201 on success, per Cloudflare's own docs, so this checks the class.
if (!response.ok) {
// The body carries the reason - a wrong key id, a revoked token - and a
// bare status would send whoever reads this at the network instead.
const detail = await response.text().catch(() => "");
throw new Error(
`Cloudflare TURN refused to mint credentials (HTTP ${response.status})` +
(detail.trim() ? `: ${detail.trim().slice(0, 300)}` : ""),
);
}
const body = (await response.json()) as { iceServers?: IceServer | IceServer[] };
if (!body.iceServers) {
throw new Error("Cloudflare TURN returned no iceServers");
}
// Cloudflare returns a single object; the browser's RTCPeerConnection wants
// a list. Normalised here so every caller does not have to know that.
return Array.isArray(body.iceServers) ? body.iceServers : [body.iceServers];
}
+100 -13
View File
@@ -9,7 +9,8 @@ import { printerProjection } from "../db/projections";
import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../auth"; import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../auth";
import { PaginationQuery, paginate } from "./pagination"; import { PaginationQuery, paginate } from "./pagination";
import { resolveStream } from "../stream"; import { resolveStream } from "../stream";
import { getStreamBaseUrl, isGatewayOnline, sendPrinterCommand } from "./ws/edge"; import { mintIceServers, turnConfigured } from "../camera/ice";
import { isGatewayOnline, negotiateCameraStream, sendPrinterCommand } from "./ws/edge";
const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
@@ -201,18 +202,31 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
{ params: IdParams, detail: { tags: ["printers"] } }, { params: IdParams, detail: { tags: ["printers"] } },
) )
/** /**
* Where a browser can play this printer's camera. * What a browser needs to start watching this printer's camera.
* *
* 404 rather than 200-with-nulls when either half is missing: a frontend * Deliberately no URL. A browser cannot play from a gateway's own address:
* handed `{ streamUrl: null }` has to remember to check, and one that * go2rtc sends no CORS headers and checks its API password before CORS, so
* an unauthenticated preflight gets a 401 - measured against 1.9.14 - and
* a gateway sits behind a workshop NAT besides. This used to return the
* gateway's LAN address, which could only ever have worked for an operator
* standing on the same network, and offered every other viewer a player
* that silently never loaded.
*
* So the caller gets a camera name to negotiate for and the ICE servers to
* negotiate with, then POSTs an offer to `/:id/webrtc`. Media travels
* browser-to-gateway over the TURN relay, DTLS-SRTP encrypted, so neither
* this process nor the relay can read it.
*
* 404 rather than 200-with-nulls when it cannot be watched: a frontend
* handed `{ cameraId: null }` has to remember to check, and one that
* forgets renders a broken player. The two causes get different messages * forgets renders a broken player. The two causes get different messages
* because they are different problems for whoever debugs it (a P1/A1 that * because they are different problems (a P1/A1 that can never stream,
* can never stream, versus a gateway that is merely offline). * versus a gateway that is merely unplugged).
* *
* LIMITATION, by design for the MVP: `streamUrl` is the gateway's LAN * `iceServers` is empty when TURN is not configured. That is a supported
* address and nothing relays it. A browser on the gateway's network can * state, not a failure: an operator on the gateway's own LAN connects on
* play it; one outside cannot, and this endpoint cannot tell which the * host candidates alone. It does mean remote viewers will not work until
* caller is. A relay is the fix and does not exist yet. * CLOUDFLARE_TURN_KEY_ID and CLOUDFLARE_TURN_API_TOKEN are set.
*/ */
.get( .get(
"/:id/stream", "/:id/stream",
@@ -224,12 +238,85 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
}); });
if (!printer) throw new NotFoundError(`Printer ${params.id} not found`); if (!printer) throw new NotFoundError(`Printer ${params.id} not found`);
const result = resolveStream(printer.cameraStream, getStreamBaseUrl(printer.farmId)); // The gateway has to be connected, because it is what answers the
// offer - a camera name alone would start a negotiation with nobody.
const result = resolveStream(printer.cameraStream, isGatewayOnline(printer.farmId));
if (!result.ok) throw new NotFoundError(result.message); if (!result.ok) throw new NotFoundError(result.message);
return result.stream;
// Minted per request and short-lived, so the window in which a leaked
// credential is useful is small. Never cached in the client beyond the
// session it opens.
const iceServers = await mintIceServers();
return { ...result.stream, iceServers: iceServers ?? [], turnConfigured: turnConfigured() };
}, },
{ {
params: IdParams, params: IdParams,
detail: { tags: ["printers"], summary: "Where to play this printer's camera (LAN address)" }, detail: {
tags: ["printers"],
summary: "What a browser needs to negotiate this printer's camera",
},
},
)
/**
* Relays a browser's WebRTC offer to the printer's gateway and returns
* go2rtc's answer.
*
* **This endpoint carries signalling, not video.** One small request and
* response; the media then flows browser-to-gateway directly over ICE. On
* a workshop LAN it never leaves the building and costs this backend no
* bandwidth, which is the whole reason the relay is built this way rather
* than proxying bytes - that would have put every viewer's video through
* the cloud and added a second of fragment buffering.
*
* It is also the *only* way a browser can play these cameras. go2rtc
* sends no CORS headers and checks its API password before CORS, so a
* browser signalling to a gateway directly gets a 401 on its preflight -
* measured against 1.9.14, not assumed. And a gateway is behind a workshop
* NAT, so this backend cannot dial in either; the gateway's own outbound
* socket is the only path.
*
* Takes the offer as JSON rather than `application/sdp` so it validates
* like every other route here, and because `application/sdp` is not a
* CORS-safelisted content type - which is exactly the trap that makes the
* direct path impossible.
*/
.post(
"/:id/webrtc",
async ({ user, params, body, status }) => {
// "viewer" is deliberate: watching a camera is reading. Anything that
// moves the machine wants "operator".
await requirePrinter(user.id, params.id, "viewer");
const result: { sdp?: string; error?: string } = await negotiateCameraStream(
params.id,
body.sdp,
).catch((err: unknown) => ({ error: err instanceof Error ? err.message : String(err) }));
// 503, not 500: nothing is wrong with this request, and a client that
// gets this should try again later rather than change what it sent.
if (!result.sdp) {
return status(503, {
error: "CAMERA_UNAVAILABLE",
message: result.error ?? "the gateway did not return an answer",
});
}
return { sdp: result.sdp };
},
{
params: IdParams,
body: t.Object({
sdp: t.String({
// An SDP offer from a browser is a few kilobytes. Bounded because
// this is forwarded verbatim over a farm's control socket, and that
// socket also carries telemetry for every printer on it.
minLength: 1,
maxLength: 64_000,
description: "The browser's WebRTC offer SDP",
}),
}),
detail: {
tags: ["printers"],
summary: "Relay a WebRTC offer to this printer's camera (signalling only)",
},
}, },
); );
+122
View File
@@ -68,6 +68,11 @@ interface PendingCommand {
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
} }
interface PendingWebrtc {
resolve: (result: { sdp?: string; error?: string }) => void;
timer: ReturnType<typeof setTimeout>;
}
interface EdgeConn { interface EdgeConn {
farmId: string; farmId: string;
gatewayId: string; gatewayId: string;
@@ -104,6 +109,13 @@ interface EdgeConn {
printerIds: Map<string, string>; printerIds: Map<string, string>;
/** Commands sent but not yet answered, keyed by command_id. */ /** Commands sent but not yet answered, keyed by command_id. */
pending: Map<string, PendingCommand>; pending: Map<string, PendingCommand>;
/**
* In-flight camera negotiations, keyed by session id. Separate from
* `pending` because the two resolve with different things and a webrtc
* session is not a command - nothing physical happens, and nothing is
* retried.
*/
webrtc: Map<string, PendingWebrtc>;
} }
const connections = new Map<string, EdgeConn>(); const connections = new Map<string, EdgeConn>();
@@ -183,6 +195,15 @@ export function listUnclaimedPrinters(farmId: string): UnclaimedPrinter[] {
/** How long to wait for a gateway to answer a command before giving up. */ /** How long to wait for a gateway to answer a command before giving up. */
const COMMAND_TIMEOUT_MS = 10_000; const COMMAND_TIMEOUT_MS = 10_000;
/**
* Longer than a command, because answering means gathering ICE candidates
* and that includes a STUN round trip from the gateway. Measured against
* go2rtc 1.9.14 it is well under this, but the gateway's own request to
* go2rtc is capped at 8s, so this has to outlast that or the cloud would
* give up on an answer that was still coming.
*/
const WEBRTC_TIMEOUT_MS = 12_000;
/** /**
* How long an authenticated socket may stay open without sending `hello`. * How long an authenticated socket may stay open without sending `hello`.
* Generous compared with the round trip it covers - a gateway sends `hello` * Generous compared with the round trip it covers - a gateway sends `hello`
@@ -369,6 +390,67 @@ export async function sendPrinterCommand(
return { ...result, commandId }; return { ...result, commandId };
} }
/**
* Relays a browser's WebRTC offer to the gateway that owns this printer and
* returns go2rtc's answer.
*
* **Signalling only.** No video passes through this process: once the
* browser has the answer, it and the gateway's go2rtc negotiate media
* directly over ICE. On a workshop LAN that keeps the stream inside the
* building and costs this backend no bandwidth at all, which is why the
* relay carries SDP rather than bytes.
*
* The cloud has to be in the middle because neither end can reach the other
* directly. A browser cannot signal to go2rtc - measured against 1.9.14, it
* sends no CORS headers and checks its password before CORS, so a browser's
* unauthenticated preflight gets a 401 - and a gateway is behind a workshop
* NAT, so this backend cannot dial in either. The gateway's own outbound
* socket is the only path, and it already exists.
*
* Throws for a printer that is unknown, has no camera, or whose farm has no
* gateway connected. Resolves `{ error }` when the gateway answered with a
* failure or did not answer in time - the caller needs to tell "no camera
* here" from "the gateway never replied".
*/
export async function negotiateCameraStream(
printerId: string,
offerSdp: string,
): Promise<{ sdp?: string; error?: string }> {
const printer = await db.query.printers.findFirst({
columns: { id: true, farmId: true, cameraStream: true },
where: eq(printers.id, printerId),
});
if (!printer) throw new Error(`printer ${printerId} not found`);
// Checked here rather than left to go2rtc: the gateway would refuse an
// unknown stream anyway, but 12 seconds later and as a vaguer error.
if (!printer.cameraStream) {
throw new Error(`printer ${printerId} has no camera stream`);
}
const wsId = gatewayByFarm.get(printer.farmId);
const conn = wsId ? connections.get(wsId) : undefined;
if (!conn) throw new Error(`no gateway connected for farm ${printer.farmId}`);
const sessionId = crypto.randomUUID();
return new Promise<{ sdp?: string; error?: string }>((resolve) => {
const timer = setTimeout(() => {
conn.webrtc.delete(sessionId);
resolve({ error: `gateway did not answer within ${WEBRTC_TIMEOUT_MS}ms` });
}, WEBRTC_TIMEOUT_MS);
conn.webrtc.set(sessionId, { resolve, timer });
conn.send({
type: "webrtc_offer",
session_id: sessionId,
camera_stream: printer.cameraStream!,
sdp: offerSdp,
});
});
}
/** Whether a farm currently has a gateway connected. */ /** Whether a farm currently has a gateway connected. */
export function isGatewayOnline(farmId: string): boolean { export function isGatewayOnline(farmId: string): boolean {
const wsId = gatewayByFarm.get(farmId); const wsId = gatewayByFarm.get(farmId);
@@ -380,6 +462,13 @@ export function isGatewayOnline(farmId: string): boolean {
* undefined if no gateway is connected or it advertised none. Deliberately * undefined if no gateway is connected or it advertised none. Deliberately
* does not distinguish those two: callers only need to know it is unusable. * does not distinguish those two: callers only need to know it is unusable.
*/ */
/**
* No longer part of the playback path, and kept deliberately rather than
* deleted: gateways still report `stream_base_url` in their hello, and it is
* the only record of where a gateway thinks it is reachable - useful when
* diagnosing one. Nothing serves it to a browser any more, because a browser
* cannot use it (no CORS on go2rtc, and a workshop NAT in the way).
*/
export function getStreamBaseUrl(farmId: string): string | undefined { export function getStreamBaseUrl(farmId: string): string | undefined {
const wsId = gatewayByFarm.get(farmId); const wsId = gatewayByFarm.get(farmId);
return wsId ? connections.get(wsId)?.streamBaseUrl : undefined; return wsId ? connections.get(wsId)?.streamBaseUrl : undefined;
@@ -426,6 +515,7 @@ export const edgeWsRoute = new Elysia()
send: (message) => ws.send(message), send: (message) => ws.send(message),
printerIds: new Map(), printerIds: new Map(),
pending: new Map(), pending: new Map(),
webrtc: new Map(),
}; };
conn.handshakeTimer = setTimeout(() => { conn.handshakeTimer = setTimeout(() => {
@@ -498,6 +588,29 @@ export const edgeWsRoute = new Elysia()
break; break;
} }
case "webrtc_answer":
case "webrtc_error": {
const waiting = conn.webrtc.get(message.session_id);
if (!waiting) {
// The browser's request already timed out, or this gateway
// answered a session nobody asked about. Either way there is
// nobody left to hand it to.
console.warn(
`[edge] gateway ${conn.gatewayId} answered camera session ` +
`${message.session_id}, which is no longer waiting`,
);
return;
}
clearTimeout(waiting.timer);
conn.webrtc.delete(message.session_id);
waiting.resolve(
message.type === "webrtc_answer"
? { sdp: message.sdp }
: { error: message.message },
);
break;
}
case "command_result": { case "command_result": {
const pending = conn.pending.get(message.command_id); const pending = conn.pending.get(message.command_id);
if (!pending) { if (!pending) {
@@ -579,6 +692,15 @@ export const edgeWsRoute = new Elysia()
pending.resolve({ ok: false, error: "gateway disconnected before answering" }); pending.resolve({ ok: false, error: "gateway disconnected before answering" });
conn.pending.delete(commandId); conn.pending.delete(commandId);
} }
// Same for camera negotiations, and it matters more here: a browser
// is holding an HTTP request open on each one, so waiting out the
// full timeout for a gateway that has already gone is 12 seconds of
// a spinner that could say "offline" now.
for (const [sessionId, waiting] of conn.webrtc) {
clearTimeout(waiting.timer);
waiting.resolve({ error: "gateway disconnected before answering" });
conn.webrtc.delete(sessionId);
}
if (conn.farmId && gatewayByFarm.get(conn.farmId) === ws.id) { if (conn.farmId && gatewayByFarm.get(conn.farmId) === ws.id) {
gatewayByFarm.delete(conn.farmId); gatewayByFarm.delete(conn.farmId);
} }
+19 -11
View File
@@ -1,29 +1,37 @@
/** /**
* Camera stream resolution, on its own with no imports - the same reason as * Camera stream resolution, on its own with no imports - the same reason as
* `dispatch/job_status.ts`: `../db` opens a connection at import time, and * `dispatch/job_status.ts`: `../db` opens a connection at import time, and
* the two distinct failure messages here are worth testing without one. * the distinct failure messages here are worth testing without one.
*
* This used to hand back the gateway's own LAN address for the browser to
* play from. It cannot: go2rtc sends no CORS headers and checks its API
* password before CORS, so a browser's unauthenticated preflight gets a 401 -
* measured against 1.9.14 - and a gateway is behind a workshop NAT anyway.
* So there is no URL to give out. What a viewer needs is a camera name to ask
* for and a gateway that is connected to relay the SDP exchange.
*/ */
export const NO_CAMERA_MESSAGE = "this printer reports no camera that can be streamed"; export const NO_CAMERA_MESSAGE = "this printer reports no camera that can be streamed";
export const NO_GATEWAY_STREAM_MESSAGE = export const NO_GATEWAY_MESSAGE =
"this farm's gateway is not connected or advertises no stream base URL"; "this farm's gateway is not connected, so its camera cannot be negotiated";
export type StreamResolution = export type StreamResolution =
| { ok: true; stream: { streamUrl: string; cameraId: string } } | { ok: true; stream: { cameraId: string } }
| { ok: false; reason: "no_camera" | "no_stream_base"; message: string }; | { ok: false; reason: "no_camera" | "gateway_offline"; message: string };
/** /**
* Both halves or an error naming the missing one. The printer is checked * A camera name, or an error naming what is missing. The printer is checked
* first: if it can never stream, saying the gateway is offline would send * first: if it can never stream, saying the gateway is offline would send
* someone off to fix the wrong thing. * someone off to fix the wrong thing - and a P1/A1 can never stream, so this
* is the common case rather than the unlucky one.
*/ */
export function resolveStream( export function resolveStream(
cameraStream: string | null | undefined, cameraStream: string | null | undefined,
streamBaseUrl: string | undefined, gatewayOnline: boolean,
): StreamResolution { ): StreamResolution {
if (!cameraStream) return { ok: false, reason: "no_camera", message: NO_CAMERA_MESSAGE }; if (!cameraStream) return { ok: false, reason: "no_camera", message: NO_CAMERA_MESSAGE };
if (!streamBaseUrl) { if (!gatewayOnline) {
return { ok: false, reason: "no_stream_base", message: NO_GATEWAY_STREAM_MESSAGE }; return { ok: false, reason: "gateway_offline", message: NO_GATEWAY_MESSAGE };
} }
return { ok: true, stream: { streamUrl: streamBaseUrl, cameraId: cameraStream } }; return { ok: true, stream: { cameraId: cameraStream } };
} }
+15 -12
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Value } from "@sinclair/typebox/value"; import { Value } from "@sinclair/typebox/value";
import { EdgeClientMessageSchema } from "@continuum/ts-types"; import { EdgeClientMessageSchema } from "@continuum/ts-types";
import { NO_CAMERA_MESSAGE, NO_GATEWAY_STREAM_MESSAGE, resolveStream } from "../src/stream"; import { NO_CAMERA_MESSAGE, NO_GATEWAY_MESSAGE, resolveStream } from "../src/stream";
const hello = { type: "hello", gateway_id: "gw-1", farm_id: "f1", token: "t" }; const hello = { type: "hello", gateway_id: "gw-1", farm_id: "f1", token: "t" };
@@ -37,26 +37,29 @@ describe("telemetry camera_stream", () => {
}); });
describe("resolveStream", () => { describe("resolveStream", () => {
// No streamUrl any more, and its absence is the point: a browser cannot
// play from a gateway's own address (no CORS, and a workshop NAT besides),
// so handing one out only invited a player that could never load. What is
// left is the camera name to negotiate for.
test("returns the endpoint's success shape", () => { test("returns the endpoint's success shape", () => {
expect(resolveStream("p1", "http://gateway.local:1984")).toEqual({ expect(resolveStream("p1", true)).toEqual({ ok: true, stream: { cameraId: "p1" } });
ok: true,
stream: { streamUrl: "http://gateway.local:1984", cameraId: "p1" },
});
}); });
test("the two failures are distinguishable", () => { test("the two failures are distinguishable", () => {
const noCamera = resolveStream(null, "http://gateway.local:1984"); const noCamera = resolveStream(null, true);
const noBase = resolveStream("p1", undefined); const offline = resolveStream("p1", false);
if (noCamera.ok || noBase.ok) throw new Error("expected both to fail"); if (noCamera.ok || offline.ok) throw new Error("expected both to fail");
expect(noCamera.reason).toBe("no_camera"); expect(noCamera.reason).toBe("no_camera");
expect(noBase.reason).toBe("no_stream_base"); expect(offline.reason).toBe("gateway_offline");
expect(noCamera.message).toBe(NO_CAMERA_MESSAGE); expect(noCamera.message).toBe(NO_CAMERA_MESSAGE);
expect(noBase.message).toBe(NO_GATEWAY_STREAM_MESSAGE); expect(offline.message).toBe(NO_GATEWAY_MESSAGE);
expect(noCamera.message).not.toBe(noBase.message); // Different problems for whoever is debugging: a P1/A1 that can never
// stream, versus a gateway that is merely unplugged.
expect(noCamera.message).not.toBe(offline.message);
}); });
test("a printer with no camera is blamed first, even if the gateway is also absent", () => { test("a printer with no camera is blamed first, even if the gateway is also absent", () => {
const both = resolveStream(undefined, undefined); const both = resolveStream(undefined, false);
expect(!both.ok && both.reason).toBe("no_camera"); expect(!both.ok && both.reason).toBe("no_camera");
}); });
}); });