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.
Back to top