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

vault.ts
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

app/vault/[vaultId]/route.ts
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 = respond

handle.fetch serves one instance; your route authenticates and picks which:

  • GET streams state commits as server-sent events, resumed after the index query parameter, with heartbeats. It carries state and nothing else: subscribers never see other callers' inputs, and a refusal answers only its caller.
  • GET ?snapshot=1 returns the current { state, index }.
  • POST makes 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

app/vault/[vaultId]/page.tsx
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} />
}
app/vault/[vaultId]/vault-client.tsx
'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 as createActorClient, 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 advanced state.
  • refresh(): reads and adopts a fresh snapshot before resolving. Use it after a refusal or when a suspended tab returns.
  • connection: the same union as useSession, 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

board.ts
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:

app/board/[boardId]/board-client.tsx
'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 setPresence and 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 a presence operation 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.