Realtime
Live lists, record locks and unread badges over a browser, WebSocket, server-sent events or Supabase transport.
Realtime lets one user's change refresh what another user is looking at. A
transport carries events between clients; Chelone turns each event into a
refetch. It adds three things to a data provider: subscribe and publish
for events, and lock, unlock, getLock and getLocks for editing locks.
The examples below use @chelone/react; the Vue section lists what
differs in @chelone/vue. Both demos wire all of it in src/realtime.ts, with
a panel that plays "another user".
Wrapping a provider
realtimeDataProvider adds the realtime members to any data provider.
addEventsForMutations makes the provider publish after each successful write.
import {
addEventsForMutations,
inMemoryLockProvider,
memoryTransport,
realtimeDataProvider,
} from "@chelone/core";
import { baseProvider } from "./baseProvider";
export const transport = memoryTransport();
export const locks = inMemoryLockProvider({ publisher: transport });
export const dataProvider = addEventsForMutations(
realtimeDataProvider(baseProvider, transport, { locks }),
transport,
);Pass the result to <Admin dataProvider> as usual.
realtimeDataProvider(base, transport, { locks })keeps every method ofbaseand addssubscribeandpublishfrom the transport. The optional transport members (onReconnect,onStatusChange,getStatus,onError) are copied only when the transport has them.locksis optional. Without it, the lock methods reject with an error that says noLockProviderwas configured.addEventsForMutations(provider, publisher)publishes aftercreate,update,delete,updateManyanddeleteMany. The write resolves after the publishes settle and never fails because of them. Pass{ onError }to hear a rejected publish.- Leave out
addEventsForMutationswhen your server already publishes events for every write, such as a database change feed. Publishing twice refetches twice.
Combined providers
realtimeDataProvider must wrap the result of combineDataProviders, not the
other way round. combineDataProviders returns an object with only the nine
verbs, so it drops subscribe, publish and the lock methods of any provider
it combines.
const combined = combineDataProviders((resource) =>
resource === "invoices" ? billingProvider : catalogProvider,
);
export const dataProvider = addEventsForMutations(
realtimeDataProvider(combined, transport, { locks }),
transport,
);isRealtimeDataProvider(dataProvider) is true for a provider that went
through realtimeDataProvider. Every live hook and component throws a message
naming itself and asking you to wrap the provider when it is not.
Topics and event payloads
An event goes to a topic, and a subscriber listens on one. The topic helpers
in @chelone/core build the names, so none is written by hand.
| Helper | Topic | Carries |
|---|---|---|
resourceTopic("posts") | resource/posts | Creates, and the ids of any write |
recordTopic("posts", 1) | resource/posts/1 | Writes to one record |
lockResourceTopic("posts") | lock/posts | Every lock taken or released on the resource |
lockTopic("posts", 1) | lock/posts/1 | The lock of one record |
1 and "1" give the same topic.
An event is { topic, type, payload, meta? }. type is "created",
"updated", "deleted", "locked" or "unlocked", or any string of your own.
addEventsForMutations publishes these payloads:
| Write | Topic | Type | Payload |
|---|---|---|---|
create | resource | created | { ids: [id] } |
update | record | updated | { id, data } |
update | resource | updated | { ids: [id] } |
delete | record | deleted | { id, previousData } |
delete | resource | deleted | { ids: [id] } |
updateMany, deleteMany | resource | updated, deleted | { ids } |
updateMany, deleteMany | each record | updated, deleted | { id } |
A lock event has type locked or unlocked and a Lock as its payload:
{ resource, recordId, identity, createdAt }.
The hooks
All of these read the data provider from context and throw when it is not a realtime one.
| Hook | Does |
|---|---|
useSubscribe(topic, callback, { enabled }) | Calls callback for each event on topic. A new callback every render does not resubscribe. |
useSubscribeToRecord(resource, id, callback) | useSubscribe for one record; waits while id is null or undefined. |
useSubscribeToRecordList(resource, callback) | useSubscribe for the resource topic. |
useSubscribeCallback() | Returns subscribe(topic, callback), for subscribing outside an effect. |
usePublish() | Returns publish(topic, event). |
useOnReconnect(callback) | Calls callback after the transport reconnects. Does nothing when the transport cannot say. |
useRealtimeStatus() | Returns { status, lastError }; status is "idle" when the transport reports neither. |
useGetListLive, useGetOneLive, useGetManyLive | The plain read hook, refetching when its data changes or the connection returns. |
useGetLockLive, useGetLocksLive | The lock read hooks, refetching when a lock is taken or released. |
import { useRealtimeStatus, useSubscribeToRecordList } from "@chelone/react";
function PostsWatcher() {
const { status } = useRealtimeStatus();
useSubscribeToRecordList("posts", (event) => {
console.log(event.type, event.payload);
});
return <span>{status}</span>;
}The components
Each component wraps its plain version and adds a subscription, so swap the import and the view stays live. All need a realtime data provider.
import { DataTable, EditLive, ListLive, TextInput } from "@chelone/react";
export const PostList = () => (
<ListLive>
<DataTable>
<DataTable.Col source="title" />
</DataTable>
</ListLive>
);
export const PostEdit = () => (
<EditLive>
<TextInput source="title" />
</EditLive>
);ListLive
A <List> that refetches when a record in its resource changes or the
connection returns. It takes the props of <List>.
| Prop | Type | Default | Description |
|---|---|---|---|
| actions | false | ReactElement<unknown, string | JSXElementConstructor<any>> | — | Header actions (create/export/filter buttons); nothing by default. |
| children | ReactNode | — | |
| className | string | — | |
| component | ElementType<any, keyof IntrinsicElements> | — | Element/component wrapping the children (defaults to `"div"`). |
| perPage | number | — | Rows-per-page for the initial query. |
| resource | string | — | Override the ambient resource (defaults to the `<Resource>` context). |
| title | ReactNode | — | Header title. A string/element renders as an `<h2>`; `false` omits it. |
ShowLive
A <Show> that refetches when its record changes or the connection returns.
It takes the props of <Show>.
| Prop | Type | Default | Description |
|---|---|---|---|
| actions | ReactNode | — | Header actions (an edit button); nothing by default. |
| children | ReactNode | — | |
| className | string | — | |
| component | ElementType<any, keyof IntrinsicElements> | — | |
| id | Identifier | — | Override the record id (defaults to the `:id` route param). |
| resource | string | — | Override the ambient resource (defaults to the `<Resource>` context). |
| title | ReactNode | — | Header title. A string/element renders as an `<h2>`; `false` omits it. |
EditLive
An <Edit> that refetches the record when it changes or the connection
returns. A change that arrives while the form has unsaved edits is held, and
one refetch runs when the form is saved or reset, so a user's typing is never
overwritten. It takes the props of <Edit>.
| Prop | Type | Default | Description |
|---|---|---|---|
| actions | ReactNode | — | Header actions (show/delete buttons); nothing by default. |
| children | ReactNode | — | |
| className | string | — | |
| component | ElementType<any, keyof IntrinsicElements> | — | |
| id | Identifier | — | Override the record id (defaults to the `:id` route param). |
| onSuccess | (record: TRecord) => void | Promise<void> | — | Runs after a successful save with the SERVER record, post-reconciliation. |
| resource | string | — | Override the ambient resource (defaults to the `<Resource>` context). |
| sanitizeEmptyValues | boolean | — | ra parity: nullify/strip empty TOP-LEVEL values before save. Default false. |
| schema | StandardSchemaV1<Partial<TRecord>, unknown> | — | Resource schema (zod or any Standard Schema) — wired as the default form validator by the submit pipeline (T2). |
| title | ReactNode | — | Header title. A string/element renders as an `<h2>`; `false` omits it. |
| validate | FormLevelValidate<Partial<TRecord>> | — | Form-level validate fn (ra contract) — consumed by the submit pipeline (T2). |
MenuLive
A nav of MenuLiveItemLinks. Each link carries a badge counting the
resource's new records since the user last opened it. The count lives in the
store, so it survives a reload when the store has an adapter, and clears while
the current route is the link's to or below it.
<MenuLive items={[{ to: "/posts", resource: "posts", label: "Posts" }]} />| Prop | Type | Default | Description |
|---|---|---|---|
| items | MenuLiveItemLinkProps[] | — |
MenuLiveItemLink
One link of MenuLive, for use on its own.
| Prop | Type | Default | Description |
|---|---|---|---|
| badgeLabel | (count: number) => ReactNode | — | Formats the badge text. Defaults to the count, `99+` above 99. |
| events | RealtimeEventType[] | — | Event types that count as new. Defaults to `["created"]`. |
| icon | ReactNode | — | Icon rendered before `label`. |
| label | ReactNode | — | Link text. |
| resource | string | — | Resource whose events are counted. |
| to | string | — | Target path. The count clears while the current route is `to` or below it. |
LockOnMount
Locks a record while mounted and renders its children once it holds the lock.
While the lock is requested it shows loading; when another identity holds the
lock it shows lockedBy, which defaults to an alert reading Locked by <identity>.
<EditLive>
<LockOnMount identity="you">
<TextInput source="title" />
</LockOnMount>
</EditLive>| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Shown only while this component holds the lock. |
| id | Identifier | — | Defaults to the id of the record context. |
| identity | Identifier | — | Defaults to the auth provider's identity. |
| loading | ReactNode | — | Shown while the lock is requested. Defaults to a `Skeleton`. |
| lockedBy | (lock: Lock) => ReactNode | — | Shown when another identity holds the lock. Defaults to an `Alert` naming the holder. |
| meta | Record<string, unknown> | — | |
| onLockError | (error: Error) => void | — | Called with a failed lock or release. A new function every render never relocks. |
| resource | string | — | Defaults to the resource context. |
LockStatus
Shows who holds a record's lock, and updates when it is taken or released. A
free record renders renderFree, which defaults to nothing.
| Prop | Type | Default | Description |
|---|---|---|---|
| id | Identifier | — | Defaults to the id of the record context. |
| renderFree | () => ReactNode | — | Rendered when the record is free. Defaults to nothing. |
| renderLock | (lock: Lock) => ReactNode | — | Replaces the default badge naming the holder. |
| resource | string | — | Defaults to the resource context. |
PresenceBar
Shows avatars of the other users who have the same record open, and renders
nothing when nobody else is there. Place it inside a record view such as
<Edit>, where the resource and record contexts name the topic.
import { Edit, PresenceBar, TextInput } from "@chelone/react";
export const PostEdit = () => (
<Edit actions={<PresenceBar />}>
<TextInput source="title" />
</Edit>
);- Presence needs a realtime data provider. With
memoryTransportit works for one page only, such as two components in the same tab. To reach other users, usewebSocketTransport,sseTransportorsupabaseRealtimeTransport, orbroadcastChannelTransportfor the tabs of one browser. See The browser transports. - The bar announces the current user on
presence/<resource>/<id>, a topic built bypresenceTopic(resource, id), and listens on it for the others. The user comes from the auth provider's identity;currentUserreplaces it. - It republishes the announce every 15 seconds (
heartbeatMs) and drops a user unheard for 30 seconds (staleMs). A user who leaves the view, by navigating away within the app, leaves at once: the bar announces its departure as it unmounts. A closed tab cannot announce, so it goes afterstaleMs. - Stale time is measured by the receiver's clock, from when each announce arrives, so clocks that disagree between browsers do not matter.
- A second tab of the same user is left out.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | |
| currentUser | { avatar?: string; id: string; name: string } | — | Replaces the auth provider's identity as the user who is announced. |
| heartbeatMs | number | — | How often the announce is republished. Default 15000. |
| maxAvatars | number | — | How many avatars to show before the rest collapse into a `+N` count. Default 5. |
| staleMs | number | — | A user unheard for longer than this is dropped. Default 30000. |
| topic | string | — | Defaults to the presence topic of the resource and record contexts. |
usePresence(options) is the hook under the bar, for a custom display. It
announces the current user and returns the other users as PresenceUsers
({ id, name, avatar? }) while the component is mounted. It takes topic,
user, heartbeatMs, staleMs and enabled, and announces nothing when it
has no topic or no user.
CommentsThread
The comments on a record, and a box to add one. Comments are records of an
ordinary resource: reference names that resource and target is the field on
a comment that holds the id of its parent record. The parent is the record
context, or record when you pass one.
import { CommentsThread, Edit, TextInput } from "@chelone/react";
export const PostEdit = () => (
<Edit>
<TextInput source="title" />
<CommentsThread reference="comments" target="postId" resolvable />
</Edit>
);A comment holds these fields, besides its id and the target field:
| Field | Holds |
|---|---|
body | The text |
authorId | The identity id of the author |
authorName | The name shown for the author |
createdAt | An ISO timestamp |
resolvedAt | An ISO timestamp, or null while unresolved |
- The client sets
authorId,authorNameandcreatedAtas hints. The server should set them from the session and the clock, and overwrite what the client sent. - With a realtime data provider the thread refreshes on its own when a comment is created or changed. With a plain one it updates only on refetch.
- It offers no edit, delete or reply.
resolvableadds aMark resolvedbutton, which setsresolvedAt.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | |
| perPage | number | — | How many comments to fetch. Default 100. |
| record | { id?: Identifier | null } | — | Defaults to the record context. |
| reference | string | — | The resource that holds the comments. |
| resolvable | boolean | — | Adds a `Mark resolved` button to unresolved comments. Default false. |
| target | string | — | The field on a comment that holds the id of its parent record. |
Locks
A lock marks a record as being edited by an identity, so a second editor
can be told to wait. Chelone does not enforce it on writes: the lock lives in
a LockProvider, and your server decides what a lock blocks.
interface LockProvider {
lock(resource, params: { id; identity; meta? }): Promise<Lock>;
unlock(resource, params: { id; identity; meta? }): Promise<Lock>;
getLock(resource, params: { id; meta? }): Promise<Lock | null>;
getLocks(resource, params?: { meta? }): Promise<Lock[]>;
}lockrejects withLockConflictErrorwhen another identity holds the record. The error'sexistingLockis that lock. Locking again as the holder resolves with the existing lock.unlockrejects withLockConflictErrorwhen another identity holds the record, and with a plain error when nobody does.getLockresolvesnullfor a free record.inMemoryLockProvider({ publisher })keeps locks in aMapand announces each lock and unlock onlockTopicandlockResourceTopic. Without apublisherit is silent, andLockStatusdoes not update on its own. It is for tests and demos: locks do not outlive the page or reach another browser.
identity defaults to the identity from the auth provider. Pass it explicitly
when the app has none.
The hooks useLock() and useUnlock() return { lock, isLoading, error } and
{ unlock, isLoading, error }. useLockOnMount(options) holds a lock while the
component is mounted and returns { lock, isLocking, lockError }. Changing the
resource, id or identity releases the old lock before it takes the next.
useGetLock and useGetLocks read locks without subscribing.
Vue
@chelone/vue has the same engine, wrapping and components. Wrapping a
provider is identical, since it lives in @chelone/core; pass the result to
<Admin :data-provider>. What differs is the shape of the API.
Composables
Every hook above has a composable of the same name in @chelone/vue:
useSubscribe, useSubscribeToRecord, useSubscribeToRecordList,
useSubscribeCallback, usePublish, useOnReconnect, useRealtimeStatus,
useGetListLive, useGetOneLive, useGetManyLive, useGetLock,
useGetLocks, useGetLockLive, useGetLocksLive, useLock, useUnlock and
useLockOnMount.
- Arguments that can change take
MaybeRefOrGetter: pass a value, a ref or a getter. This coverstopicofuseSubscribe,resourceandidofuseSubscribeToRecord,resourceofuseSubscribeToRecordList,resourceandparamsofuseGetListLive,paramsofuseGetOneLive,useGetManyLive,useGetLockanduseGetLockLive, theenabledoption, and theresource,idandidentityoptions ofuseLockOnMount. A change resubscribes or refetches. resourceofuseGetOneLive,useGetManyLive,useGetLock,useGetLocks,useGetLockLiveanduseGetLocksLiveis a plain string, so those composables read one resource for the life of the component.- The values they return are refs:
statusandlastErrorofuseRealtimeStatus,lock,isLockingandlockErrorofuseLockOnMount, andisLoadinganderrorofuseLockanduseUnlock. Thelockandunlockof those two are plain functions. useSubscribereads its callback when it subscribes. A Vue callback closes over live refs, so it needs no latest-callback wrapper.
<script setup lang="ts">
import { useRealtimeStatus, useSubscribeToRecordList } from "@chelone/vue";
const { status } = useRealtimeStatus();
useSubscribeToRecordList("posts", (event) => {
console.log(event.type, event.payload);
});
</script>
<template>
<span>{{ status }}</span>
</template>Components
ListLive, ShowLive, EditLive, MenuLive, MenuLiveItemLink,
LockOnMount, LockStatus, PresenceBar and CommentsThread are exported from @chelone/vue with the same
props as their React twins. Slots stand in for the render props.
| Component | Slots |
|---|---|
ListLive, ShowLive, EditLive | The slots of List, Show and Edit, passed through, such as #actions and #toolbar. |
MenuLiveItemLink | #icon replaces the icon prop, for markup. |
LockOnMount | Default once the lock is held; #loading while it is requested; #locked-by="{ lock }" when another identity holds it. |
LockStatus | #lock="{ lock }" replaces the default badge; #free renders when the record is free. |
PresenceBar, CommentsThread | None. |
MenuLive takes items like the React component.
usePresence is a composable too. It takes the same options, each as a
MaybeRefOrGetter, and returns { users }, where users is a ref of the
users on the topic. Unlike the React hook, it does not filter out the current
user's other tabs; PresenceBar does, so filter by id in a custom display.
<script setup lang="ts">
import {
DataTable,
DataTableCol,
EditLive,
ListLive,
LockOnMount,
LockStatus,
TextInput,
} from "@chelone/vue";
</script>
<template>
<ListLive resource="posts">
<DataTable>
<DataTableCol source="title" />
</DataTable>
</ListLive>
<EditLive resource="posts">
<template #actions><LockStatus /></template>
<LockOnMount identity="you">
<template #locked-by="{ lock }">Held by {{ lock.identity }}</template>
<TextInput source="title" />
</LockOnMount>
</EditLive>
</template><script setup lang="ts">
import { EditLive, PresenceBar, TextInput } from "@chelone/vue";
</script>
<template>
<EditLive resource="posts">
<template #actions><PresenceBar /></template>
<TextInput source="title" />
</EditLive>
</template>EditLive holds events while the form has unsaved edits and replays one
refetch once it is saved or reset, as the React component does. The Vue demo
(apps/vue-demo/src/realtime.ts) wires it all with ListLive on the posts
list and LockOnMount inside EditLive on the edit page, so the lock
reads the record EditLive provides.
The transport contract
A transport is what connects clients. Only subscribe and publish are
required.
interface RealtimeTransport {
subscribe(topic, callback): Unsubscribe;
publish(topic, event): Promise<void>;
onReconnect?(callback): Unsubscribe;
onStatusChange?(callback: (status) => void): Unsubscribe;
getStatus?(): "connected" | "reconnecting" | "disconnected" | "idle";
onError?(callback: (error: RealtimeTransportError) => void): Unsubscribe;
}subscribecallscallbackwith the full event, topic included, and returns a function that stops it.onReconnectlets a view catch up on events it missed while offline. The live components and hooks refetch when it fires.onStatusChangeandgetStatusfeeduseRealtimeStatus. A transport withonStatusChangebut nogetStatusreads as"disconnected"until it speaks.onErrorreportsconnect_failed,auth_failed,send_failed,parse_failedandhandler_threw, each withretryingset.
Chelone ships transports for the browser, a WebSocket server, a server-sent
events server and Supabase, listed below. For any other service, write one that
implements the interface above with createTopicHub, see
Writing your own.
The in-memory transport
memoryTransport() routes events inside one process, with no server and no
library. Use it for tests and demos.
const transport = memoryTransport({ delayMs: 50, onError: console.error });
transport.publishedEvents; // every event published so far, in order
transport.setStatus("reconnecting");
transport.simulateReconnect(); // calls every onReconnect listenerdelayMswaits between recording a publish and delivering it.- A subscriber that throws goes to
onErrorashandler_threw; the other subscribers still run. - It starts as
"connected".
The browser transports
Three transports in @chelone/core need no library. Each returns a
RealtimeTransport with every optional member and a close(). Pass one to
realtimeDataProvider like memoryTransport.
| Transport | Reaches | Needs a server |
|---|---|---|
broadcastChannelTransport | The tabs of one browser | No |
webSocketTransport | Every client of a WebSocket server | Yes |
sseTransport | Every client of a server-sent events server | Yes, plus an endpoint to publish to |
For Supabase, see Supabase Realtime.
BroadcastChannel
broadcastChannelTransport shares events between the tabs of one browser
through a BroadcastChannel. Every tab that uses the same channel name joins.
import { broadcastChannelTransport, realtimeDataProvider } from "@chelone/core";
import { baseProvider } from "./baseProvider";
export const transport = broadcastChannelTransport({ channel: "admin" });
export const dataProvider = realtimeDataProvider(baseProvider, transport);BroadcastChannelreaches only tabs of one browser, and only for one origin. It does not reach another browser or another device.- A publish also reaches the subscribers of the tab that published, so a tab sees its own events.
- It throws on creation where the global
BroadcastChannelis missing. - It reports
"connected"untilclose().
WebSocket
webSocketTransport keeps one socket open and sends every topic through it.
import { realtimeDataProvider, webSocketTransport } from "@chelone/core";
import { baseProvider } from "./baseProvider";
import { getSession } from "./session";
export const transport = webSocketTransport({
url: "wss://api.example.com/realtime",
getAuthToken: async () => (await getSession())?.accessToken,
authMode: "subprotocol",
});
export const dataProvider = realtimeDataProvider(baseProvider, transport);The socket opens on the first subscribe or publish. It carries JSON text frames. The client sends:
{"op":"subscribe","topic":"resource/posts"}
{"op":"unsubscribe","topic":"resource/posts"}
{"op":"publish","topic":"resource/posts","event":{"type":"updated","payload":{"ids":[1]}}}
{"op":"ping"}The server sends an event as a frame with the shape of the event, and answers a
ping with a pong:
{"topic":"resource/posts","type":"updated","payload":{"ids":[1]},"meta":{}}
{"op":"pong"}topicandtypeare required strings.payloadandmetaare optional. A frame that is not text, not JSON or not an event goes toonErrorasparse_failed.- After a reconnect the transport sends
subscribeagain for every topic that still has a listener, so the server needs no memory of the old socket. - A
publishmade before the socket is open waits in a queue and is sent when it opens.close()rejects the ones still waiting. heartbeatMs(default 30000) sets how often apinggoes out. Apingwith no frame of any kind back by the next one counts as a dropped connection.idleDisconnectMs(default 30000) closes the socket once nothing is subscribed, and it opens again on the next subscribe.protocolslists the subprotocols offered on every connection.
getAuthToken runs before every connection, so a retry carries a fresh token.
authMode says where the token goes:
"query"(the default) adds atokenparameter to the URL. Server logs, proxy logs and browser history can keep a URL, and so can the token in it."subprotocol"offers the token as a subprotocol afterprotocols, and keeps it out of the URL. The server must accept that subprotocol in the handshake. Use it when the server can read theSec-WebSocket-Protocolheader.
Server-sent events
sseTransport reads a stream with EventSource and publishes with a POST.
import { realtimeDataProvider, sseTransport } from "@chelone/core";
import { baseProvider } from "./baseProvider";
import { getSession } from "./session";
export const transport = sseTransport({
url: "https://api.example.com/realtime/stream",
publishUrl: "https://api.example.com/realtime/publish",
getAuthToken: async () => (await getSession())?.accessToken,
});
export const dataProvider = realtimeDataProvider(baseProvider, transport);The stream opens on the first subscribe, and subscribes in the same tick share
one request. It is a GET on url with the topics in a topics query
parameter (rename it with topicFilterParam), comma separated:
/realtime/stream?topics=resource%2Fposts%2Clock%2Fposts&token=....
The server sends one named event per topic. The event name is the topic, and
data is JSON of { type, payload, meta }:
event: resource/posts
data: {"type":"updated","payload":{"ids":[1]}}typeis a required string;payloadandmetaare optional. The topic comes from the event name, sodatadoes not carry one. Data that is not JSON goes toonErrorasparse_failed; JSON without atypeis dropped.EventSourcecannot set headers, so the token is always in the stream URL astoken. It carries the same risk asauthMode: "query"above: use a short-lived token.- A
publishsends aPOSTtopublishUrlwith a JSON body of{ "topic": "...", "event": { "type": "...", ... } }and anAuthorization: Bearer <token>header. Any status outside 200 to 299 rejects it. WithoutpublishUrlapublishrejects. - Subscribing to a topic the open stream does not carry reopens it with the new topic added, which can take a moment. Unsubscribing from one leaves the stream as it is, and the server keeps sending that topic on it until the stream closes; events for a topic with no subscriber are dropped.
withCredentialsis passed toEventSourceand sends credentials on thePOST.fetchreplaces the global one for thePOST.idleDisconnectMs(default 30000) closes the stream once nothing is subscribed.
Reconnect and status
webSocketTransport and sseTransport retry a dropped connection with
exponential backoff. reconnect takes these options:
| Option | Default | Does |
|---|---|---|
enabled | true | false turns retries off. |
initialDelayMs | 1000 | The wait before the first retry; each retry doubles it. |
maxDelayMs | 30000 | The wait never grows past this. |
maxAttempts | Infinity | Retries before giving up. |
jitter | 0.3 | The share of each wait that is randomised, up or down. |
A connection that opens again resets the count. A reconnect calls the
onReconnect listeners, which refetch the live views. getStatus reports:
| Status | Means |
|---|---|
"idle" | Nothing is subscribed, so no connection is open. A subscribe opens one, and so does a webSocketTransport publish. A sseTransport publish only sends the POST. |
"connected" | The connection is open. |
"reconnecting" | The connection dropped and a retry is waiting or running. |
"disconnected" | close() was called, a retry gave up, retries are off, or the server closed the connection cleanly. |
A failed connection goes to onError as connect_failed with retrying set.
A token that cannot be read is auth_failed, a send that throws is
send_failed.
Closing
close() ends a transport and sets "disconnected". Call it when the app tears
the transport down, such as a test cleanup or a hot-reload dispose.
- On
broadcastChannelTransport, apublishafterclose()rejects. - On
webSocketTransportit closes the socket and rejects queued publishes. - On
sseTransportit closes the stream. - A later subscribe on a WebSocket or server-sent events transport opens a connection again.
Writing your own
For a service Chelone has no transport for, such as a hosted pub/sub client,
createTopicHub holds the parts every transport shares: the subscribers of each
topic, with a throwing subscriber isolated from the others, and the status,
reconnect and error listeners. Your transport connects to the service and calls
dispatch for each event it receives.
import { createTopicHub, type RealtimeTransport } from "@chelone/core";
export function myTransport(client: MyClient): RealtimeTransport {
const hub = createTopicHub();
client.onMessage((topic, event) => hub.dispatch({ topic, ...event }));
return {
subscribe: hub.subscribe,
publish: (topic, event) => client.send(topic, event),
onReconnect: hub.onReconnect,
onStatusChange: hub.onStatusChange,
getStatus: hub.getStatus,
onError: hub.onError,
};
}hub.setStatus, hub.reconnected and hub.report let the transport tell the
hub about the connection. hub.has(topic) and hub.topics() say what to
subscribe to on the service.
How events reach the query cache
An event invalidates queries. It does not patch them: the payload is never
written into the cache, so a view always shows what the data provider returns.
The payload.data of an updated event is for your own subscribers.
ListLive,ShowLive,EditLiveand theuse*Livehooks subscribe to a topic and, on an event, invalidate every query under the resource: lists, single records and references. Active queries refetch.- Lock queries are left out of that invalidation. They have their own subscribers on the lock topics.
- Events in the same tick fold into one invalidation, however many arrive.
- Events are held while an undoable mutation is pending, because a refetch would bring back its optimistic delete. They are replayed once it settles.
EditLiveholds events while the form has unsaved edits, and replays one invalidation once the form is saved or reset.- A reconnect invalidates the same queries, since events may have been missed.
Tips
- Create the transport, lock provider and data provider at module scope, next to
the
QueryClient, so they are stable across renders. - Publish from the server where you can.
addEventsForMutationsonly reports writes made through this client's provider.