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.
continuum-backend
Cloud control plane for Continuum. Bun + ElysiaJS, PostgreSQL (via pgCat/PgBouncer, with pgvector for frame embeddings), Valkey Streams, and Cloudflare R2 storage.
Run
bun install
scripts/dev-env.sh up # Valkey + Postgres/pgvector, and print the URLs
scripts/dev-env.sh schema # apply drizzle/ - same as bun run db:migrate
cp .env.example .env # fill in the URLs it printed, plus R2_*, SUPABASE_JWT_SECRET
bun run dev
scripts/dev-env.sh uses docker-compose.yml when a Docker daemon is
answering, and falls back to the same two services from apt when there is no
usable container runtime - some sandboxes cannot run Docker at all, because
dockerd needs CAP_NET_ADMIN for its bridge and user namespaces to unpack an
image, and neither can be granted from inside. scripts/dev-env.sh status
says which backend is in use.
Schema changes go through migrations: edit src/db/schema/, run
bun run db:generate, and commit the file it writes to drizzle/ along with
the schema change. scripts/dev-env.sh push reshapes a local database straight
from the schema with no migration file, which is faster while a table is still
moving but records nothing and will drop a column to make things match - so
generate before committing. drizzle/0000_*.sql creates the vector extension
itself; that line is hand-added, since drizzle-kit does not emit extension
statements and the migration would otherwise only apply to a database somebody
had already prepared.
Test
bun test # unit + protocol contract tests, no services needed
scripts/e2e-edge.sh # the whole edge path: real database, real printers
scripts/e2e-edge.sh seeds a farm, claims its printers, mints an edge token,
runs the real continuum-proxy against this backend, and asserts that live
telemetry reached Postgres and that a command reached a printer. It only ever
sends a fan speed, and only to a printer reporting IDLE.
scripts/capture-gateway-frames.js is the smaller tool: it stands in for this
backend and records what a gateway sends, for refreshing the fixtures in
tests/fixtures/.
Layout
src/db/- Drizzle schema (printers,farms,print_jobs,frame_embeddings) and client, pointed at pgCat.src/db/validators.tsderives Elysia/TypeBox request validators straight from the Drizzle tables viadrizzle-typebox, so the Postgres schema and the API validators come from one definition instead of two hand-synced ones.src/s3/- R2 presigned URL helpers (PUTfor client uploads,GETfor the edge proxy).src/queue/- Valkey client (ioredis) +XADDhelper pushing ontostream:camera_framesforcontinuum-ai-worker.
Valkey, not Redis: Redis 7.4+ is RSALv2/SSPL and 8.0+ adds AGPLv3, none of
which a commercial product can ship. Valkey is BSD-3-Clause and speaks the
same RESP protocol, so ioredis is unchanged - only the server differs. See
continuum-common/docs/LICENSES.md. docker-compose.yml here brings up
Valkey and Postgres+pgvector for local development.
src/routes/-/auth,/farms,/printers,/jobs, and the edge-gateway WebSocket at/ws/edge/v1. That route's wire format is not defined here: it isschemas/continuum/v1/edge/in continuum-schemas, validated through its TypeBox mirror in@continuum/ts-types, because both ends of it are separate programs in different languages and they had already drifted apart once.printers.gateway_printer_idis what maps a gateway's own printer ids ("p1p") onto rows here; telemetry for an unclaimed id is logged and skipped, never inserted.
frame_embeddings.embedding requires the vector extension. The first
migration creates it, and scripts/dev-env.sh up also does so for the push
path, which does not run migrations.
Dependency pins
elysia, @sinclair/typebox and the drizzle-* packages are pinned tightly
on purpose, not out of caution. Caret ranges here resolved into a combination
that could not boot: elysia: ^1.1.26 installed 1.4.30, which calls
t.Module and so needs TypeBox >=0.34, while drizzle-typebox: ^0.2.1
installed a version whose peers demand drizzle-orm >=0.36 against the
^0.33.0 pinned here. Typechecking passed throughout - only starting the
server failed. tests/dependency_identity.test.ts guards the part of this that
can silently come back.