Realtime & notifications
A logged-in user's browser holds one SSE stream to the backend (GET /api/realtime/connection), multiplexed across every topic and shared by all of its tabs. The engine joins it on login and leaves it on logout — there is no realtime traffic for anonymous visitors.
One connection per browser, a session per tab
Over HTTP/1.1 a browser opens at most six connections per host, and a stream holds one for as long as it lives. A stream per tab would starve the dashboard after a few tabs: pages that never become interactive, code chunks that fail to load, writes that never leave the browser. So the tabs of one browser profile share a single connection:
- One tab is elected to hold the stream, with a Web Lock the browser releases when that tab closes, crashes or is discarded. It relays what the stream carries to the other tabs over a
BroadcastChannel. - Each tab opens its own session on that connection (
POST /api/realtime/session, naming the connection in thex-realtime-connectionheader): its page, its topics and its presence stay its own, exactly as with a stream of its own.sessionIdis still per tab. - A session belongs to the account and workspace its tab opened it in, and only receives the events of that workspace. A switch of either (the session's
accountIdoractiveTenantIdchanging) opens a new session on the same connection. A tab whose page requests come from another workspace than its session's (the workspace switched in another tab) has its session ended by the server (asession-endedframe), and opens a new one in the current workspace; the other tabs' sessions stay. - When the holding tab goes, the next tab takes the lock and opens a new stream; every tab opens a new session on it, puts its page back and re-acquires its presence, as after any reconnection. When another tab goes, the holder ends that tab's session (
POST /api/realtime/session/close, with thex-realtime-connectionandx-realtime-sessionheaders), releasing its presence at once. - When the backend answers a session request
ok: false(it does not know the connection named: the stream went, or the request reached an instance that does not hold it), the tab asks the holding tab to open a new stream, and opens its session on that one. The attempts are spaced by a growing delay. - When the backend ends one tab's session alone, it tells that tab on the shared stream (a
session-endedframe), and the tab opens a new session on the same connection. The other tabs keep theirs. - The tabs of different users (an account switched in one tab) never share a connection.
- In development the connection also carries the backend's reload signal (under the topic the session's greeting names in
devReloadTopic,dms:dev-reload), so a signed-in browser holds one stream to the dev server. Signed-out tabs (the sign-in page) and tabs whose session is reconnecting followGET /dms/dev/reloadinstead, one stream shared the same way by all of them.
Web Locks need a secure context — HTTPS, or localhost. Served over plain HTTP from another host name, each tab falls back to a connection of its own and the six-connection limit applies again: serve the dashboard over HTTPS, and preferably over HTTP/2 (see Deployment).
The x-realtime-connection and x-realtime-session headers reach the backend through the dashboard's server proxy, which relays them by name (dms-frontend CLIENT_HEADERS).
localhost), each tab holds a stream of its own, and six tabs over HTTP/1.1 hold all six connections: every other request of those tabs — a page's data, a chunk, a save — waits until one closes. Serve the dashboard over HTTPS, and over HTTP/2 (or HTTP/3) in production, typically by terminating TLS at a reverse proxy that speaks HTTP/2 to browsers: a stream then is one of many on a single connection.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.
import { useRealtimeTopic } from "#dms-core/app/composables/realtime/useRealtimeTopic";
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 — 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). When several instances hold a page at once -- a page remounted at the same path because only its query changed sets up its new instance before or after the old one is torn down -- the session follows the instance that set its page last, so a remount never drops the page topics.
The catch-all page renderer ([...slug].vue) already calls usePageRealtime for the current page and provides it to the page's components with providePageRealtime. A component inside a page therefore never needs the page object to listen: it subscribes to its topic with useRealtimeTopic, which rides on the same session.
useRealtimeTopic("table:rows", (event) => {
// event.topic === "table:rows"
});
The returned PageRealtimeApi exposes the same subscribe as the engine, the shared sessionId, and the resolved pageId as a ref.
useRealtimeTopic in components. Reach for usePageRealtime(pageId) only when you render a page yourself.usePageLive — Direct and Pause
On a page declared with live, the built-in blocks refetch on their realtime events through the page's Direct / Pause valve. usePageLive() lets a module's own component follow the same state, without reading the DOM: stop a client-side ticker while paused, show what was missed, offer its own switch.
See Period filtering for the chart controls in context, rather than the isolated toggle alone.
const { isPaused, missedCount, lastUpdatedAt, pause, resume } = usePageLive();
watch(isPaused, (paused) => (paused ? stopTicker() : startTicker()));
| Field | Type | Meaning |
|---|---|---|
isEnabled | ComputedRef<boolean> | The page declares live. |
isLive | ComputedRef<boolean> | The page's data follows its events: Direct, or a page without live. |
isPaused | ComputedRef<boolean> | The page is paused: its blocks request nothing on their events. |
pause / resume / toggle | () => void | Switch the page; resuming refetches once each block that missed an event. |
lastUpdatedAt | ComputedRef<number | null> | When the page last read its data (ms since the epoch); null without live. |
missedCount | ComputedRef<number> | What the page missed since the pause, in published rows: payload.ids once per row, payload.count as that many, other events as one. |
pauseReason | ComputedRef<PageLiveNote | null> | Why the page paused by itself (a hold's reason, a hidden tab); null while live or paused by the user. |
hold / release | (reason) => void / () => void | Holds the page paused for a reason (an i18n key prefixed with $, or a PageLiveNote) until released or until the component goes. |
missedDetail / setMissedDetail | ComputedRef<PageLiveNote | null> / (detail | null) => void | A breakdown of what was missed, shown by the toggle after the count; cleared on resume. |
isPolling | ComputedRef<boolean> | The realtime stream is down and the page reads its blocks every maxRate: true from the first poll, not while the stream is still connecting. |
The server knows whether a page declares live (isEnabled), but the rest of the state is born in the browser: the realtime stream, the page's reads, a pause. A server render reads the page as live, not polling, never read, nothing missed; a component hydrating that render reads the same until it is mounted, so its first render in the browser matches the server's HTML, then it follows the page. A component mounted later, after a client navigation say, reads the page's state at once.
A component pauses the page while the user types, and gives the toggle a breakdown of the missed events from the page's own feed:
const live = usePageLive();
const onFocus = () => live.hold("$api.live.typing_in_tester"); // "Paused · typing in the Tester"
const onBlur = () => live.release();
let server5xx = 0;
useRealtimeTopic("api:requests", (event) => {
if (!live.isPaused.value || !("payload" in event)) return;
server5xx += Number(event.payload?.server5xx ?? 0);
live.setMissedDetail({ text: "$api.live.in_5xx", params: { count: server5xx }, tone: "error" });
});
watch(live.isPaused, (paused) => !paused && (server5xx = 0));
A PageLiveNote is { text, params?, tone? }: text is an i18n key prefixed with $ (translated with params, pluralised on params.count) or a plain text, and tone (neutral, warning, error) colours it.
A component that refreshes its own data on a topic uses usePageLiveRefresh below. A component that refreshes the whole page on its own clock calls refreshLivePageBlocks() (from #dms-ui/app/utils/blockRefresh) rather than refreshPageBlocks(): the live one is held while paused, the other is a user's request and always reads.
A component's useApiData polling (refreshInterval) stops while the page is paused, and reads once on resume if a poll fell due meanwhile: nothing to stop by hand. useApiData reads the pause through LIVE_PAUSE_SOURCE (#dms-core/app/composables/live/livePauseSource), which the UI layer provides for the whole app; an app that renders without it (a test mounting a component alone) polls as if live, and can provide its own () => boolean there. The full list of what Pause freezes, and what still reads, is in Live pages.
On a page without live, isEnabled is false, the page reads as live and pause / resume do nothing. A server render reads every page as live and never paused, and its isEnabled says whether the page declares live, so a component draws on the server what the hydrating browser draws. The state is the page's: a component reading it on one page sees the next page's state after a navigation.
usePageLiveRefresh refresh like a built-in block
A module's own component that reads its data again on a realtime topic goes through the same valve as the built-in blocks with a realtimeTopic, with usePageLiveRefresh(refresh, options). Nothing to reimplement and no cadence of its own: it follows the page's maxRate, and its slower slowRateAfter rate once the page's PeriodSelector shows a long period.
A useApiData read already follows the page's refreshes and the events of the page's own topics (see Auth requests): usePageLiveRefresh is for a topic of the component's own, or an idle refresh, and the read then opts out of the page's refresh, which would read it a second time. The session subscribes only to the topics registered on the page: a topic of the component's own is bound to the page in the backend with RegisterPageTopic(pageId, topic) (see Backend services), as a built-in block's realtimeTopic is by its builder.
const { data, refresh } = useApiData<Health>("/api/health", {
refreshOnPageRefresh: false,
});
usePageLiveRefresh(() => void refresh(), {
realtimeTopic: "api:requests",
idleRefresh: 60, // the verdict judges time too: read again after a quiet minute
});
- In Direct,
refreshruns on each event ofrealtimeTopicand on eachrefreshLivePageBlocks(), at most once permaxRate; the last event of a burst is never lost. - Paused (by the user, a hold or a hidden tab), nothing runs: the events count in the toggle's "N new", and one read catches up on resume.
refreshPageBlocks(), an action of the user, runs it at once, paused or not.- On a page without
live,refreshruns on every event and every refresh of the page.
| Option | Type | Meaning |
|---|---|---|
realtimeTopic | string | string[] | Topics whose events make the component read again, as a built-in block's realtimeTopic. |
idleRefresh | number | Seconds without a read after which the component reads again while the page is live, for data that moves with time alone. Nothing is read while paused. |
An event that names what changed can patch the read instead of reading it all again: useDmsData(key) gives its data, to change in place (see Auth requests & permissions).
It returns { missed, trigger, refreshNow }: missed counts the events the component missed while paused, trigger() asks for a read nobody asked for (a clock of the component's own), gated like an event, and refreshNow() reads at once, as a user's action does. A component that skips a read in some state (a tab that must not move) returns early from refresh.
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)registershandlerfortopicand 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/unsubscribefornull).usePageRealtimedrives this — you rarely call it by hand.sessionIdis this tab's realtime session id —$authFetchforwards it as thex-realtime-sessionheader so the backend can attribute your writes to your live stream (Auth requests & permissions).pageIdis 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.
See the bell before and after reading and category and subject preferences that consume this state.
State:
const { unreadCount, unreadTone, areCountsLoaded, unreadPreview, notifications, hasMore } =
useNotifications();
| Field | Meaning |
|---|---|
unreadCount | Number of unread notifications in the whole feed: the bell's badge, the Notifications entry of the navigation, the overview. |
unreadTone | The tone of the most important unread notification, which that badge is drawn in; undefined when nothing is unread. |
areCountsLoaded | Whether the counts were read once (with the page, or by fetchBellCounts); until then a 0 is not known to be true. |
unreadPreview | Short list of unread items for the dropdown. |
notifications | The paginated full list. |
hasMore | Whether another page exists for fetchNotifications. |
The bell and the Notifications entry show the same badge: the unread count, in the tone of the most important unread notification (error, then warning, primary, success, neutral). The server weighs it, an untoned notification counting as primary, the colour the inbox gives it while unread. Opening the bell changes neither: a notification leaves the count once it is opened or marked read.
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;
subjectId?: string;
tone?: NotificationTone | null; // "neutral" | "primary" | "success" | "warning" | "error"
createdAt: string;
updatedAt: string;
}
Server actions
These call the backend and then update local state:
fetchBellCounts()— refreshunreadCountandunreadTone. The dashboard frame reads them with the page (useRenderedBellCounts()): a server render shows the badge and the browser starts from the same counts, so a component showing them reads them again only after a change.fetchUnreadPreview()— refreshunreadPreview.fetchNotifications(reset = false)— append the next page intonotifications; passresetto start over from the top.markAsRead(id)/markAsUnread(id)— flipisReadand take the counts the write answers.deleteNotification(id)— remove the item locally and take the counts the write answers.
Each write on the feed (PUT mark-read/:id, mark-unread/:id, mark-all-read and its undo, DELETE delete/:id) answers counts, the whole feed's counts after it, as GET /counts gives them: the badge takes them without asking again.
The notifications page's inbox is not part of this store: it is a TableView.fromSource table over pageRoutePath("/settings/user/notifications", "/inbox"), drawn by the dms:inbox display. It lists the feed again once after each of its own row actions, and whenever unreadCount or the newest notification changes otherwise.
const { unreadCount, unreadTone, areCountsLoaded, fetchBellCounts, fetchUnreadPreview } =
useNotifications();
onMounted(async () => {
// Read with the page under the dashboard frame; read here elsewhere.
if (!areCountsLoaded.value) await fetchBellCounts();
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 type | Reconciler | Effect |
|---|---|---|
notification:new | handleIncomingNotification | Prepends the item, bumps unreadCount, and takes the stronger of unreadTone and the item's tone, without a request. |
notification:read | handleRemoteRead(ids, counts) | Marks those ids read, takes the counts the event carries. |
notification:unread | handleRemoteUnread(ids, counts) | Marks those ids unread, takes the counts the event carries. |
notification:all-read | handleRemoteAllRead(counts) | Marks all read, clears the preview, takes the counts. |
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, without a request: a read, an unread or an all-read event carries the whole feed's counts after it (payload.counts), which the server weighs, tone included; a new notification is weighed locally, since it can only strengthen the badge. A tab's own write comes back as an event too, before or after the write's answer, with the same counts: taking them again changes nothing. An event without counts (from a server before they were sent) has the counts read with fetchBellCounts.
Auth requests & permissions
The composables a layer's Vue uses to call the backend with auth, gate UI by permission, switch accounts, and recover expired sessions.
Page context
Composables exposing the dashboard's nav tree, current module, ownership, and per-user preferences — plus the page setup functions a backend page can name.