statewire
v0.14.0
Published
State over the wire: snapshot-first delta streaming and command submission for any state and any command
Downloads
42,427
Readme
statewire
React/TypeScript client for the Statewire protocol: a server owns a JSON state object and streams it to clients as packets (POST /stream attach, hello-snapshot first); clients change it by submitting stamped statement frames (POST /frames, {"cmd": [{"method", "params", "seq"}]}). The client keeps your UI on the canonical state with optimistic updates rebased on top, survives disconnects, evictions, and reloads of the server, and tells you exactly what happened to every command you sent.
import { useStatewire, StatewireSSE } from "statewire";
type Commands = {
increment(): void;
};
const { state, connection, commands } = useStatewire<State, Commands>({
transport: StatewireSSE({ url: "/api/threads/demo" }),
optimistic: (draft, command) => {
if (command.method === "increment") draft.count += 1;
},
});
const onClick = async () => {
await commands.increment(); // resolves when the server has provably applied it
};What you get
state—undefineduntil the first snapshot, then canonical server state with your pending commands' optimistic effects rebased on top. When the server's ack arrives, the optimistic overlay drops in the same tick: no flicker.connection— connection health as a tagged union:connection.statusis"connecting" | "live" | "reconnecting" | "gone", and each variant carries exactly the data that exists in that situation:reconnectinghascause("dropped","evicted", or"error"),attempt,nextRetryAt,lastError?,message?;gonecarries the server'smessage?andpayload?.connection.reconnect()re-attaches manually fromgone, or "retry now" during backoff.commands— a typed proxy over theCommandsmethod map:commands.increment()submits{ method: "increment", params: [] }and resolves with the handler's result only once the stream ack proves the effect is in canonical state. Rejects withStatewireSendError, whosefailure.fatecarries the verdict; the boundary is definitiveness:"aborted"— definitely not applied (never POSTed, or every attempt accounted for); safe to resend"lost"— a POST left the machine and never got a definitive answer; whether the server applied it is unknown — check the snapshot for your payload ids"rejected"— the server said no; not applied (message, optionalpayloadfrom the backend'sStatewireReject)"unknown-command"/"invalid-params"— the server does not know the method, or the args failed its schema"crashed"— acked, but the processing died before producing a result"result-unavailable"— acked, but the result can no longer be delivered
Every failure also carries
cause— which channel settled it:"server"= the server's answer,"stream-gone"= terminalfinish(gone),"session-lost"= the server forgot the client id (eviction),"unmount"is client-side. An"aborted"failure carriespredecessorLost: truewhen an earlier in-flight command of the same batch has uncertain fate. The taxonomy's single exported type isStatewireClient.SendFailure; thefateandcauseunions are reachable asSendFailure["fate"]/SendFailure["cause"].An eviction replays every unsettled command under the rotated client id, in seq order — fresh seqs, relative order preserved, the original promise settles with the replay's outcome. Context submissions settle as session-lost failures and the stored context re-sends whole; replay may re-run a command the lost session already applied.
Reconnects are not errors: transport drops and server evictions self-heal with exponential backoff while state stays renderable. Commands carry a monotonic seq, so retries are deduplicated server-side and a reconnect splices already-applied commands instead of re-running them.
A client id may hold any number of concurrent attaches: each gets its own hello and lease, and cmd answers fan out to every live attach of the id. A lease lives and dies with its attach — a 423 means that attach is gone: re-attach and resend.
HTTP attach and follow-up frame bodies are limited to 8 MiB; oversized bodies receive 413 Payload Too Large before JSON parsing.
Three ways to mount
One implementation, three entry points:
useStatewire(options) // React hook
StatewireResource(options) // @assistant-ui/tap resource, for composition
new StatewireClient(options) // framework-free: .state/.connection/.commands,
// .subscribe(cb), .dispose()StatewireClient#subscribe follows the external-store contract (useSyncExternalStore-compatible). dispose() settles pending commands like an unmount.
Every public type merges onto the class as a namespace — StatewireClient.Connection, StatewireClient.ConnectionStatus, StatewireClient.Options, StatewireClient.SendFailure, … — so one import covers the whole surface.
There is no session key: a transport url change reattaches in place and the attach snapshot arbitrates identity (an unknown client evicts — pending settles, the client id rotates). For a forced fresh session, key the resource — withKey(threadId, StatewireResource(options)); pending commands settle with reason "unmount".
Transport
StatewireSSE({
url, // endpoint root — the transport appends /stream and /commands
headers, // HeadersInit or (async) () => HeadersInit, re-evaluated per request
fetch, // custom fetch — the escape hatch for everything else
})StatewireWS({ url, protocols, query, webSocket }) is the full-duplex sibling over /ws. A transport is a tap resource producing the Statewire value the client consumes; createMockTransport from statewire/testing is a complete in-memory driver for tests.
Backend
Any server speaking the Statewire convention works. The Python implementation lives in this repo (python/statewire/statewire):
from statewire import Statewire, StatewireReject, command
class Counter(Statewire):
@command
async def increment(self):
if self.state["count"] >= 10:
raise StatewireReject("counter is full", payload={"max": 10})
self.state["count"] += 1See examples/vite-statewire-example (frontend) and python/statewire/examples/demo_app.py (backend) for a complete runnable pair, and the repo's protocol spec for the wire format.
