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
@org/fluffy-chainsaw-realtimeon the backend;@org/fluffy-chainsaw-realtime/clientin the browser (plus thefirebasepackage).- For deploys: the GCP project must be a Firebase project (already true if you
use auth).
fluffy-chainsaw deployprovisions the RTDB instance and deploys its security rules.
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 dynamic — realtime.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 therealtimecapability. Without this call,fluffy-chainsaw scanwill 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:
- Public channels (the default): any channel with no ACL. Readable by
everyone, or by any signed-in user when an
auth:block is declared. - Member-gated channels: the first
allow()flips a channel private — from then on only listed uids can subscribe:
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
-
Importing the server SDK in the browser.
@org/fluffy-chainsaw-realtimeis the backend package: it readsREALTIME_DEFAULTfrom the environment and talks through the Admin SDK — neither exists in a browser, and if they did, every security rule would be bypassed. The browser imports@org/fluffy-chainsaw-realtime/client, full stop. (Same split as auth.) -
Trying to
send()from the frontend. There is no clientsend— by design. Clients can never write to a channel (rules deny it); anything a user "sends" goes to your HTTP route, which authorizes it and then publishes: browser →POST /api/...→ serverchannel(...).send(...)→ all subscribers. -
Write-then-ping in the wrong order — or with the data in the ping. The sequence is: write the row, then send the bare ping. Pinging first is a race (subscribers refetch before the row exists); putting the content in the payload turns your channel into an unordered, lossy datastore. If the write runs in a transaction, send after commit — a ping for a rolled-back write makes clients refetch nothing.
-
Subscribing before login when
auth:is declared. Declaring anauth:block gates every channel behind a signed-in user — public channels included, not just member-gated ones — so a logged-out visitor's subscription is denied (permission_denied). The subscription also carries whatever identity exists when it starts; a denied listener does not retry after you log in. Gate the subscribe on login state (in React: an effect keyed on the current user, skippingon()while logged out), and passon()'s third argument so a denial shows up in your UI instead of only the console. Logged-out visitors still read over your HTTP routes; they just don't get live pings. -
allow()without the auth plugin. Membership is checked against auth uids; with noauth:block nobody can present one, so the firstallow()makes the channel unreadable for every client (fail closed, deliberately). Private channels require auth. -
Forgetting to mount
realtime.handler. The browser client bootstraps fromGET /realtime/config; without the mount, subscriptions never start (the error callback reports the failed config fetch). -
Keeping membership only in the channel ACL. The ACL answers exactly one question — who may subscribe. Your HTTP routes can't authorize against it (and shouldn't:
members()is a realtime lookup, not your query layer). The database is the source of truth; mirror changes into the ACL withallow/revokein the same request, asexamples/chatdoes. -
Expecting delivery of every send, or history. Signals are last-write-wins per
(channel, event): rapid sends coalesce, late subscribers see only the latest value. If losing an intermediate value is a bug, it's durable data — database + ping. -
Channel names with
/ . # $ [ ]. Building names from user input, emails, or URL paths hits the key rules (realtime.channel("rooms/101")throws). Use ids — a row id or UUID is ideal. -
Bundling two copies of
firebase(local dev). Withfile:-linked SDKs, each package can drag its ownnode_modules/firebaseinto the bundle — two module registries, so the realtime client can't see the auth client's app and subscriptions go out without the ID token (every private channel denies) — or fail outright withService database is not availablewhen the database component registered against the other copy's registry. Dedupe per bundler: Viteresolve: { dedupe: ["firebase"] }, esbuild--alias:firebase=./node_modules/firebase. Registry installs hoist one copy and don't hit this.examples/chat's verify script guards it.
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)
- Coalescing: rapid sends of the same
(channel, event)can skip intermediate values — subscribers converge on the latest. If every message must be delivered, that's durable data: write it to the database and ping. - No history / replay: a client that subscribes late sees only the latest value per event. Catch-up is an HTTP refetch.
- Read granularity is per channel, per uid: the ACL answers "may this user subscribe to this channel" — there are no per-event grants or roles within a channel (model those as separate channels, or keep them in your HTTP layer). And regardless of gating, durable or sensitive content belongs in the database behind your routes; channels carry signals.
- Region: the RTDB instance is provisioned in
us-central1(v1). - The browser client pulls in the
firebasepackage (same as auth).