Signatures quoted from source. Usage: Consume a Statewire, Host a Statewire. Wire: protocol spec. Related: Statewire bindings, Python Statewire.
Entry points
| Import from | Exports |
|---|---|
statewire | Statewire types, useStatewire, StatewireResource, StatewireClient, StatewireHttp, StatewireWebsocket, StatewireSendError |
statewire/host | useStatewireState, useStatewireCommands, append, getStateHost, StatewireReject, StatewireHost |
statewire/host-internal | StatewireSocketHost, WS_SUBPROTOCOL, clientIdFromUrl, textFrameOrClose, StatewireHostInternal |
statewire/testing | createMockTransport, tick, settle, applyOptimistic, createCounterElement, commandFrame, createSocketRecorder |
statewire/storage | KeyValueSessionStorage, MemorySessionStorage, IndexedDbSession, indexedDbBackend, readOutboxes, subscribeOutboxes, ReadOutboxes, sessionNamespace, STORAGE_FORMAT (6) |
| binding and runtime packages | Statewire bindings |
statewire
Statewire
interface Statewire<TState = any, TCommands extends Statewire.Commands = Statewire.Commands> {
state: TState; commands: Statewire.CommandsProxy<TCommands>;
connection?: StatewireClient.Connection; source?: Statewire.StateSource;
isBusy?: boolean; setContext?: (protocol: string, value: Statewire.Context) => void;
/** Host side: the read filter for each document entering an attach's view. */
filter?: StatewireHost.FilterDecider;
/** Present on transports with a durable session. */
outbox?: {
list: () => readonly StatewireClient.OutboxEntry[]; clientId: () => string;
hold: StatewireClient.Outbox["hold"]; release: StatewireClient.Outbox["release"]; remove: StatewireClient.Outbox["remove"];
update: StatewireClient.Outbox["update"]; insert: StatewireClient.Outbox["insert"];
};
}The transport contract every transport: element resolves to (type-only export).
Statewire.* | Shape |
|---|---|
Context | Readonly<Record<string, unknown>> |
Commands | Record<string, (...args: never[]) => unknown> |
Command<TCommands> | { method: K; params: Parameters<TCommands[K]> } union over K |
CommandsProxy<TCommands> | { [K]: (...args: Parameters<TCommands[K]>) => Promise<Awaited<ReturnType<TCommands[K]>>> } |
CommandUpdate<TCommands> | { readonly protocol?: string; id?: string; method: K; params; seq?: number; status: "queued" | "posted" | "applied" | "settled"; result?: unknown; failure?: StatewireClient.SendFailure; localMeta?: unknown }; seq is absent while queued in the outbox |
StateSource | { snapshot: () => unknown; takeChanged: () => StatewirePath[]; setOnChange: (listener: (() => void) | null) => void } |
Op | { op: "replace"; path; value } | { op: "add"; path; value } | { op: "remove"; path }, path: readonly (string | number)[] |
Changes | { isChangedAt(path): boolean; getChangedKeys(path): string[] } |
Connection
type ConnectionState =
| { status: "standby"; reason: "deferred" | "idle"; degraded: false }
| { status: "connecting"; degraded: false }
| { status: "connected"; degraded: false }
| { status: "retrying"; degraded: true; attempt: number; cause?: "dropped" | "reconnect" | "error" | "unauthorized"; code?: string; nextRetryAt?: number; lastError?: string; message?: string }
| { status: "stopped"; degraded: true; reason: "gone" | "forbidden" | "unauthorized" | "protocol-error" | "storage-error"; message?: string; payload?: unknown };
type Connection = ConnectionState & { reconnect: () => void; error?: ConnectionError };
type ConnectionStatus = Connection["status"];connection.degraded: show a connection problem indicator.connection.status === "stopped": automatic recovery has ended.connection.status === "retrying": a recovery attempt is waiting or dialing after a scheduled wait.- The first redial after a failed dial or a drop is immediate and is part of the current dial: the status stays
connecting(or movesconnected→connecting) anddegradedstaysfalse.retryingappears once a wait is scheduled, so the firstretryingisattempt: 2unless the server's retry hint delays attempt 1. - Redial
n ≥ 2waits at leastmin(5 s, 1 s · 2^(n-2)), stretched by up to 50 % jitter and never shortened: 1, 2, 4, 5 s. The attempt counter resets after 30 s of a healthy connection. A server retry hint, capped at 5 s, is a lower bound on every wait. - Coming back redials a
connectedattach at once:online, a documentresume, a back-forward cachepageshow, the page shown after 30 s or more hidden, or, over WebSocket, a clock tick (read every second) over 5 s late on a shown page. - Any wake (also
focus,pageshow, the page shown again) redials aretryingconnection (no sooner than a host retry hint allows), or aconnectingone dialing for 5 s or more, at once. - An attempt ends when its silence deadline passes or
reconnect()is called, without waiting for the socket's close. - Backoff fields (
cause,code,nextRetryAt,lastError,message) are present while waiting and absent during the dial. reconnect()starts a fresh connection attempt fromstandbyorstopped.
useStatewire, StatewireResource
const useStatewire: <TState, TCommands extends Statewire.Commands = Statewire.Commands>(options: StatewireClient.Options<TState, TCommands>) => StatewireClient.Helpers<TState, TCommands>;
const StatewireResource: <TState, TCommands extends Statewire.Commands = Statewire.Commands>(options: StatewireClient.Options<TState, TCommands>) => ResourceElement<StatewireClient.Helpers<TState, TCommands>>;React hook over a transport element (state, commands, connection, outbox), and the same hook as a tap resource element. A key change remounts a fresh session.
StatewireClient
class StatewireClient<TState, TCommands extends Statewire.Commands = Statewire.Commands> {
constructor(options: StatewireClient.Options<TState, TCommands>);
get state(): TState; get connection(): StatewireClient.Connection; get commands(): Statewire.CommandsProxy<TCommands>; get isBusy(): boolean;
setContext(protocol: string, value: Statewire.Context): void;
get outbox(): StatewireClient.Outbox;
invoke(protocol: string, method: string, params: readonly unknown[], options?: StatewireClient.InvokeOptions): Promise<unknown>;
subscribe(listener: () => void): () => void; dispose(): void;
}Framework-agnostic client over the same core; Helpers fields as getters.
isNew starts at standby with reason: "deferred"cause preserves the diagnosis; the wait grows with attemptfin: idlereason preserves the diagnosis; reconnect() re-armsstatus | cause / reason | Meaning |
|---|---|---|
"connecting" | — | A dial, including its one immediate redial |
"retrying" | cause: "dropped" | An interrupted stream or failed dial is being retried |
"retrying" | cause: "reconnect" | Server-directed reconnect; code may identify an eviction |
"retrying" | cause: "error" | A retryable server error |
"retrying" | cause: "unauthorized" | One credential-refresh redial with staleAuth: true |
"connected" | — | The snapshot arrived |
"standby" | reason: "deferred" | Waiting for the first send or reconnect() |
"standby" | reason: "idle" | The host finished idle; the next send or reconnect() re-attaches |
"stopped" | reason: "gone" | The host finished the session |
"stopped" | reason: "forbidden" | Permission was denied |
"stopped" | reason: "unauthorized" | Authentication failed after the credential-refresh attempt |
"stopped" | reason: "protocol-error" | Invalid packets or incompatible protocol negotiation |
"stopped" | reason: "storage-error" | Session hydration or persistence failed; outgoing traffic is blocked |
- Held state remains renderable during retries.
- Reconnects never re-send a sequence number: answers for admitted commands ride the hello, commands that never arrived get new sequence numbers through the outbox.
- Terminal failures settle pending commands and preserve the fin's
messageandpayload. - WebSocket hosts send the fin as a text frame before closing. Implementations that discard it can only report a dropped connection.
StatewireClient.Options<TState, TCommands>
| Name | Type | Default | Meaning |
|---|---|---|---|
transport | ResourceElement<Statewire<TState, TCommands>> | required | Transport element the client mounts |
storage? | { session?: ResourceElement<SessionStorage> } | — | Durable session: holds the lane identity and un-durable commands, resent on the next attach |
context? | Readonly<Record<string, Statewire.Context>> | — | Initial context objects keyed by protocol |
initialState? | TState | — | Canonical state until the attach snapshot; own commands fold over it |
optimistic? | Optimistic<NonNullable<TState>, TCommands> | — | Folds each unsettled command into the draft; re-runs on every rebase |
materialize? | Materialize<NonNullable<TState>> | — | Folds client-known facts into every published state; runs after the optimistic folds |
materializeKey? | unknown | — | Republishes through materialize when it changes (Object.is); materialize is read fresh on every publish |
onStateChange? | (state: NonNullable<TState>, ctx: { changes: Statewire.Changes }) => void | — | Observes each published state with its changed paths |
onCommandChange? | (update: Statewire.CommandUpdate<TCommands>, ctx: { state: NonNullable<TState> }) => unknown | — | Observes each command lifecycle step; durable settled updates are acked once the returned promise resolves (Settled updates) |
onConnectionChange? | (connection: Connection) => void | — | Observes connection transitions |
canOvertake? | Readonly<Record<string, CanOvertake>> | — | Per protocol: lets an outbox entry move ahead of held or waiting entries; the transport's own canOvertake wins |
StatewireClient.Helpers<TState, TCommands>
| Name | Type | Meaning |
|---|---|---|
state | TState | Remote statewires include undefined until the first attach snapshot applies |
getState | () => TState | Identity-stable; reads the canonical state without subscribing |
connection | Connection | Current connection state plus reconnect and degraded |
commands | Statewire.CommandsProxy<TCommands> | Typed command proxy |
isBusy | boolean | Negation of the server's broadcast idle level; a transport without idle is always busy |
setContext | (protocol: string, value: Statewire.Context) => void | Replaces the protocol's lease context; restores its latest value on every attachment |
outbox | Outbox | Unsettled commands in queue order; on a transport without a session the outbox is empty and its methods throw |
invoke | (protocol: string, method: string, params: readonly unknown[], options?: InvokeOptions) => Promise<unknown> | Sends a command on a negotiated protocol; options.hold enqueues it held, options.localMeta persists with it |
Outbox
A command waits in the outbox, without a sequence number, until a flush stamps it. Flushes run on every enqueue, hold, release, remove and attach, under the storage lock, so exactly one sharer stamps each entry.
type Outbox = {
readonly entries: readonly OutboxEntry[];
hold: (id: string) => void;
release: (id: string) => Promise<unknown>;
remove: (id: string) => void;
update: (id: string, fn: (entry: OutboxEntry) => OutboxPatch) => void;
insert: (entry: OutboxInsert) => string;
};
type OutboxState = "queued" | "held" | "sending";
type OutboxEntry = {
readonly id: string;
readonly protocol: string;
readonly command: { readonly method: string; readonly params: readonly unknown[] };
readonly ts: number;
readonly state: OutboxState;
readonly localMeta: unknown;
readonly durable: boolean;
};
type OutboxPatch = { held?: boolean; localMeta?: unknown; command?: { params: readonly unknown[] } };
type OutboxInsert = {
from?: string;
protocol: string;
command: { method: string; params: readonly unknown[] };
held?: boolean;
localMeta?: unknown;
after?: string;
before?: string;
};
type FlushReason = "enqueue" | "reconnect" | "reload" | "release";
type OnFlush = (flush: { commands: readonly OutboxEntry[]; reason: FlushReason }) => void;
type CanOvertake = (entry: OutboxEntry, ahead: OutboxEntry) => boolean;
type InvokeOptions = { hold?: boolean; localMeta?: unknown };entrieslists every unsettled command across sharers, per lane in queue order: stamped commands ("sending") by seq, then the outbox's"queued"and"held"entries.idis the client id the command keeps from enqueue to verdict.tsis the enqueue time.durableisfalsewhen the entry lives in page memory only.localMetais client-only: persisted with the entry, never sent.invoke(..., { localMeta }),updateandinsertset it.hold(id):"queued"→"held". No flush stamps a held entry until it is released. The hold persists in the session record; every sharer sees it and it survives reloads.release(id):"held"→"queued". Works from any sharer; the promise settles with the command's outcome.remove(id)drops a queued or held entry; its pending send rejects{ fate: "aborted", cause: "removed" }.update(id, fn)runsfnon the current entry under the storage lock and applies the returned patch:held,localMeta, orcommand.params. Any othercommandfield throwsoutbox: a patch may only replace command.params.insert(entry)adds a command no send awaits, at the lane's tail or next toafter/before, and returns its id.afterandbeforetogether throw.fromnames thesettledupdate the insert re-queues: the same write consumes the update, so a redelivery never inserts twice (Settled updates).- Every verb throws
outbox: no entry <id>for an unknown id andoutbox: <id> is in flightfor a"sending"entry.insertthrows the same for an unknown or in-flightafter/beforeanchor. - Inside
onFlush, verbs on the offered entries apply before stamping.
FlushReason | The entry |
|---|---|
"enqueue" | Was queued since the last flush |
"reconnect" | Had a sequence number above the hello watermark, which was freed. A stamp taken over from a closed sharer, and the stamps below it, keep theirs: a frame of that sharer may still land |
"reload" | Was queued before this client mounted |
"release" | Had its hold released |
Ordering: each entry moves toward the front past held or waiting entries it may overtake (canOvertake(entry, ahead)) and stops behind the first it can't. Entries with nothing held or waiting ahead are stamped, in the reordered order, which the session record keeps. Without canOvertake, a held entry blocks every later entry on its protocol.
Optimistic folds run in queue order: this client's "sending" commands, then every sharer's "queued" and "held" entries, each with ctx.state and ctx.localMeta. A non-accepted settle drops the command from the outbox and the fold, and rejects its promise.
Settled updates
queued, posted and applied updates are in memory and best-effort. A settled update of a stamped command on a durable lane is persisted to the lane's inbox in the same write that drops the stamp, so a reload either resends the command or delivers the update.
- Delivered through
onCommandChange, one sharer at a time underSessionStorage.notifyLock. - Delivery starts after the first hello, so
ctx.stateis the attached state. - At-least-once under the command's
id; acked once the handler's returned promise resolves. - A pending delivery blocks later ones for the same session.
- A handler that throws leaves the update for the next mount.
- Updates carry the command's
localMeta. - Each lane keeps the newest 256 undelivered updates; older ones are dropped and reported through
SessionStorage.onEvictwith reason"inbox". - An update whose payload storage dropped to fit its byte budget carries
compacted: true,params: []and noresult;id,seq,ts,failureandlocalMetaremain.
Re-queue a failed command held, for the user to edit, release or remove:
const client = new StatewireClient({
transport: StatewireHttp({ url, sessionStore }),
onCommandChange: (update) => {
if (update.status !== "settled" || update.failure === undefined) return;
client.outbox.insert({
from: update.id,
protocol: update.protocol!,
command: { method: update.method, params: update.params },
held: true,
localMeta: { error: update.failure },
});
},
});Session loss
A proven record gap (a gap refusal) drops the lane's whole queue: every stamped and outbox command settles { fate: "lost", cause: "session-lost" } and becomes a settled update. The lane clock restarts at the watermark.
With unshared session storage, a gap refusal at or above every seq the lane saw answered since the hello, whose stamps above the watermark were all first sent on the current attach or never sent, loses nothing: the stamps go back to the front of the outbox and the lane clock restarts at the watermark.
An ephemeral-lane gap refusal loses nothing: the refused frame's statements go out again after the ones still in flight.
onSessionLost returning "requeue" instead re-enqueues this client's lost stamps with fresh ids at the tail and keeps the outbox. It is temporary and discouraged, and will be removed. Each requeue calls onDeprecatedPath({ kind: "session-lost-requeue", count }), or console.warn without it.
Failures
type SendFailure =
| { fate: "rejected"; cause: "server"; code?: string; message?: string; payload?: unknown }
| { fate: "failed"; cause: "server"; message?: string }
| { fate: "crashed"; cause: "server" }
| { fate: "expired"; cause: "server" }
| { fate: "aborted"; cause: "unmount" | "stream-gone" | "session-lost" | "removed" | "quota"; predecessorLost?: true }
| { fate: "lost"; cause: "unmount" | "stream-gone" | "session-lost" };cause: "server" is the server's answer; the rest settle locally. "quota": a session storage limit refused the write that would add the command. Host-minted code values: unknown-command, invalid-params. Per-fate meaning and resend safety: failure and retry.
StatewireClient.* | Shape |
|---|---|
Optimistic<TState, TCommands> | (draft: TState, command: Statewire.Command<TCommands>, ctx: OptimisticContext) => void |
OptimisticContext | { ts: number; seq?: number; clientId?: string; state: OutboxState; localMeta: unknown }; ts is the enqueue time; seq is absent while the command waits in the outbox |
Materialize<TState> | (draft: TState) => void |
OnSessionLost | (pending: ReadonlyArray<{ id: string; method: string; params: readonly unknown[]; ts: number; seq: number; clientId?: string }>) => "requeue" | "drop"; temporary, see Session loss |
OnDeprecatedPath | (event: { kind: "session-lost-requeue"; count: number }) => void |
OutboxCommand | { id: string; method: string; params: unknown[]; meta?: unknown; ts: number; held?: true; localMeta?: unknown; released?: true; by?: string }; meta encodes non-JSON params; by is the enqueuing sharer's owner id |
SessionCommand | { id: string; seq: number; owner?: string; by?: string; method: string; params: unknown[]; meta?: unknown; ts: number; localMeta?: unknown }; owner is the stamping sharer's SessionStorage.owner.id |
SessionRecord | { clientId: string; lanes: Readonly<Record<string, SessionLane>> } |
SessionLane | commands keeps each stamp until its owner settles it. { nextSeq: number; commands: readonly SessionCommand[]; outbox?: readonly OutboxCommand[]; removed?: readonly { id: string; by: string; failure?: SendFailure }[]; verdicts?: readonly SessionVerdict[]; inbox?: readonly StoredSettled[] }; removed keeps entries another sharer settled unsent until their enqueuer rejects them (no failure means removed); inbox holds settled updates awaiting their ack |
StoredSettled | { id: string; method: string; params: unknown[]; meta?: unknown; ts: number; seq?: number; localMeta?: unknown; result?: { value: unknown[]; meta?: unknown }; failure?: SendFailure; compacted?: true }; result.value is the accepted result encoded as a one-element param list; compacted marks an update whose params, meta and result storage dropped (params: [], result: { value: [] }) |
SessionVerdict | { seq: number; id?: string; type: "result" | "rejected" | "failed" | "crashed" | "expired"; code?: string; message?: string; payload?: unknown; meta?: unknown } |
SessionStorage | { load: () => SessionRecord | null; save: (record: SessionRecord) => void; lock?: <T>(fn: () => T | Promise<T>) => Promise<T>; subscribe?: (listener: () => void) => () => void }; save updates the cached record synchronously; asynchronous adapters provide ready?: Promise<void> and flush?: () => void | Promise<void>; read?: () => Promise<SessionRecord | null> re-reads the stored record and is awaited under lock; durable?: boolean is false for page-memory storage; lock marks the storage shared and requires subscribe, owner: { id: string; whenGone: (id: string, signal: AbortSignal) => Promise<void> } and notifyLock?: <T>(fn: () => Promise<T>) => Promise<T>; a sharer takes over the stamps of an owner once whenGone resolves and reattaches, so the hello watermark settles them; notifyLock is held while a durable settled update is delivered; onEvict?: (event: Omit<EvictEvent, "key">) => void reports dropped settled updates |
EvictEvent | { key: string; reason: "expired" | "overflow" | "quota" | "inbox" | "corrupt"; dropped: readonly Statewire.CommandUpdate[] }; dropped are the undelivered settled updates and unsent commands that went with it (unsent ones as settled with { fate: "lost", cause: "session-lost" } when stamped, { fate: "aborted", cause: "removed" } when queued) |
HeadersOption | HeadersInit | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>); staleAuth is true right after an auth rejection |
StatewireClient.HttpOptions
| Name | Type | Default | Meaning |
|---|---|---|---|
url | string | required | Base URL; attaches at POST {url}/stream, submits at POST {url}/frames |
sessionStore? | SessionStorage | — | Session storage for the lane identity and un-durable commands |
isNew? | boolean | — | The session does not exist on the server yet: the first attach waits for the first command send or an explicit reconnect() |
headers? | HeadersOption | — | Static headers or a per-request resolver |
fetch? | typeof globalThis.fetch | — | Fetch implementation |
onSessionLost? | OnSessionLost | — | Temporary: "requeue" keeps the 0.23 requeue on session loss (Session loss) |
onDeprecatedPath? | OnDeprecatedPath | — | Reports each deprecated code path that fires; without it, console.warn |
onFlush? | OnFlush | — | Runs in the sharer about to stamp, once per reason, before stamping |
canOvertake? | Readonly<Record<string, CanOvertake>> | — | Per protocol: lets an outbox entry move ahead of held or waiting entries |
onChange? | (connection: Connection) => void | — | Connection transitions |
onDone? | () => void | — | The broadcast idle flag's rising edge |
StatewireClient.WebsocketOptions
| Name | Type | Default | Meaning |
|---|---|---|---|
url | string | required | Base URL; connects at {url}/ws?client=<id>, http/https rewritten to ws/wss, with subprotocol "statewire.v1" |
sessionStore? | SessionStorage | — | Session storage for the lane identity and un-durable commands |
isNew? | boolean | — | The session does not exist on the server yet: the first attach waits for the first command send or an explicit reconnect() |
headers? | HeadersOption | — | Static headers or a per-attach resolver |
protocols? | string | string[] | — | Extra WebSocket subprotocols |
query? | Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>) | — | Query parameters, static or resolved per attach |
webSocket? | new (url: string, protocols?: string | string[]) => WebSocket | — | WebSocket constructor |
onSessionLost? | OnSessionLost | — | Temporary: "requeue" keeps the 0.23 requeue on session loss (Session loss) |
onDeprecatedPath? | OnDeprecatedPath | — | Reports each deprecated code path that fires; without it, console.warn |
onFlush? | OnFlush | — | Runs in the sharer about to stamp, once per reason, before stamping |
canOvertake? | Readonly<Record<string, CanOvertake>> | — | Per protocol: lets an outbox entry move ahead of held or waiting entries |
onChange? | (connection: Connection) => void | — | Connection transitions |
onDone? | () => void | — | The broadcast idle flag's rising edge |
KeyValueSessionStorage
import { KeyValueSessionStorage } from "statewire/storage";
const storage = KeyValueSessionStorage({
backend: myStorage,
namespace: "my-app:sessions:",
principal: userId,
key: threadId,
retention: { maxAgeMs: 30 * 24 * 60 * 60 * 1000, maxEntries: 50 },
limits: { outboxEntries: 100 },
onEvict: ({ key, reason, dropped }) => report(key, reason, dropped),
});namespace KeyValueSessionStorage {
type Backend = {
getItem: (key: string) => string | null | Promise<string | null>;
setItem: (key: string, value: string) => void | Promise<void>;
removeItem: (key: string) => void | Promise<void>;
getAllKeys?: () => readonly string[] | Promise<readonly string[]>;
withUnused?: (key: string, action: () => Promise<void>) => Promise<void>;
subscribe?: (listener: (key: string) => void) => () => void;
};
type Codec = { encode: (plain: string) => Promise<string>; decode: (stored: string) => Promise<string> };
type EvictEvent = StatewireClient.EvictEvent;
type Options = {
backend: Backend;
key: string;
namespace?: string;
principal: string;
protocol?: string;
migrate?: StatewireClient.SessionStorage["migrate"];
retention?: { maxAgeMs?: number; maxEntries?: number };
limits?: { outboxEntries?: number; recordBytes?: number; totalBytes?: number };
codec?: Codec;
onError?: (error: unknown) => void;
onEvict?: (event: EvictEvent) => void;
};
}const sessionNamespace: (namespace: string, principal: string) => string;- Mount with
storage: { session: storage }inStatewireClientoruseStatewire. principal: the signed-in account, non-empty. The record is stored under<namespace><encoded-principal>:<key>;sessionNamespace(namespace, principal)returns that prefix. Changingprincipalon a mounted resource throws; remount the session per account.- Each record names its
principal. Loading a record that names another principal fails withstorage-errorand leaves the record in place: its queued commands are not sent and itssettledupdates are not delivered. backend: each method may return synchronously or through a promise.getAllKeys()enables retention andtotalByteswithinnamespace.withUnusedruns cleanup under exclusive access, skipping keys another runtime uses.subscribereports keys other runtimes wrote.protocol: the lane that sessions saved in a single-lane format lift into; defaults to the client's default protocol.codectransforms the stored string (encryption, compression). With a codec, hydration is asynchronous.- Hydration completes before attaching. Command persistence completes before network submission.
- Records are written as storage format 6 (
STORAGE_FORMAT), which addsprincipal. Formats 1 to 5 upgrade on load and belong to the principal whose key holds them: a format 4hold: { meta }becomesheld: truewithlocalMeta: meta, and eachlane.failedentry becomes asettledupdate inlane.inboxunder a fresh id. - Malformed records and storage failures surface as errors; the stored record remains available for recovery.
- A stored string that is not JSON (a truncated or emptied write) reads as absent: the session starts a new client identity, its next save replaces the string, and
onEvictreportsreason: "corrupt"once. A sweep removes it from an unmounted session. A codecdecodefailure is an error. LocalStorageSessionin@assistant-ui/react-statewire/local-storageprovides browser coordination over this adapter.
Limits (limits), off by default:
outboxEntriescaps unsettled entries in the record,recordBytesthe record's stored size,totalBytesthe stored size of every record the principal keeps undernamespace(requiresgetAllKeys). Each must be a positive integer.- The other records count as they stand at the write: sessions mounted in the same runtime with each save they accept, the rest read again on a synchronous backend, or recounted on an asynchronous one after same-runtime saves and the keys
subscribereports. - A write past
recordBytes, or pasttotalBytesgiven the other records, first drops the payloads of the record's storedsettledupdates: failures before results, oldest first, until it fits. - Past
totalBytes, withretentionset, a sweep under pressure frees the excess from the other records. - A write that still adds an entry past a limit rejects that send
{ fate: "aborted", cause: "quota" }and changes nothing. Settling and stamping always land. - On a synchronous backend, a
QuotaExceededErroron a write that adds an entry rejects the same way, a sweep under pressure frees the write's size, and the session keeps running. Other writes, and every write on an asynchronous backend, retry after each step of a sweep under pressure; aQuotaExceededErroronce nothing is left to free stops the session withstorage-error.
Retention (retention), across the principal's records under namespace:
- A record mounted in this runtime, or in use by another (
withUnused), is never touched. - Records that are not JSON go on every sweep (
"corrupt"). - Every record goes once past
maxAgeMs("expired"); empty records also pastmaxEntries("overflow"). - Under pressure (a backend quota error, or past
totalBytes), oldestsavedAtfirst, until the write fits: empty records, then the payloads of storedsettledupdates (records kept), then records holding onlysettledupdates, then records with queued, held or stamped entries ("quota"). onEvict({ key, reason, dropped })reports each evicted record with thesettledupdates and unsent commands that went with it, and updates the session dropped past its inbox cap (reason: "inbox").
MemorySessionStorage
const MemorySessionStorage: (options: { principal: string; scope: string }) => ResourceElement<StatewireClient.SessionStorage>;Session storage in page memory, keyed by principal and scope: the record and client identity survive remounts, not reloads. Its outbox entries report durable: false. principal is required and non-empty; changing it on a mounted resource throws.
IndexedDbSession, indexedDbBackend
const IndexedDbSession: (options: IndexedDbSession.Options) => ResourceElement<StatewireClient.SessionStorage>;
namespace IndexedDbSession {
type Options = Omit<KeyValueSessionStorage.Options, "backend" | "namespace"> & { database?: string; namespace?: string };
}
const indexedDbBackend: (database: string) => KeyValueSessionStorage.Backend;const storage = IndexedDbSession({ principal: userId, key: threadId });- Shared session storage on IndexedDB: sharers serialize through Web Locks and hear each other over a
BroadcastChannel. Provideslock,subscribe,ownerandnotifyLock. databasedefaults to"statewire",namespaceto"statewire-session:". The other options areKeyValueSessionStorage's,principalrequired.- Stores the record under
<namespace><encoded-principal>:<key>; the Web Lock names carry that key. Changingprincipalthrows; another principal's record is refused as inKeyValueSessionStorage. - Records saved by 0.24 under the unscoped
<namespace><key>are not read. An app that knows their owner moves them to the scoped key before mounting. - Requires
indexedDBandnavigator.locks. indexedDbBackend(database)is the key-value backend it writes through, one per database; pass it toreadOutboxes.
readOutboxes, subscribeOutboxes
const readOutboxes: (backend: KeyValueSessionStorage.Backend, options: ReadOutboxes.Options) => Promise<readonly ReadOutboxes.Outbox[]>;
const subscribeOutboxes: (
backend: KeyValueSessionStorage.Backend,
options: ReadOutboxes.Options,
listener: (outboxes: readonly ReadOutboxes.Outbox[]) => void,
onError: (error: unknown) => void,
) => () => void;
namespace ReadOutboxes {
type Options = { namespace: string; principal: string; codec?: KeyValueSessionStorage.Codec; protocol?: string };
type Outbox = { key: string; entries: readonly StatewireClient.OutboxEntry[] };
}readOutboxesreads the outbox of every sessionprincipalkeeps undernamespace, mounted or not; records without entries are skipped. A record naming another principal rejects. RequiresgetAllKeys, a non-emptynamespaceand a non-emptyprincipal.subscribeOutboxescallslistenernow and after every save toprincipal's sessions undernamespace, in this runtime and, throughbackend.subscribe, in others; a read that rejects goes toonError. Returns unsubscribe.
StatewireHttp, StatewireWebsocket
const StatewireHttp: <TState = any, TCommands extends Statewire.Commands = Statewire.Commands>(options: StatewireClient.HttpOptions) => ResourceElement<Statewire<TState | undefined, TCommands>>;
const StatewireWebsocket: <TState = any, TCommands extends Statewire.Commands = Statewire.Commands>(options: StatewireClient.WebsocketOptions) => ResourceElement<Statewire<TState | undefined, TCommands>>;StatewireSendError
class StatewireSendError extends Error { readonly failure: StatewireClient.SendFailure; constructor(failure: StatewireClient.SendFailure); }Rejection value of a command promise. name is "StatewireSendError".
statewire/host
useStatewireState, useStatewireCommands
const useStatewireState: <TState>(init: () => TState) => readonly [TState, StatewireHost.StateCtl<TState>];
const useStatewireCommands: <THandlers extends Statewire.Commands>(commands: (THandlers & ThisType<StatewireHost.Ctx>) | undefined | null | false, options?: StatewireHost.CommandsOptions) => Statewire.CommandsProxy<StatewireHost.HandledCommands<THandlers>>;Recording-draft state (every mutation records a wire op) and handler registration (own enumerable function properties) for a host resource returning { state, commands }. A method named statewire/* throws statewire: cannot register "<m>": the "statewire/" method namespace is reserved; calls queued past unmount reject. The ThisType<StatewireHost.Ctx> intersection types this inside method shorthand and function handlers; arrow handlers keep the enclosing this.
append, getStateHost, StatewireReject
const append: <TKey extends string | number>(parent: { readonly [P in TKey]: string }, key: TKey, suffix: string) => void;
const getStateHost: (state: object) => StatewireHost.StateHost | undefined;
class StatewireReject extends Error { readonly payload?: unknown; constructor(message: string, options?: { payload?: unknown }); }append records one end-offset text-insert add op (throws off a draft, on a non-string suffix, or when the field holds a non-string; an empty suffix is a no-op). getStateHost reads a useStatewireState object's source. A thrown StatewireReject settles the command rejected with message and payload.
ANY, readFilter
const ANY: unique symbol; // Symbol.for("statewire.any")
const readFilter: (entries: readonly { readonly path: StatewireHost.FilterPath; readonly rule: StatewireHost.FilterRule }[]) => StatewireHost.Filter;readFilter validates and freezes: it throws on an empty or non-string segment (other than ANY), a non-function rule, or two entries that can match the same node. A host resource returns filter?: StatewireHost.FilterDecider beside state and commands; the socket host calls it synchronously for each document entering an attach's view (after attaching, and at the flush that adds a document). null sends the document unfiltered; a throw, a result not built by readFilter, or a failing rule ends the attach with fin error / filter-failed. Attaches sharing a document and a filter instance compute once per flush. Rules re-run on the final state for every node an op touched at or under, and a changed instruction sends one replace of the node. Guide.
StatewireHost
StatewireHost.* | Shape |
|---|---|
StateCtl<TState> | { apply: (ops: readonly Statewire.Op[]) => void; snapshot: () => TState }: a root replace swaps the whole state |
StateHost | Statewire.StateSource & { setOnOp: (listener: ((op: Statewire.Op) => void) | null) => void } |
Ctx | { readonly caller: Caller; readonly context: Statewire.Context; readonly version: string; readonly attach: Attach; readonly signal: AbortSignal; readonly applied: () => void }: context is captured at command admission; version is the command protocol's version the submitting attach negotiated (in-process dispatch: CommandsOptions.version, else reading it throws); applied() is idempotent |
CommandsOptions | { context?: () => Statewire.Context; version?: string }: what this.context and this.version read for in-process dispatch |
Caller | { readonly clientId: string; readonly auth: unknown; readonly isConnected: boolean }: clientId throws for in-process dispatch; auth is undefined for a void authorize and in-process dispatch |
HandledCommands<THandlers> | each handler's parameters with Awaited<ReturnType> |
Attach | { readonly caller: Caller; context; document; mount; unmount; include; exclude; reconnect: (code?: string, options?: { retry?: number }) => void }: reconnect ends the attach with fin reconnect / code (default reauthorize) and close 1012 |
FilterPath | readonly (string | typeof ANY)[] |
FilterInstruction | "keep" | null | { readonly pick: readonly string[] } | { readonly omit: readonly string[] } |
FilterRule | (value: any) => FilterInstruction: synchronous and pure |
Filter | { readonly entries: readonly { readonly path: FilterPath; readonly rule: FilterRule }[] }: built by readFilter |
FilterDecider | (protocol: string, key: readonly string[] | null, attach: Attach) => Filter | null |
Fin | { reason: "evicted" | "error" | "unauthorized" | "gone" | "idle" | (string & {}); message?: string; payload?: unknown; meta?: unknown } |
statewire/host-internal
StatewireSocketHost
const StatewireSocketHost: (transport: ResourceElement<Statewire>, options?: StatewireHostInternal.SocketHostOptions, persistence?: StatewireHostInternal.SocketHostPersistence) => StatewireHostInternal.SocketHost;
const WS_SUBPROTOCOL = "statewire.v1";Transport-agnostic serving engine over any send/close socket. dispose() finishes { reason: "reconnect", code: "evicted" }. persistence.clients keeps each client id's lane watermarks in a ClientStore across restarts: records load before an attach registers, admitted commands run and their dur goes out after save settles, and a re-sent seq at or below a stored watermark answers expired.
clientIdFromUrl, textFrameOrClose
const clientIdFromUrl: (url: string | null | undefined) => string;
const textFrameOrClose: (socket: Pick<StatewireHostInternal.Socket, "close">, data: unknown) => string | null;clientIdFromUrl reads the client query parameter (throws when missing or unparseable). textFrameOrClose returns a string frame or closes 1008 ("text frames only") and returns null.
StatewireHostInternal
StatewireHostInternal.* | Shape |
|---|---|
Socket | { send: (data: string) => void; close: (code?: number, reason?: string) => void } |
SocketConnection | { readonly clientId: string; message: (data: string) => void; close: () => void } |
SocketConnectOptions | { headers?: Record<string, string>; authorized?: boolean; authorizedAt?: number; selection?: StatewireProtocol.Selection; attacher?: string | null; auth?: unknown; onAuthorized?: (auth: unknown, selection: StatewireProtocol.Selection, authorizedAt: number, attacher: string | null) => void }: restored-session socket; authorizedAt is the original UNIX epoch timestamp in milliseconds, persisted by onAuthorized before the first snapshot; attacher is the attach's Statewire-Attacher under 2026-11-02 |
SocketHost | { connect: (clientId, socket: Socket, options?: SocketConnectOptions) => SocketConnection | null; stream: (request: Request) => Promise<Response>; frames: (request: Request) => Promise<Response>; finish: (fin: StatewireHost.Fin) => void; reconnect: (match?: (attach: StatewireHost.Attach) => boolean, code?: string, options?: { retry?: number }) => void; dispose: () => Promise<void> }: null means refused; dispose resolves once the client store holds the watermarks it leaves behind; reconnect ends every attach match accepts (all when omitted) with fin reconnect / code (default reauthorize) |
SocketHostOptions<TAuth> | per-field table below |
SocketHostPersistence | { clients?: ClientStore; hibernatable?: boolean } |
ClientStore | { load: (clientId: string) => ClientWatermarks | null | Promise<ClientWatermarks | null>; save: (records: readonly ClientWatermarks[]) => void | Promise<void>; prune: (before: number) => void | Promise<void> }: save calls apply in call order; a failed save answers the waiting commands rejected / unavailable; prune deletes records touched before before |
ClientWatermarks | { id: string; touched: number; lanes: Readonly<Record<string, number>>; principal: unknown }: lanes maps each durable protocol to its admitted watermark, in-flight commands included; principal must survive a JSON round trip |
Fin | StatewireHost.Fin |
StatewireHostInternal.SocketHostOptions<TAuth>
| Name | Type | Default | Meaning |
|---|---|---|---|
authorize? | (ctx: { headers: Record<string, string> }) => TAuth | Promise<TAuth> | — | Gates every attach; the awaited value is this.caller.auth. The first attach binds the client id's record to it; a later attach whose value fails structural equality is refused (HTTP 403, socket close 1008) |
wsInitTimeoutMs? | number | 5000 | Time allowed to complete WebSocket initialization and authorization |
maxLeaseLifetimeMs? | number | null | 86400000 | Maximum authorization lease age in milliseconds; finite positive, null disables. Expiry ends with reconnect / lease-expired |
heartbeatIntervalMs? | number | 15000 | Heartbeat interval |
maxSubscribers? | number | 1024 | Maximum concurrently attached WebSocket and SSE streams; positive integer |
maxInflightCommands? | number | 1024 | Maximum admitted commands still running across all clients; positive integer |
streamQueueSize? | number | 256 | Maximum queued SSE packets before a lagging stream is closed; positive integer |
clientRecordLimit? | number | 1024 | LRU cap on client records in memory; live or in-flight records are exempt. A dropped record reloads from the ClientStore |
clientIdleMs? | number | 2592000000 | Records idle longer are dropped from memory and pruned from the ClientStore; finite positive |
finOnIdle? | boolean | false | Hangs every attach up with fin: idle at the idle flag's rising edge |
Fixed wire limits (limits): 256 commands per frame, 256 in flight per client, 8 MiB frame body, 64 KiB per context value, capacity refusals carry retry: 1000.
statewire/testing
createMockTransport
const createMockTransport: <TState>(initialState: TState) => MockTransport<TState>;
type MockTransport<TState> = {
transport: ResourceElement<Statewire<TState | undefined, any>>;
commands: { method?: string; params?: unknown[]; context?: unknown }[];
setState: (state: TState) => Promise<void>; applied: (seq: number) => Promise<void>; settle: (seq: number, payload?: unknown) => Promise<void>;
sendEnvelope: (envelope: StatewireEnvelope) => Promise<void>; finish: (reason: string, message?: string) => Promise<void>;
readonly clientId: string; readonly attached: boolean;
};Mock backend: initialState is the attach snapshot, frames land in commands. Acks and verdicts come from test code. StatewireEnvelope is one packet: { ops?: unknown[]; cmd?: StatewireCmd[]; next?: true; idle?: boolean; syn?: StatewireSyn; fin?: StatewireHost.Fin }.
tick, settle
const tick: () => Promise<void>; // one macrotask
const settle: () => Promise<void>; // three ticksapplyOptimistic
const applyOptimistic: <TState>(state: TState, mutate: (draft: TState) => void) => TState;Runs a mutator against a recording draft, as the client does for optimistic. Throws statewire: cannot record mutations on primitive state.
createCounterElement
const createCounterElement: () => ResourceElement<{ state: { count: number }; commands: Statewire.CommandsProxy<{ increment: () => void }> }>;Minimal host element: { count } state with an increment command.
commandFrame
const commandFrame: (method: string, seq: number, params?: unknown[]) => string;Serializes a one-statement cmd-lane frame, { cmd: [{ method, params, seq }] }. params defaults to [].
createSocketRecorder
const createSocketRecorder: () => SocketRecorder;
type SocketRecorder = { sent: string[]; closes: { code?: number; reason?: string }[]; last: () => any; send: (data: string) => void; close: (code?: number, reason?: string) => void };Records sent frames and close calls. last() parses the latest frame.
Lease context
const client = new StatewireClient({
context: { default: { page: "overview" } },
transport: StatewireWebsocket({ url: "/workspace" }),
});
client.setContext("default", { page: "changes", openedAt: new Date() });setContext(protocol, value)replaces the selected protocol's object.{}clears its fields.- Date values use Statewire Document encoding. Each encoded entry is limited to 64 KiB.
- Context updates leave command clocks and durable replay records unchanged.
- New attachments receive the latest retained context, including a new hello on a preserved socket.
- Host transports implement
validateContext(protocol, value, attach)andcontextChanged(protocol, value, attach). Validators run for the whole frame before installation; change hooks run after installation. attach.callercarries authorization.attach.context(protocol)reads that attachment's current context.this.contextin a command handler is its captured admission snapshot.useStatewireCommands(handlers, { context: () => value, version })supplies context andthis.versionto in-process dispatch.