Routes, clients, and React
One route serves an instance: GET streams live state, POST answers calls. Authorize per operation, project per mount, and call it from any client or one React hook.
The actor for this page
import { NonRetriableError } from 'experimental-a2'
import { actor } from 'experimental-a2/actor'
interface Vault {
state: {
balance: number
/** Internal bookkeeping; not for subscribers. */
audit: string[]
}
events: {
deposit: { amount: number }
withdraw: { amount: number }
}
}
export type VaultState = Vault['state']
export const vault = actor<Vault>({
name: 'vault',
state: { balance: 0, audit: [] },
handlers: {
deposit: (ctx, input) => {
ctx.state.balance += input.amount
ctx.state.audit.push(`deposit ${input.amount}`)
},
withdraw: (ctx, input) => {
if (ctx.state.balance < input.amount) {
throw new NonRetriableError('insufficient funds')
}
ctx.state.balance -= input.amount
ctx.state.audit.push(`withdraw ${input.amount}`)
},
},
})The route
import { vault } from '@/vault'
async function respond(
request: Request,
context: { params: Promise<{ vaultId: string }> },
): Promise<Response> {
const { vaultId } = await context.params
// here's where you'd do auth, or any other checks
return vault.actor(vaultId).fetch(request)
}
export const GET = respond
export const POST = respondhandle.fetch serves one instance; your route authenticates and picks
which:
GETstreams state commits as server-sent events, resumed after theindexquery parameter, with heartbeats. It carries state and nothing else: subscribers never see other callers' inputs, and a refusal answers only its caller.GET ?snapshot=1returns the current{ state, index }.POSTmakes a call and responds with its answer.
A POST body names a declared event:
{ "event": "deposit", "input": { "amount": 100 }, "messageId": "m-1" }It answers { state, index }, 409 with the refusal's message, or
400 for an undeclared event. The optional messageId makes retrying
the request idempotent. Snapshot and call responses use
Cache-Control: private, no-store.
After a clear
After handle.session.clear(), snapshots, call answers, and stream
commits include clearIndex; keep it in any snapshot you pass to
useActor's initial. The stream then sends the actor's initial state
(through view) as a reset, and the hook drops older history. A
delayed reset never replaces a newer snapshot.
A call cleared before its answer gets 410 with
{ code: 'SESSION_CLEARED', error }, which the actor client raises as
an ActorRequestError with status 410 and the A2 error as its
cause. The handler may already have run, so treat its effects as
unknown, as after a timeout. A later message reusing the id can't
answer the cleared call.
Authorize
// app/vault/[vaultId]/route.ts, now with per-operation policy:
import { vault } from '@/vault'
export async function POST(
request: Request,
context: { params: Promise<{ vaultId: string }> },
): Promise<Response> {
const { vaultId } = await context.params
return vault.actor(vaultId).fetch(request, {
authorize: (operation) =>
operation.type === 'call' && operation.event !== 'withdraw',
})
}Authentication stays in your route; authorize decides whether each
operation is allowed, before any read, write, or subscription.
Returning false answers 403. The operation is one of:
{ type: 'state', id }for snapshot reads{ type: 'stream', id, startAfter }for subscriptions{ type: 'call', id, event, input, messageId? }for calls{ type: 'presence', id, participant, values }for presence announcements
Views: what each mount shows
// app/vault/[vaultId]/public/route.ts, a projected mount of the same actor:
import { vault } from '@/vault'
export async function GET(
request: Request,
context: { params: Promise<{ vaultId: string }> },
): Promise<Response> {
const { vaultId } = await context.params
return vault.actor(vaultId).fetch(request, {
view: (state) => ({ balance: state.balance }),
})
}A view projects everything its mount serves: snapshots, stream
frames, and call answers alike, so calls never leak what the stream
hides. Audiences are a route concern, like auth: an admin mount with no
view sees everything, and the mount above hides audit. For a
per-viewer view, close over your route's identity:
view: (state) => ({ ...visible, mine: state.perUser[user.id] }). View
output must be plain JSON, like state.
Clients of a projected mount pass its shape as the second type argument
(createActorClient<typeof vault, PublicVault> or
useActor<typeof vault, PublicVault>).
Views hide internal fields, not secrets. State commits are log rows, and the log is forever: keep secrets outside, pass references, and resolve them inside handlers.
Any client
// in the browser, or any client at all:
import {
ActorRefusedError,
createActorClient,
} from 'experimental-a2/actor/client'
import type { vault } from '@/vault'
const savings = createActorClient<typeof vault>({ api: '/vault/savings' })
try {
const { state } = await savings.call.deposit({ amount: 100 })
// state is the answer: the fold after this deposit
} catch (error) {
if (error instanceof ActorRefusedError) {
// the server's refusal, revived with its message
}
}createActorClient is the typed call surface over the route's POST,
for browsers, other frameworks, and other servers. The vault import
is type-only, so the server module never enters a client bundle. client.state() reads a fresh snapshot. The optional
onSnapshot callback receives successful reads and call answers before
their promises resolve, including older results from idempotent
retries, so compare indexes before replacing live state.
ActorRequestError carries an HTTP status when available. A network
failure, timeout, or server error doesn't establish whether a call
committed: retry with the same explicit id and input to recover its
original answer. The client never retries calls automatically.
React
import { vault } from '@/vault'
import { VaultClient } from './vault-client'
export default async function VaultPage({
params,
}: {
params: Promise<{ vaultId: string }>
}) {
const { vaultId } = await params
const initial = await vault.actor(vaultId).state()
return <VaultClient vaultId={vaultId} initial={initial} />
}'use client'
import { useActor } from 'experimental-a2/actor/react'
import type { ActorSnapshot } from 'experimental-a2/actor/react'
import type { vault, VaultState } from '@/vault'
export function VaultClient({
vaultId,
initial,
}: {
vaultId: string
initial: ActorSnapshot<VaultState>
}) {
const { state, call, connection } = useActor<typeof vault>({
api: `/vault/${vaultId}`,
id: vaultId,
initial,
})
return (
<div>
<p>
Balance: {state.balance}
{connection.status === 'live' ? '' : ' (reconnecting)'}
</p>
<button onClick={() => void call.deposit({ amount: 25 })}>
Deposit $25
</button>
</div>
)
}The server component hands down the first snapshot; useActor keeps it
live and returns:
state: the live view (no reducers, schemas, or contracts in browser code).call: the same typed calls ascreateActorClient, which the hook wraps.index: the latest adopted state index.events: the state commits the stream observed after the initial snapshot. A delayed commit still enters once, even when a call answer or refresh already advancedstate.refresh(): reads and adopts a fresh snapshot before resolving. Use it after a refusal or when a suspended tab returns.connection: the same union asuseSession, heartbeat watchdog included.
State is live, not optimistic: handlers decide on the server against fresh state. For pending UI, await the call.
A successful call updates state before its promise resolves (React
renders on its normal schedule) and returns its own answer, even if
newer state already arrived. State only advances: an older answer or
delayed stream frame never replaces newer state. Refusals don't change
state or trigger a refresh.
Presence
import { actor } from 'experimental-a2/actor'
interface Board {
state: { strokes: number }
events: { draw: { path: string } }
presence: { cursor: { x: number; y: number }; name: string }
}
export const board = actor<Board>({
name: 'board',
state: { strokes: 0 },
presence: true,
handlers: {
draw: (ctx) => {
ctx.state.strokes += 1
},
},
})Declare presence fields as types in the protocol
and arm the wire with presence: true. Presence replicates to the
instance's subscribers and expires; it never enters the log, and
handlers can't see it. The route doesn't change: GET interleaves
presence with state commits, and POST accepts announcements. In
React, pass a participant identity and read the map:
'use client'
import { useActor } from 'experimental-a2/actor/react'
import type { ActorSnapshot } from 'experimental-a2/actor/react'
import type { board } from '@/board'
export function BoardClient({
boardId,
initial,
user,
}: {
boardId: string
initial: ActorSnapshot<{ strokes: number }>
user: string
}) {
const { presence, setPresence } = useActor<typeof board>({
api: `/board/${boardId}`,
id: boardId,
initial,
participant: user,
})
return (
<div
onPointerMove={(event) =>
setPresence({ cursor: { x: event.clientX, y: event.clientY } })
}
>
{Object.keys(presence).length} here
</div>
)
}setPresence is fire-and-forget: throttled, resent after a reconnect,
and null clears a field. The map holds each participant's latest
fields with their stamps; decide how long to paint a value from at.
Three guards, none of them a schema:
- Types: the protocol types
setPresenceand the map, so honest clients can't send the wrong shape. - The floor: A2 rejects values that aren't plain JSON trees, values over 8 KB, and object-plumbing field names.
authorize: every announcement arrives as apresenceoperation before it is accepted. Clamp, verify the participant matches the authenticated user, or rate-limit here.
No guard makes peer values trustworthy: presence is authored by whoever holds a connection, so render it like user input.
The session wire underneath
For the raw session protocol (the full event feed, pushes, presence,
the ws transport), mount def.server.fetch as a
session route and use the core
client. That wire shows the whole mailbox, inputs
included, so reserve it for trusted audiences.