Transports

One client, three wires: one HTTP route, split routes for per-verb timeouts, or one WebSocket.

One wire seam, same client

api picks the wire:

// in a 'use client' session module:
api: '/api/order-events'
api: (sessionId) => `/api/orders/${encodeURIComponent(sessionId)}/events`
api: { type: 'http', push: '/api/order-push', stream: '/api/order-stream' }
api: { type: 'ws', url: '/api/order-events' }
  • One route is the default and the recommendation: a fixed URL or a session URL function, serving GET (SSE stream and history) and POST (push).
  • http split serves the two verbs from separate routes, for platforms whose duration limits differ per verb.
  • ws carries streams, pushes, and presence over one socket per session. A socket is one connection in both directions, so it has no split form.

The server side of each lives on Routes. Everything above the wire is shared: useSession, push acks and confirmed, presence cadence, connection status, stall detection, and reconnection. SSE and WebSocket resume from the same durable frontier and treat presence as ephemeral, repaintable state.

Session URL functions

Every session-scoped URL may be a (sessionId) => string function. A2 resolves it for each request or reconnect, then adds its standard query parameters. Encode the ID when placing it in a path segment.

The function chooses a route, not authority. If a path or connection token is scoped to one session, authorize must compare that scope to operation.sessionId. Bind URL tokens to the same session and keep them short-lived, because URLs can appear in infrastructure logs.

WebSocket

app/orders/[orderId]/session.ts
'use client'
import { createClient } from 'experimental-a2/client'
import { createReact } from 'experimental-a2/react'
import { orders } from '@/contracts'

export const ordersClient = createClient({
  contract: orders,
  api: { type: 'ws', url: '/api/order-events' },
})
export const { SessionProvider, useSession } = createReact({
  client: ordersClient,
})

To share one socket across the client's sessions, set multiplex: true in api and multiplexWebSocket: true on the route. A multiplexed URL is one plain string, since the connection carries more than one session.

ws has no history lane. loadHistory is a bounded cold read over plain HTTP; on a ws api it throws a TypeError.

Lifecycle

When a socket closes, the client reconnects at its current frontier per its reconnection policy, receives a fresh presence snapshot, and re-sends its own latest presence. A multiplexed socket re-subscribes every active session at its own frontier. Pushes keep their client-generated ids, so retries stay idempotent.

The createClient({ reconnect }) policy returns a delay, or false to pause. The default reconnects with backoff indefinitely. A clean close also runs the policy, with error: null. A paused session keeps its state and leases; session.reconnect() resumes it.

Upgrade authorization stays in the HTTP route. Browser WebSockets hide a rejected upgrade's status and body, so it surfaces as an opaque CONNECTION_FAILED error. Structured subscription errors arrive once a multiplexed socket is open.