[data-reveal]{opacity:1!important;transform:none!important}
Frontend Composables

Realtime & notifications

Consume SSE topic updates through the realtime composables and the notification-center store they feed.

A logged-in user has one SSE stream to the backend (GET /api/realtime/user), multiplexed across every topic. The engine opens it on login and closes it on logout — there is no realtime traffic for anonymous visitors.

You never touch the stream directly: you subscribe to a named topic and get a handler call for each event, plus a cached snapshot replay if you subscribe late. Topics are page-bound on the backend, so most layer code subscribes through the page-scoped facade. See Backend services for how the backend registers and publishes topics.

useRealtimeTopic — the simplest entry point

useRealtimeTopic(topic, handler) subscribes for the lifetime of the calling component. The topic may be a ref or getter: when it changes the old subscription is dropped and a new one opens, and the subscription is released automatically on unmount.

app/components/StatsCard.vue
import { useRealtimeTopic } from "#imports";

useRealtimeTopic("stats:live", (event) => {
  // event is a RealtimeEvent or RealtimeSnapshotEvent for "stats:live"
  refresh();
});

A falsy topic subscribes to nothing, so you can gate on a reactive value:

const topic = computed(() => (rowId.value ? `presence:row:${rowId.value}` : undefined));
useRealtimeTopic(topic, onPresence);

usePageRealtime / injectPageRealtime — page-scoped

Backend topics are bound to a page; subscribing to the page is what activates them. usePageRealtime(pageId) is a facade over the engine that watches pageId and calls setPage for you, telling the backend which page this user is viewing (clearing it again when the owning scope is disposed).

The catch-all page renderer ([...slug].vue) already calls usePageRealtime for the current page and exposes it with providePageRealtime. Descendant components therefore inject it rather than re-deriving the page:

app/components/LiveTable.vue
import { injectPageRealtime } from "#imports";

const realtime = injectPageRealtime();
const stop = realtime?.subscribe("table:rows", (event) => {
  // event.topic === "table:rows"
});
// call stop?.() to unsubscribe early
onScopeDispose(() => stop?.());

injectPageRealtime() returns undefined when no ancestor provided it (e.g. outside the page renderer); guard for that. The returned API exposes the same subscribe as the engine, the shared sessionId, and the resolved pageId as a ref.

Prefer useRealtimeTopic for fire-and-forget handlers and injectPageRealtime when you want the page-scoped object. Reach for usePageRealtime(pageId) directly only when you render a page yourself.

useUserRealtime — the engine

useUserRealtime() returns the app-wide singleton that owns the stream. Its API:

interface UserRealtimeApi {
  subscribe(topic: string, handler: RealtimeTopicHandler): () => void;
  setPage(pageId: string | null): Promise<void>;
  sessionId: Ref<string | undefined>;
  pageId: Ref<string | undefined>;
}
  • subscribe(topic, handler) registers handler for topic and returns an unsubscribe function. Multiple handlers per topic are allowed.
  • setPage(pageId | null) declares which page this user is viewing (POST /api/realtime/subscribe/<pageId>, or /api/realtime/unsubscribe for null). usePageRealtime drives this — you rarely call it by hand.
  • sessionId is the shared stream session id — $authFetch forwards it as the x-realtime-session header so the backend can attribute your writes to your live stream (Auth requests & permissions).
  • pageId is the currently applied page.

Handlers receive one of two event shapes:

interface RealtimeEvent {
  topic: string;
  type: string;
  payload?: Record<string, unknown>;
  actorId?: string;
  ts: number;
}

interface RealtimeSnapshotEvent {
  topic: string;
  entries: Array<{
    topic: string;
    rowId: string;
    actor: { id: string; displayName?: string; avatarUrl?: string };
    sessionId: string;
    instanceId: string;
    since: number;
  }>;
}

Branch on shape in your handler — a RealtimeSnapshotEvent carries entries (used for presence-style "who is here" state), a RealtimeEvent carries type and payload.

Notifications

The notification center is a store built on the engine. useNotifications() returns shared state plus two kinds of mutators: server actions (call the backend through $authFetch) and realtime reconcilers (mutate local state from incoming SSE, no list refetch). Sending a notification is a backend concern — the Notification() builder in Backend services.

State:

const { unreadCount, unreadPreview, notifications, hasMore } = useNotifications();
FieldMeaning
unreadCountNumber of unread notifications (badge count).
unreadPreviewShort list of unread items for the dropdown.
notificationsThe paginated full list.
hasMoreWhether another page exists for fetchNotifications.

Each item is a UserNotification:

interface UserNotification {
  _id: string;
  userId: string;
  icon: string;
  title: string;
  description: string;
  params: Record<string, string | number> | null;
  linkTo: string | null;
  isRead: boolean;
  categoryId: string;
  createdAt: string;
  updatedAt: string;
}

Server actions

These call the backend and then update local state:

  • fetchUnreadCount() — refresh unreadCount.
  • fetchUnreadPreview() — refresh unreadPreview.
  • fetchNotifications(reset = false) — append the next page into notifications; pass reset to start over from the top.
  • markAsRead(id) / markAllAsRead() — flip isRead and re-sync the count.
  • deleteNotification(id) / deleteAll() — remove items locally and re-sync.
app/components/NotificationBell.vue
const { unreadCount, fetchUnreadCount, fetchUnreadPreview } = useNotifications();

onMounted(async () => {
  await fetchUnreadCount();
  await fetchUnreadPreview();
});

How the SSE bridge keeps it live

A base-layer client plugin wires the engine to the store. While logged in it subscribes to the per-user topic notifications:user:<userId> and dispatches each event by its type to a reconciler:

SSE event typeReconcilerEffect
notification:newhandleIncomingNotificationPrepends the item, bumps unreadCount.
notification:readhandleRemoteRead(ids)Marks those ids read, re-syncs the count.
notification:all-readhandleRemoteAllReadMarks all read, clears the preview, re-syncs.

Because the bridge is always mounted, the badge and dropdown stay current across tabs and devices without you wiring anything — read a notification elsewhere and this client reflects it. Reconcilers update local state in place — only fetchUnreadCount is re-issued to keep the badge exact — never the full list.