Routes

Serve every session with server.fetch over HTTP or a WebSocket, or build your own route from session methods.

The HTTP route

app/api/public-order-events/route.ts
import { ordersServer } from '@/server/orders'

export const GET = ordersServer.fetch
export const POST = ordersServer.fetch

server.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:

app/api/protected-order-events/route.ts
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 = fetchOrders

Authenticate 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

app/api/socket-order-events/route.ts
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:

app/api/multiplexed-order-events/route.ts
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 unsubscribed notice with FORBIDDEN and leaves other sessions connected. A failed stream or a thrown authorization check carries STORE_UNAVAILABLE. The client hands these to its reconnection policy as A2ClientErrors.
  • A denied event push gets a non-retryable FORBIDDEN ack.
  • 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.

app/api/orders/[orderId]/live/route.ts
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:

app/api/orders/[orderId]/live/route.ts
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:

app/orders/[orderId]/follow-order.ts
'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 index instead of a new snapshot
  • bounded history reads (gte and lte)
  • the push envelope with acks and .confirmed, and presence
  • WebSocket upgrades
  • authorize on every operation; direct session.append bypasses 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.