Client
experimental-a2/client, experimental-a2/react, and experimental-a2/cache-indexeddb.
experimental-a2/client
createClient(options: {
contract: Contract // requires a public reducer
api: ClientApi
gcTime?: number // idle session lifetime; 5 minutes by default
participant?: string // default presence identity for every session
reconnect?: (failure: {
sessionId: string
error: A2ClientError | null
attempt: number
}) => number | false
}): A2Client
type ClientApi =
| SessionUrl // one route: GET SSE stream + POST push
| { type: 'http'; push: SessionUrl; stream: SessionUrl }
| { type: 'ws'; url: SessionUrl; multiplex?: false }
| { type: 'ws'; url: string; multiplex: true }
type SessionUrl = string | ((sessionId: string) => string)The framework-agnostic session client experimental-a2/react is built on: the
SSE subscription with frontier resume and reconnection, the optimistic push
queue with ack and rollback, and the local fold. Use it from any framework, or
none.
reconnect
- Runs after a failed connection or a stream end, including a clean end with
error: null. Not for an explicit restart, an idle-stream refresh, or a caller closing the stream. - Returns the delay in ms before the next attempt (finite, non-negative, at
most 2,147,483,647), or
falseto pause. Synchronous; a throw or invalid result pauses with anA2ClientError. attemptstarts at 1, counts consecutive attempts per session, and resets when the stream goes live orsession.reconnect()restarts it. It is separate from the lifetimereconnectscount.- Default: retry indefinitely, from 500 ms doubling up to five seconds.
- Paused:
connection.statusispausedwith its last error. State, hydration, optimistic writes, and leases stay intact. Neither a new lease nor a push resumes it (push retries are independent);session.reconnect()does, with a fresh attempt count. Releasing the last lease still closes the stream; a later first lease starts a new connection. Resuming keeps the stream's own position, even if hydration advanced state while paused.
SessionUrl functions run for each HTTP request or WebSocket connection;
A2 still adds its sessionId and read-bound query parameters. The function
does not authorize that ID: bind any path scope or URL token to
operation.sessionId in authorize, and keep URL tokens short-lived (URLs
reach infrastructure logs). A multiplexed WebSocket URL is a plain string. See
Transports.
client.session(id, options?)
client.session(id, { initial?, initialEvents?, participant? }) returns a
handle:
| Member | Does |
|---|---|
getSnapshot(), subscribe() | the useSyncExternalStore contract; snapshots carry state, events, index, history, hydrated, and connection, as in useSession |
push(), loadHistory(), reconnect() | as in useSession |
connect() | takes a stream lease and returns its release. Leases refcount per handle: the first connects, releasing the last closes, releasing twice is a no-op |
close() | hard stop: drops every lease and closes now; a later connect() starts fresh |
setPresence() | presence contracts only, as in useSession; snapshots then carry presence |
| Option | What it is |
|---|---|
initial | an InitialSnapshot<S>, as in React |
initialEvents | earlier raw events for history UIs; they merge into events and never affect the folded state or stream frontier |
participant | the identity setPresence sends under; overrides createClient's |
- One
A2Clientreturns the same live handle for repeatedsession(id)calls. A later call with a further server-rendered frontier advances it without dropping pending optimistic events; a stale render cannot move it backward. - Idle handles are evicted after
gcTime. This memory layer keeps identity across route transitions;experimental-a2/cache-indexeddbkeeps the replica across reloads. - A pending
initialPromise delays cold connection startup, including a connection a push acquires, and starts no request of its own. Replacing the source orclose()invalidates its pending result and any deferred adoption behind a write. - Releasing the last lease does not cancel the caller's Promise; it can still seed cached data without reopening transport. Idle eviction detaches A2's listener. Repeating the same Promise in that cached runtime does not restart cancelled work.
experimental-a2/react
createReact(options)
createReact(options: {
client: A2Client // bind a shared client: api + contract + session identity
}): { SessionProvider, useSession }
// or, bind the contract and supply api through the Provider:
createReact(options: {
contract: Contract // requires a public reducer
}): { SessionProvider, useSession }A factory, like createContext: call it once in a 'use client' module and
export the bound pair. Server components import SessionProvider from it as a
client reference; client components import useSession from the same file.
Bind a shared client when code outside the provider pushes before a route
transition and the destination should adopt the same optimistic session;
{ contract } suits provider-bound clients. See
Bind React once.
<SessionProvider>
| Prop | What it is |
|---|---|
sessionId | which session to subscribe to |
initial | optional snapshot or Promise of a snapshot; null means bootstrap |
initialEvents | optional earlier raw events for a history UI |
participant | overrides the client's participant; one of the two is required to call setPresence |
- Supplies scope and optional initial data. Mounted enabled hooks own connection leases and reconnect from the last streamed event.
participantbinds at the session's first resolution; later changes are ignored for the session's runtime lifetime.initialis the complete state/index snapshot, plusclearIndexonce the session has been cleared. A new session streams from afterinitial.index, including events appended between render and connection. Later snapshots advance state but keep the resume position, so delayed events still enter the feed without folding twice.initialEventsonly preloads the raweventsfeed with entries before that frontier; it never affects state hydration or stream resumption.
InitialSnapshot<S> is StateSnapshot<S> | null | undefined | Promise<StateSnapshot<S> | null | undefined>, exported from experimental-a2
and experimental-a2/client. A cold session waits for a supplied Promise
before connecting. A snapshot seeds state; null or undefined selects
subscription bootstrap. A rejection pauses cold startup and reports through
connection.error; supply a new Promise, snapshot, or null to resume. A warm
session keeps its state and connection. The Provider never suspends. Keep
Promise identity stable. See
Seed the first render.
useSession(options?)
const { state, hydrated, push, reconnect, events, index, loadHistory, history, connection } = useSession()| Option | Default | What it does |
|---|---|---|
sessionId, client, participant, initial | from the Provider and bound client | scope and seed |
enabled | true | owns a connection lease. false defers automatic I/O: the hook observes cached state, other owners continue, and explicit pushes can still acquire a connection |
suspend | false | true reads the initial Promise with React use(), through the nearest Suspense and Error boundaries. Otherwise a pending cold Promise renders the reducer seed. Automatic bootstrap never suspends, even when the Promise resolves to null or undefined |
| Member | What it is |
|---|---|
state | the reducer's client state type. A supplied snapshot seeds the first render; otherwise reducer.initialState (through the client projection) shows until a snapshot replaces it atomically. Folds live events through the shared reducer |
hydrated | true once an initial snapshot or bootstrap applies, even an empty state at index 0; false while only the reducer seed exists. Stays true through reconnects, disabled hooks, and a failed replacement preload. Not a sign of a live connection or fresh data. Shared with session.getSnapshot() |
events | the raw feed: observed, seeded, or backscrolled. Starts after initial.index unless initialEvents or loadHistory add earlier entries |
index | the latest server-confirmed state index, the lastSeenIndex for cancellation |
push, reconnect, loadHistory, history, connection | below |
presence, setPresence | presence contracts only; below |
Without a supplied snapshot, the server sends one first on the same
subscription; bootstrap errors and retries use connection.
loadHistory({ before?, limit? }) fetches a bounded slice below the
frontier (same route, gte/lte query parameters) and resolves with the
events in that range.
- Merges into
events: deduped, ordered, shared by every handle of the session. Never touchesstateor the optimistic overlay. - Defaults walk back 50 at a time from the oldest loaded event, also after a
newer snapshot hydrates.
beforeis an exclusive upper bound for another range. - Calls serialize per session; loaded ranges are not refetched.
historyis{ loading, complete, oldestLoaded }.complete: the feed reaches index 1, or the first event after the latest clear, or the log is empty.- The
wsapi has no history lane:loadHistorythrows aTypeError.
Clears reset state to the reducer's initial state and drop events at or
below the marker. Unconfirmed pushes stay in the overlay and apply to the reset
state. A push acked below the marker was cleared with it: it leaves the overlay
and its confirmed resolves.
push appends optimistically: validated locally, rolled back on rejection,
retried for STORE_UNAVAILABLE and transport failures. Awaiting it gives the
server ack; its confirmed is a lazy promise for when the stream has delivered
the batch back:
const t0 = performance.now()
const result = push({ type: 'created', payload })
await result // the server ack: push→ack latency
await result.confirmed // the stream round-trip: ack→stream latency
const roundtripMs = performance.now() - t0confirmedwaits for the stream to deliver through the acked index. A bootstrap snapshot, or a stream starting after a supplied initial index, delivers through that index; hydration does not otherwise confirm an unobserved ack.- A push never waits for bootstrap, and an HTTP push never waits for a pending initial Promise. A WebSocket push rides its connection, which opens once that Promise settles.
- Overlapping calls on one session client update the view immediately and enter the transport in invocation order, each waiting only for the previous call's ack or final rejection. Other sessions and clients run concurrently and converge on the log's durable order.
- A rejection rejects both promises with the same
A2ClientError.confirmedis materialized only when accessed, so ignoring it costs nothing.
reconnect (session.reconnect(): void) restarts a leased subscription
now from its current cursor, including one the policy paused. It keeps state,
pending writes, and leases, and does nothing without an active lease. It
requests a restart; it returns no promise for catch-up. Use it after a custom
REST mutation. A multiplexed WebSocket keeps its socket and other session
lanes; a single-session WebSocket replaces its socket.
connection is a discriminated union; error only exists while
disconnected:
// in a client module:
import type { A2ClientError } from 'experimental-a2/client'
type Connection =
| { status: 'idle' }
| { status: 'connecting'; reconnects: number; error: A2ClientError | null }
| { status: 'paused'; reconnects: number; error: A2ClientError | null }
| { status: 'live'; reconnects: number }
| { status: 'closed' }"Reconnecting…" is status === 'connecting' && reconnects > 0. live means
bytes are flowing: the server heartbeats the stream (: ping every 15s), and a
client that hears nothing for two beats aborts and reconnects.
presence is the replicated ephemeral map,
Record<participantId, { [field]: { value, seen, at } }>, including this
client. setPresence(values) is fire-and-forget:
- Validated locally and merged into the local map immediately.
- Coalesced on the wire to a fixed cadence: a leading send, then at most one merged send per interval, later values winning per field.
- Stamped with the frontier at send time as
seen. - No ack, no
confirmed, no retry. Parallel sends are safe: merges are last-writer-wins byat, and stamps are monotonic per session (wall-seeded, guarded so overlapping sends never invert).
Both exist only when the contract declares presence, typed from its schemas
through the reducer with no type arguments (the reducer never folds presence).
setPresence requires a participant, from the client or the provider. See
Presence.
useSession(client, sessionId, options?)
useSession(client: A2Client, sessionId: string, options?: {
participant?: string // overrides the client's participant
initial?: InitialSnapshot<S>
enabled?: boolean
suspend?: boolean
}): UseSessionResultThe provider-less hook, for components that reach sessions ad hoc (a sidebar of channel sessions, a user session read from a menu). Same result as the bound hook.
- Identity-mapped: every hook and provider mounting a session shares one runtime. The stream is refcounted: the first mount connects, the last unmount closes (StrictMode-safe).
initialtakes the result ofsession.state(reducer)or its Promise. Pass it whole: itsclearIndexlets a cached client drop history from before a clear it never streamed (the automatic snapshot carries it too). Without it, the subscription obtains a snapshot. For deliberate full replay, supply{ state: reducer.initialState, index: 0 }.enabled: falsedefers automatic I/O, as above.
See React.
experimental-a2/cache-indexeddb
indexedDb(): A2ClientCacheA browser-side cache for SessionProvider: the session's events (immutable;
writes are idempotent by index), a folded snapshot (invalidated by a reducer
rename), and the pending-push queue. cache.clear(sessionId?) is the logout
story. Create instances in client code; they're live objects, not serializable
props. See Client lifetime.