Wire protocol

What's on the WebSocket — @effect/rpc over JSON, the snapshot/delta subscription envelope, and the POST /rpc one-shot path.

Voltro's client and api speak @effect/rpc over a WebSocket. Every primitive — queries, mutations, actions, agents, subscriptions — rides the one bidirectional connection, multiplexed by the rpc layer. The serialization is JSON (RpcSerialization.layerJson), not a bespoke binary format.

You usually don't think about the wire — the typed client + @voltro/protocol handle it end-to-end. This page is for when you DO need to: debugging a mystery, proxying through a gateway, or understanding what the inspect tooling shows you.

The transport

The api boots two rpc server instances on the same HttpLayerRouter:

  • WebSocketRpcServer.layerProtocolWebsocketRouter({ path }), the primary transport. Long-lived; carries streaming queries (subscriptions), unary mutations/actions, and agent runs.
  • HTTP one-shotRpcServer.layerProtocolHttpRouter({ path: '/rpc' }), registered at POST /rpc. Non-streaming; one request → one batched response.

Both are provided RpcSerialization.layerJson and the SAME rpc group (options.group), so the SAME per-rpc handlers + the group-level AuthMiddleware / ConnectionInfoMiddleware run on either path. A forwarded session cookie resolves the same Subject + tenant whether the call arrives over WS or HTTP.

Because @effect/rpc owns the framing, there's no app-level frame taxonomy to learn — the rpc client encodes a request, the server decodes it, runs the handler, and streams back the result. What's app-specific is the payload schema of each rpc (your defineQuery / defineMutation input + output) and, for streaming queries, the subscription-event envelope below.

Subscription events — snapshot / delta

A streaming query (what useSubscription opens) emits a sequence of subscription events. The envelope is defined in @voltro/protocol's subscriptionEvent(output) — a Schema.Union of three variants, parameterised on the query's declared output schema so the rpc layer enforces the row shape end-to-end:

// snapshot — always the FIRST event; the full initial query result
{ _tag: 'snapshot', revision: number, data: <output> }

// delta — every subsequent event; an id-keyed JSON-patch, NOT full data
{
  _tag: 'delta'
  revision: number
  emittedAt: number
  patch: {
    ops: Array<
      | { op: 'add';     path: '/<id>'; value: <row> }
      | { op: 'replace'; path: '/<id>'; value: <row> }
      | { op: 'remove';  path: '/<id>' }
    >
    order: Array<id>   // the full id sequence of the next set, in order
  }
}

// error — this ONE subscription's handler failed; surfaced in-band on its own
// stream (never a defect that would stall siblings on the shared connection)
{ _tag: 'error', error: { _tag?: string, message: string, ...fields }, revision?: number }
  • revision — monotonically increasing; lets the client order events and detect gaps.
  • emittedAt — epoch milliseconds, present on delta only.
  • data (snapshot) — the full payload, typed by the query's output schema (a row, an array of rows, a computed value — whatever the handler returns).
  • patch (delta) — an id-keyed RFC-6902-style patch against the row set the client last held.
  • error — a JSON-safe shape of a typed error (its _tag + fields + message preserved, so the client can pattern-match error._tag). useSubscription exposes it as .error for THAT query key.

The first event is always a snapshot carrying the full initial result — the client materialises it as its base. Subsequent events are deltas carrying only the rows that changed, plus the next id order. The server still re-runs the query on a change (the patch saves wire egress, not the re-query); it then diffs the previous row set against the new one into the patch.

Per-subscription errors — isolated, not connection-wide

Many subscriptions multiplex over ONE WebSocket. If a single subscription's handler fails (e.g. a live single-row getter that throws for a stale/foreign id), the server emits an error event on THAT subscription's own stream and completes it — it does not let the failure become a defect, which would propagate to the shared connection and stall every other subscription on it (the classic "one not-found and the whole dashboard hangs on loading"). The client surfaces it as useSubscription(...).error for that one query key; siblings keep delivering their snapshots and deltas.

Author a live-subscribed getter to return, not throw. A subscription is a long-lived stream, so a getter that throws on every re-evaluation is a broken stream. For an expected-absent row, make the query output: Schema.NullOr(...) and return null — that's a normal snapshot the widget renders as "empty", cleaner than an error banner. Reserve throwing for genuinely exceptional cases; even then it's now contained to the one subscription.

How the patch is keyed — by row id, not array index

The diff keys on each row's id, addressing it as path: '/<id>', rather than by array index. Index-based paths are brittle the moment a row moves — and reordering is the common case for the live result sets this targets (leaderboards, collaborative lists, game state). Id-keying makes a reshuffle cost just the changed rows plus the id list:

  • replace — a row present in both prev and next whose content changed; value is the full new row.
  • add — a row new in next; value is it.
  • remove — a row gone from next.
  • reorder — carried entirely by order (the next id sequence); a pure reorder emits zero ops and just a new order.

The client keeps the last materialised row set, applies the ops to an id→row map, then materialises the result strictly in order. The round-trip is exact: applying the computed patch to the previous set reproduces the new set for every case — add, remove, replace, reorder, and combinations.

No-op suppression still applies. When a change re-runs the query but the resolved row set is unchanged, no event is emitted at all — the patch path covers "a small part of a big result changed", the suppression covers "nothing changed".

Fallbacks. A result set whose rows aren't id-keyed (a custom projection that drops id) can't be diffed by id, so its update ships as a full snapshot instead. Computed queries (a handler that returns a scalar or aggregate, not an id-keyed row set) likewise ship every update as a snapshot — there's no row set to patch.

HTTP one-shot rpc (POST /rpc)

The WebSocket is the primary transport, but the api ALSO exposes POST /rpc for non-streaming invokes. It speaks the same JSON envelope and runs through the SAME per-rpc handlers + the same auth middleware — a forwarded session cookie resolves the same Subject + tenant as the WS path.

This is what a server-side web-router loader's ctx.query uses: a loader runs inside the request handler with no WS connection, so it calls the backend over HTTP for SSR first-paint + meta. The HTTP protocol drains a streaming query handler's stream to completion and returns it, so a streaming query yields its FIRST (initial) snapshot in the response batch — exactly what first-paint needs. See Loaders & meta.

For live, after-hydration data, subscribe over the WebSocket with useSubscription instead — POST /rpc is one-shot and never streams deltas.

Authentication

The session cookie travels in the WebSocket upgrade headers (and in the POST /rpc request headers). The api's AuthMiddleware resolver decodes it and binds the resolved Subject to the connection; subsequent rpc calls on that connection inherit it.

For API-key authentication, send Authorization: Bearer <key> in the upgrade (or request) headers — the same resolver path maps it to a subject.type === 'apiKey'.

Debugging

Use the framework's own tooling rather than reading raw frames:

  • voltro traces — the per-request span waterfall, including each subscription snapshot / delta delivery with its produce→push latency. voltro traces --errors filters to failed hops.
  • voltro logs --trace <id> — every log line of one request across hops, in order.
  • The inspect dashboard's stream firehose (GET /_voltro/inspect/stream) surfaces rpc / cdc / log events live.

Anti-patterns

  • Hand-crafting rpc frames. Use @voltro/client@effect/rpc owns the framing and the protocol can change.
  • Long-polling fallbacks. None — Voltro is WS-or-bust for live data. Browsers without WebSocket support don't get reactive updates (one-shot reads still work over POST /rpc).
  • Proxying through a CDN without WebSocket support. Cloudflare / Fastly / Vercel Edge all support it; configure the upgrade headers.