Controls live stream (SSE)
Real-time push channel for controls with delta / polling fallback.
Controls have a real live push channel: an SSE endpoint at /controls/stream. It streams full snapshots with tombstones so the client's job is always "replace the local map".
When to use SSE vs polling
- SSE — dashboards, kiosk apps, anything that needs near-real-time updates. The shared server watcher polls every ~3 s, so a change normally reaches connected clients after the next ~3-second server poll plus network latency.
- Delta poll — mobile apps with strict battery/data budgets. Poll /controls/delta?since=… every 30–60 s in foreground.
- Full /controls — cold start, or after any error.
Minimal browser client (authenticated fetch stream)
Native EventSource cannot attach an Authorization header, and this endpoint does not accept a key in the URL. Use authenticated fetch streaming — or, when you need to reach it from a browser without exposing a key, a same-origin server proxy. Never put credentials in a URL.
ts
// Reads Server-Sent Events with an Authorization header.
// Payload per 'update' frame (snapshot: true means 'changed' is the FULL
// resolved controls map for this device — replace, don't merge):
// { changed: Record<string, Control>, deleted: string[], cursor: string, snapshot: true }
async function streamControls(uid: string, publishableKey: string, since?: string) {
const url = new URL(`https://ctrlapp.krdcode.com/api/public/sdk/v1/controls/stream`);
url.searchParams.set("device_uid", uid);
if (since) url.searchParams.set("since", since);
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${publishableKey}`,
Accept: "text/event-stream",
...(since ? { "Last-Event-ID": since } : {}),
},
});
if (!res.ok || !res.body) throw new Error(`stream failed: ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let cursor = since ?? "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line. Keep the trailing partial in the buffer.
let idx: number;
while ((idx = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message";
const dataLines: string[] = [];
for (const line of frame.split("\n")) {
if (line.startsWith(":")) continue; // heartbeat / comment
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
else if (line.startsWith("id:")) cursor = line.slice(3).trim();
}
if (!dataLines.length) continue;
const payload = JSON.parse(dataLines.join("\n"));
if (event === "update") {
// snapshot: true → 'changed' is the FULL resolved map for this device.
// Replace the local map wholesale, then drop any tombstoned keys.
replaceLocalMap(payload.changed);
for (const key of payload.deleted ?? []) removeKey(key);
cursor = payload.cursor ?? cursor;
persistCursor(cursor); // resume with Last-Event-ID / since
} else if (event === "bye") {
// Server hit its 5-minute cap ({ reason: "max-duration" }).
// Break out and reconnect with the persisted cursor.
break;
}
}
}
return cursor;
}Security
Do not put a publishable key in the SSE URL. This endpoint reads only the
Authorization header; a key in the query would still not authenticate, and would leak into referrer/proxy logs. When you cannot attach headers (e.g. plain EventSource), proxy the stream through your own server.Verify it
- A control edit in the dashboard reaches the connected client after the next server poll — normally about 3 seconds plus network latency.
- At the 5-minute cap the client observes event: bye ({ reason: "max-duration" }) or EOF, then reconnects using the persisted cursor (Last-Event-ID / since=) and receives subsequent changes from that point on.