Valcraven Docs
Building with Valcraven

Add a realtime channel

Push live events to clients — publish() on the server, useRealtime() on the client, and the single authorizeChannel seam you extend to authorize a new channel type.

Add a realtime channel

The realtime layer delivers instant, server-pushed events to connected clients over WebSockets. It's a generic, per-entity fan-out: any feature can publish() an event to a channel, and every client subscribed to that channel receives it. Adding a realtime feature is three small pieces — publish, subscribe, and (for a new channel type) one authorization case.

This page is the recipe; for the architecture — the Durable Object, hibernation, and why the old in-process pub/sub was replaced — see Realtime and Realtime Worker, plus the dev wiki at /admin/docs/realtime.

The channel model

A channel is a string "<type>:<id>" — the type before the first colon, the id after. Each distinct channel string maps to exactly one Durable Object instance, and every socket connected to it receives every event broadcast to it. The template ships these conventional types:

  • user:<userId> — a single user's personal channel.
  • notifications:<userId> — the notification bell's channel.
  • campaign:<id> — admin-only, used by the campaign send-progress feature.

Unknown types (doc:, room:, …) are denied by default — the template ships no membership model for them, so you add one deliberately (see Step 3 — Authorize the channel below).

Step 1 — Publish (server)

Import publish from @/lib/realtime and call it from any server code — an API route, a job, a webhook handler. The event is any JSON object; by convention it carries a type discriminator so the client can branch:

import { publish } from "@/lib/realtime";

await publish(`user:${userId}`, { type: "report-ready", reportId });

Two rules matter, and both are documented inline in lib/realtime.ts:

  • await it. In the Workers runtime, a promise left running after the response is returned can be cancelled before it completes, silently dropping the push. In a Next API route, always await publish(...) (or hand it to the platform waitUntil) so the request stays alive until the event is sent.
  • It's best-effort and non-throwing. A missing live push must never fail the request. Persist your source of truth first (the row in the database), then publish — the WebSocket fan-out is purely an instant-delivery optimization. If the realtime infra is unwired or unreachable, publish() logs and returns; it never throws, so don't rely on its result for correctness.

Step 2 — Subscribe (client)

Use the useRealtime hook from @/lib/use-realtime. It opens a WebSocket to the channel and calls your handler for each event:

import { useRealtime, type RealtimeEvent } from "@/lib/use-realtime";

useRealtime(`user:${userId}`, (event: RealtimeEvent) => {
  if (event.type === "report-ready") {
    // update local state — no refetch, no page reload
  }
});
  • Pass null as the channel to subscribe to nothing (e.g. while the user id is still loading); the hook tears down cleanly on unmount or channel change.
  • The hook keeps the socket warm with a heartbeat and reconnects automatically with capped backoff. Don't send an event literally typed "ping" — the heartbeat uses that name and the hook swallows it. (This is why the built-in demo uses demo-ping.)
  • The same hook exists for Expo (apps/mobile/src/lib/use-realtime.ts) with an identical signature, so a realtime feature works on mobile too.

The worked example is the RealtimeDemoCard on the dashboard (components/realtime-demo-card.tsx): a button POSTs to /api/realtime-demo/ping, which publishes demo-ping to the caller's own user: channel, and the card's useRealtime ticks a counter live. Copy it as your starting point; use notifications (lib/notifications.ts) as the production reference — it writes the row to the database, then publishes a new-notification event.

Step 3 — Authorize the channel (the single seam)

Every subscription is authorized before any Durable Object is touched. The one place that decides who may subscribe to what is authorizeChannel() in apps/realtime/src/channel/routing.ts — a pure, synchronous, deny-by-default function. To support a new channel type, add a case:

// apps/realtime/src/channel/routing.ts
export function authorizeChannel(
  parsed: ParsedChannel,
  user: { id: string; role?: string },
): boolean {
  switch (parsed.type) {
    case "user":
    case "notifications":
      return parsed.id === user.id;   // own channel only
    case "campaign":
      return user.role === "admin";   // admin-only, fail-closed
    default:
      return false;                   // unknown type → denied
  }
}

Guidance for your case:

  • Own-resource channels — compare the channel id to user.id, like user:/notifications:.
  • Admin-only channels — gate on user.role === "admin". The session surfaces a Better Auth role (synced from the app's isAdmin=1); check role, not an isAdmin field, here. Fail closed: no role or a non-admin returns false, so restricted data never fans out to a non-admin socket. This is exactly how campaign: works.
  • Shared / membership channels (a doc a user can edit, a room they joined) — return your membership check. If that check needs a database or an API call, the authorizer is designed to be swapped for an async one ((parsed, user, env) => Promise<boolean>) that the worker gate awaits before forwarding to the DO. Keep this the only place that authorizes subscriptions.

Throttle bursty events at the source

The fan-out is cheap but not free, and a socket that receives hundreds of frames a second is worse than useless. If an event fires rapidly (progress ticks, cursor moves, high-frequency updates), throttle or batch at the publisher, not the client — publish a coalesced update on an interval or per meaningful step rather than per change. The campaign progress feature, for example, publishes per send-batch, not per recipient.

Deploying it

The realtime channel and the AI agent both run in the apps/realtime Worker, which deploys independently of the web app. Changes to authorizeChannel (or anything else in apps/realtime) ship with a manual deploy — they do not go out when the web app deploys:

npm run deploy:realtime   # realtime worker only

See Realtime Worker for the full deploy/rollback runbook.

  • Realtime — the feature overview and the channel model in depth.
  • Realtime Worker — the Durable Object, hibernation, and deploy.
  • Add a chat tool — the other feature hosted in the realtime Worker.
  • Notifications — the production example of publish + subscribe.

On this page