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 false to pause. Synchronous; a throw or invalid result pauses with an A2ClientError.
  • attempt starts at 1, counts consecutive attempts per session, and resets when the stream goes live or session.reconnect() restarts it. It is separate from the lifetime reconnects count.
  • Default: retry indefinitely, from 500 ms doubling up to five seconds.
  • Paused: connection.status is paused with 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:

MemberDoes
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
OptionWhat it is
initialan InitialSnapshot<S>, as in React
initialEventsearlier raw events for history UIs; they merge into events and never affect the folded state or stream frontier
participantthe identity setPresence sends under; overrides createClient's
  • One A2Client returns the same live handle for repeated session(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-indexeddb keeps the replica across reloads.
  • A pending initial Promise delays cold connection startup, including a connection a push acquires, and starts no request of its own. Replacing the source or close() 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>

PropWhat it is
sessionIdwhich session to subscribe to
initialoptional snapshot or Promise of a snapshot; null means bootstrap
initialEventsoptional earlier raw events for a history UI
participantoverrides 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.
  • participant binds at the session's first resolution; later changes are ignored for the session's runtime lifetime.
  • initial is the complete state/index snapshot, plus clearIndex once the session has been cleared. A new session streams from after initial.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.
  • initialEvents only preloads the raw events feed 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()
OptionDefaultWhat it does
sessionId, client, participant, initialfrom the Provider and bound clientscope and seed
enabledtrueowns a connection lease. false defers automatic I/O: the hook observes cached state, other owners continue, and explicit pushes can still acquire a connection
suspendfalsetrue 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
MemberWhat it is
statethe 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
hydratedtrue 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()
eventsthe raw feed: observed, seeded, or backscrolled. Starts after initial.index unless initialEvents or loadHistory add earlier entries
indexthe latest server-confirmed state index, the lastSeenIndex for cancellation
push, reconnect, loadHistory, history, connectionbelow
presence, setPresencepresence 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 touches state or the optimistic overlay.
  • Defaults walk back 50 at a time from the oldest loaded event, also after a newer snapshot hydrates. before is an exclusive upper bound for another range.
  • Calls serialize per session; loaded ranges are not refetched.
  • history is { loading, complete, oldestLoaded }. complete: the feed reaches index 1, or the first event after the latest clear, or the log is empty.
  • The ws api has no history lane: loadHistory throws a TypeError.

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() - t0
  • confirmed waits 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. confirmed is 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 by at, 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
}): UseSessionResult

The 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).
  • initial takes the result of session.state(reducer) or its Promise. Pass it whole: its clearIndex lets 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: false defers automatic I/O, as above.

See React.

experimental-a2/cache-indexeddb

indexedDb(): A2ClientCache

A 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.