Realtime
The web app and the mobile app keep a WebSocket open to the workspace on screen. Events reach everyone connected to that workspace, or only one person’s apps, whichever copy of the API they are connected to: each copy writes them to one Redis stream and reads every event from it.
The kit sends three:
| Event | Sent by | The web app then |
|---|---|---|
usage.changed |
The API, on every use | Shows the new usage, wherever it is on screen |
workspace.changed |
A member’s app, after a change through Better Auth | Fetches the workspace and its people again |
notifications.changed |
The API, to one person | Fetches their notifications again |
Add an event
Section titled “Add an event”-
Name it, with what it carries, in
REALTIME_EVENTSinpackages/contracts/src/realtime.ts. Add it toCLIENT_EVENTStoo if apps may send it.'project.changed': z.object({ projectId: z.uuid() }), -
Send it from the API where the change happens. Inside
database.transaction(), it goes out once the change is saved:this.realtime.publish(workspace.id, 'project.changed', { projectId });Or to one person’s open apps, on every device, and to nobody else. Nothing is kept: someone with no app open never hears it. For what they should see later, send a notification instead.
this.realtime.publishToUser(user.id, 'export.ready', { exportId });Or from the web app, for an event in
CLIENT_EVENTS:import { realtime } from '@/lib/realtime';realtime.send('project.changed', { projectId }); -
Do something with it in
useRealtime, inapps/web/src/features/workspaces/use-realtime.tsandapps/mobile/src/features/workspaces/use-realtime.ts, usually fetching a query again:case 'project.changed':void queryClient.invalidateQueries({ queryKey: ['workspaces', workspaceId, 'projects'] });break;
Send what changed, not the change: an event is a nudge to look again. One that is missed costs a refresh, never data, and nothing in an event is checked again before it is shown.
Who hears what
Section titled “Who hears what”The connection is GET /api/realtime?workspaceId=<id> with Upgrade: websocket, on the API’s
own address, so Caddy and the dev server pass it on as they do /api. It is let in with a
session, for a member of the workspace, from a page of the app or the API’s own address, as
the mobile app sends: a page elsewhere gets 403, someone who is not a member 404. It hears
the workspace’s events and the person’s own. Each event names who sent it, or null for the API.
What an app sends is checked against the contracts, and each person may send 30 events every 10 seconds. A connection stays open until it is closed, even if the session ends meanwhile; the next one needs a session again.
Test it
Section titled “Test it”Connect as a member with new WebSocket(url, { headers: { cookie: client.cookieHeader(), origin } }).
To test two copies of the API, start two test apps with the same redisPrefix. See
apps/api/test/realtime.e2e.test.ts.