Routes
Serve every session with server.fetch over HTTP or a WebSocket, or build your own route from session methods.
The HTTP route
import { ordersServer } from '@/server/orders'
export const GET = ordersServer.fetch
export const POST = ordersServer.fetchserver.fetch is bound, so an unprotected route is two exports. A plain GET
streams one session over SSE, a GET with gte and lte returns a history
slice, and POST appends the pushed events. If the contract declares presence,
the same route streams and accepts it.
Wrap the call when the route needs authentication or authorization:
import { ordersServer } from '@/server/orders'
async function fetchOrders(request: Request): Promise<Response> {
// your authentication:
const user = { id: 'user-1' }
if (!user) return new Response(null, { status: 401 })
return ordersServer.fetch(request, {
authorize(operation) {
// your per-session and per-operation authorization:
// return canAccess(user, operation)
return true
},
})
}
export const GET = fetchOrders
export const POST = fetchOrdersAuthenticate in the route, where headers, cookies, and framework context
live. authorize then checks each typed operation (stream, history, or
push with its parsed events and presence patch) after A2 parses it and
before schema validation or durable I/O. Return false to answer
FORBIDDEN (403). A throw is an infrastructure failure and answers
STORE_UNAVAILABLE.
If a URL or connection token is scoped to one session, authorize must
compare that scope to operation.sessionId. Keep URL tokens short-lived:
URLs can appear in infrastructure logs.
The WebSocket route
import { experimental_upgradeWebSocket } from '@vercel/functions'
import { ordersServer } from '@/server/orders'
export const POST = ordersServer.fetch
export function GET(request: Request): Promise<Response> {
return ordersServer.fetch(request, {
upgradeWebSocket: (attach) =>
experimental_upgradeWebSocket(attach, {
maxPayload: 4 * 1024 * 1024,
}),
})
}Pass the platform's upgrade capability on GET; POST can stay a direct export.
The socket is bound to the sessionId in its upgrade URL: A2 authorizes that
session before upgrading, and frames can't switch sessions. Each connected
session has its own socket.
Authenticate the upgrade request before calling server.fetch. For browser
sockets authenticated by cookies, validate the request's Origin too: a
WebSocket handshake has no CORS preflight. Then authorize checks the session
stream and every push or presence frame. There is no separate upgrade
operation.
To share one socket across sessions, opt in on the route and set
multiplex: true on the client:
import { experimental_upgradeWebSocket } from '@vercel/functions'
import { ordersServer } from '@/server/orders'
export function GET(request: Request): Promise<Response> {
return ordersServer.fetch(request, {
multiplexWebSocket: true,
upgradeWebSocket: (attach) =>
experimental_upgradeWebSocket(attach, {
maxPayload: 4 * 1024 * 1024,
}),
})
}Enable multiplexing only when the request's authentication context may open
more than one session and authorize checks each session id. Subscribe frames
open sessions after the upgrade.
Denials stay scoped:
- A denied subscribe on a multiplexed socket gets an
unsubscribednotice withFORBIDDENand leaves other sessions connected. A failed stream or a thrown authorization check carriesSTORE_UNAVAILABLE. The client hands these to its reconnection policy asA2ClientErrors. - A denied event push gets a non-retryable
FORBIDDENack. - Denied presence is dropped silently; presence repaints on the next update.
A2 reads the platform's invocation deadline and closes the socket cleanly before it.
Build your own
server.fetch speaks the wire A2's client expects. For anything else, such as
another framework, your own framing, or a consumer that isn't A2's client,
build the route from session methods. It's just an event log: read a snapshot,
follow the stream, append.
import { orders } from '@/contracts'
import { ordersServer } from '@/server/orders'
type Params = { params: Promise<{ orderId: string }> }
const encoder = new TextEncoder()
const frame = (name: string, data: unknown): Uint8Array =>
encoder.encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`)
export async function GET(
_request: Request,
{ params }: Params,
): Promise<Response> {
const { orderId } = await params
// here's where you'd do auth, or any other checks
const session = ordersServer.session(orderId)
const snapshot = await session.state(orders.reducer)
const events = session
.stream({ startAfter: snapshot.index })
[Symbol.asyncIterator]()
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(frame('snapshot', snapshot))
},
async pull(controller) {
const next = await events.next()
if (next.done) return controller.close()
const event = next.value
const name = event.type === 'a2.cleared' ? 'cleared' : 'event'
controller.enqueue(frame(name, event))
},
// the browser left: end the subscription
async cancel() {
await events.return?.()
},
})
return new Response(body, {
headers: { 'content-type': 'text/event-stream' },
})
}session.state() returns the snapshot's index, so the stream picks up
exactly where the snapshot ends. A clear arrives as an a2.cleared marker in
the same stream. Writes go through session.append, which validates against
the contract:
import { A2Error } from 'experimental-a2'
export async function POST(
request: Request,
{ params }: Params,
): Promise<Response> {
const { orderId } = await params
// here's where you'd do auth, or any other checks
const { shopId, items } = await request.json()
try {
const [event] = await ordersServer.session(orderId).append({
type: 'created',
payload: { shopId, items },
})
return Response.json(event)
} catch (error) {
if (error instanceof A2Error && error.code === 'INVALID_PAYLOAD') {
return Response.json({ issues: error.details }, { status: 400 })
}
throw error
}
}The browser follows it with EventSource and folds with the contract's own
reducer, the same fold the server runs:
'use client'
import { orders } from '@/contracts'
type OrderState = typeof orders.reducer.initialState
export function followOrder(
orderId: string,
onChange: (state: OrderState) => void,
): () => void {
const url = `/api/orders/${encodeURIComponent(orderId)}/live`
const source = new EventSource(url)
let state = orders.reducer.initialState
const update = (next: OrderState): void => {
state = next
onChange(state)
}
source.addEventListener('snapshot', (message) => {
update(JSON.parse(message.data).state)
})
source.addEventListener('event', (message) => {
update(orders.reducer.fold(state, JSON.parse(message.data)))
})
source.addEventListener('cleared', () => {
update(orders.reducer.initialState)
})
return () => source.close()
}EventSource reconnects on its own, and each connection starts from a fresh
snapshot. What server.fetch adds on top, and a custom route takes on itself:
- resuming a stream from the client's last
indexinstead of a new snapshot - bounded history reads (
gteandlte) - the push envelope with acks and
.confirmed, and presence - WebSocket upgrades
authorizeon every operation; directsession.appendbypasses it, so the route is where your checks go- closing streams before the platform's invocation deadline
A2's client, createClient and useSession, talks to server.fetch. Pair a
custom route with your own client.