Realtime (channels & signals)

Server-published channels the browser subscribes to — typing indicators, presence, live scores, "refetch now" pings. Backed by Firebase Realtime Database (WebSockets under the hood): connection management, reconnect, and fan-out are managed, not yours to operate. The same code runs locally (RTDB emulator, started by fluffy-chainsaw local) and deployed (a provisioned RTDB instance).

The opinion: signals, not data. send(event, payload) is last-write-wins per (channel, event) — subscribers see the latest value, rapid sends of the same event coalesce, and nothing accumulates. Durable data (chat history, feeds) lives in the database; realtime tells clients when to refetch it over HTTP, where your authorization already lives. There is no message history.

Direction is one-way. The server publishes; browsers subscribe. Clients can never write to a channel (security rules deny it; the server's Admin SDK bypasses them). Client→server communication is your normal HTTP routes.

Prerequisites

Declare it

The realtime.channel(...) call in code is the marker; the backend is a global singleton in fluffy-chainsaw.yaml, and the publishing runner opts in via uses.realtime:

realtime:
  engine: firebase              # only engine today

runners:
  app:
    uses: { realtime: true }    # this runner receives the realtime descriptor
    trigger: { http: true }

Multiple runners can set uses.realtime: true — for example, an HTTP web app that publishes channel updates and a background worker that also publishes on incoming queue messages. Non-HTTP runners (workers, scheduled jobs, push-subscription handlers) do not conflict. If two HTTP-triggered runners both set uses.realtime: true, the framework cannot infer which one should receive public /realtime/** traffic, and fluffy-chainsaw scan will fail with E_ROUTER_INVALID. Break the tie with realtime.router:

realtime:
  engine: firebase
  router: web-app               # this runner receives /realtime/** hosting rewrites

runners:
  web-app:
    uses: { realtime: true }
    trigger: { http: true }
  event-processor:
    uses: { realtime: true }    # also publishes to channels, but is not the realtime router
    trigger: { http: true }

Unlike other markers, channel names may be dynamicrealtime.channel(roomId) is fine. Channels are runtime paths inside the app's one realtime backend, not provisioned resources, so the static-name rule doesn't apply.

[!IMPORTANT] Because the scanner only processes TypeScript files, a static realtime.channel("any-placeholder") call must be present in the scanned source codebase (e.g. src/index.ts) so the scanner can detect the realtime capability. Without this call, fluffy-chainsaw scan will throw a validation error.

Backend

Publish signals from anywhere in the runner, and mount the handler once (it serves GET /realtime/config, which the browser client bootstraps from):

import express from "express";
import { realtime } from "@org/fluffy-chainsaw-realtime";

const app = express();
app.use(express.json());

app.use("/realtime", async (req, res) => {
  const result = await realtime.handler({
    method: req.method,
    path: req.path,          // mounting at "/realtime" strips the prefix
    headers: req.headers,
    body: req.body,
  });
  if (!result) return res.sendStatus(404);
  for (const [k, v] of Object.entries(result.headers ?? {})) res.setHeader(k, v);
  res.status(result.status).json(result.body);
});

// Publish: last-write-wins per (channel, event).
await realtime.channel("room-101").send("typing", { user: "Alice" });

Frontend

import { realtime } from "@org/fluffy-chainsaw-realtime/client";

const unsubscribe = realtime.channel("room-101").on(
  "typing",
  (data) => showTypingIndicator(data.user),
  (err) => showOffline(err), // optional: fires if the subscription is denied
);

on fires with the latest payload immediately (if the event was ever sent) and on every publish; it returns an unsubscribe function. The optional third argument receives subscription errors — a member-gated channel you're not on, or a missing login. The client pulls the database coordinates from /realtime/config on first use — your code never touches Firebase config.

The two patterns

Ephemeral signal — the payload IS the message, and losing intermediate values is fine (typing, presence, cursors, live counters):

app.post("/api/typing", async (req, res) => {
  await realtime.channel(req.body.room).send("typing", { user: req.body.user });
  res.sendStatus(202);
});

Write-then-ping — SQL stays the source of truth; the channel only says "something changed", and clients refetch over HTTP (where your authorization lives). This is how "update the database and every client updates" works:

app.post("/api/notes", async (req, res) => {
  await pool.query("INSERT INTO notes (body) VALUES ($1)", [req.body.text]);
  await realtime.channel("notes").send("changed");
  res.sendStatus(201);
});
realtime.channel("notes").on("changed", () => refetchNotes());

Bridging Pub/Sub into a channel is the same composition — a push-subscription handler that forwards:

pubsub.subscription("/on-order", async (msg) => {
  await realtime.channel(`orders:${msg.orderId}`).send("changed");
});

See examples/realtime/app for a complete runnable app with both patterns.

Access control

No yaml knobs — access is decided at runtime, by your server code, as data:

await realtime.channel(convId).allow(session.user.id);   // create the ACL / add members
await realtime.channel(convId).revoke(session.user.id);  // remove; effective on next read
await realtime.channel(convId).members();                // current ACL ([] = public)
await realtime.channel(convId).close();                  // delete data + ACL atomically

Enforcement happens at subscribe time in the database's security rules, against the live ACL data — creating or closing a private channel never redeploys rules. The browser side needs nothing: the realtime client shares the auth client's Firebase app, so a signed-in user's ID token rides the subscription automatically (subscribe after login; on()'s third argument receives the denial if you want to render it). Listing /channels (enumeration) is always denied; client writes are always denied; the ACL itself is never client-readable.

Membership requires the auth plugin — uids are auth users. Without an auth: block, a member-gated channel is unreadable by everyone (fail closed); the server can still write to it.

Invites, kicks, roles are your app's logic, not the SDK's: keep the real membership in your database (authorize HTTP routes against it) and mirror it into the channel ACL with allow/revoke. See examples/chat — a complete private-conversations app with invite links doing exactly this.

Pitfalls

The engine contract

engine: firebase is v1's only backend, but the API is the contract, not the engine. Anything that can answer "may user P subscribe to channel C?" and push updates can implement it (self-hosted WebSockets, Postgres-CDC, …). A future engine must honor: server-only writes; last-write-wins per (channel, event); channels public unless an ACL exists; membership enforced at subscribe time; uids are the auth plugin's user ids. App code never changes across engines.

Limits (by design)