Skip to content

RPC contracts

One contract per operation, and today that contract is HttpApi. This page answers when effect/rpc is worth building, and once it is, how its transport, contracts, streams and evolution are shaped. The rules after the first wait for their subject: they cost nothing until the first server-pushed feature, then apply the first time.

Serve every request/response operation through HttpApi

Section titled “Serve every request/response operation through HttpApi”

Impact: HIGH one error boundary, one auth middleware, one contract test

  • Every request/response operation, for every client, is an HttpApi endpoint (HttpApi and handlers). A CLI or a mobile app reuses HttpApiClient.make(Api); it does not get a second contract.
  • Neither a second client nor a first-party Effect client is a reason for RPC. opencode serves an Effect client and a Promise client from one HttpApi by codegen.
  • RPC is built only when a feature needs the server to push to an open client: a change on the server must reach it without the client asking. Large payloads stay on HTTP even then.
  • Accepted cost: the first live feature brings the whole RPC stack at once, under that feature’s pressure.

❌ Incorrect — a second surface for CRUD the HttpApi already serves:

export class LabelingRpcs extends RpcGroup.make(
Rpc.make("labeling.listRules", { payload: ListRulesRequest, success: RuleList }),
Rpc.make("labeling.createRule", { payload: CreateLabelingRuleRequest, success: PublicLabelingRule }),
) {}

✅ Correct — every client on the one contract:

packages/contracts/src/labeling.ts LabelingApi (HttpApiGroup), every req/resp operation
packages/contracts/src/api.ts the one HttpApi
web ─┐
CLI ─┼─ HttpApiClient.make(Api) same contract, no second surface
SDK ─┘

Source: notes/08-application-surfaces/rpc-contracts.md · Decision 1

Use one WebSocket with JSON; on Workers, end it in a Durable Object

Section titled “Use one WebSocket with JSON; on Workers, end it in a Durable Object”

Impact: MEDIUM push needs a long-lived connection with acks

  • One WebSocket at /rpc, serialized with RpcSerialization.layerJson, never layerNdjson: a WebSocket frames messages itself. HTTP transports have no acks, and on a Worker one request cannot fan a change out to other tabs.
  • On the Worker profile the Worker never holds the socket. It authenticates the upgrade, checks org membership, then forwards it to an RpcDurableObject (alchemy), one per org. Every RPC on that socket stays on that object.
  • A socket client inside a Worker is provided per request, never in the isolate initializer.
  • Accepted cost: the Worker profile gains a Durable Object. A hibernated socket with unfinished requests closes with 1012 and nothing is replayed; the stream cursor makes that survivable.

❌ Incorrect — ndjson on a socket, and a choice of object before auth:

RpcServer.layerHttp({ group: AppRpcs, path: "/rpc", protocol: "websocket" })
.pipe(Layer.provide(RpcSerialization.layerNdjson))
// Worker: forwards first, so an unauthenticated socket reaches an org's object
return yield* feeds.fetch(url.searchParams.get("org"), request)

✅ Correct — JSON on the socket; the Worker authorizes, then forwards:

// container profile — apps/server/src/rpc.ts
export const RpcLayer = RpcServer.layerHttp({
group: AppRpcs, path: "/rpc", protocol: "websocket",
disableFatalDefects: true, // error-boundaries §6
}).pipe(Layer.provide(RpcSerialization.layerJson))
// Worker profile — authenticate, authorize the org, then forward the upgrade
if (isRpcUpgrade(request)) return yield* feeds.fetch(orgId, request) // RpcDurableObject, one per org

Source: notes/08-application-surfaces/rpc-contracts.md · Decision 2, amended

One RPC group per feature, merged once, middleware last

Section titled “One RPC group per feature, merged once, middleware last”

Impact: HIGH an RPC merged after .middleware runs without auth

  • Each feature’s RPCs sit beside its HttpApi group in packages/contracts/src/<feature>.ts. rpc.ts merges them into AppRpcs; the server side is a thin LabelingRpcs.toLayer(...).
  • Tags are <feature>.<operation>. A tag is wire identity and is never renamed in place.
  • .merge first, .middleware last, always: .middleware copies only onto RPCs already in the group. RpcAuthentication (provides: CurrentPrincipal) goes on first; RpcErrorBoundary is added last so it wraps auth. A public RPC lives in its own named group with only RpcErrorBoundary.
  • Payloads and successes are the Public* / *Request wire schemas; errors are the shared status-shaped classes from HttpApi and handlers. Every client imports AppRpcs from contracts. The structural contract test catches a missing middleware (see testing).

❌ Incorrect — BillingRpcs merged after the middleware, so it is unprotected:

export const AppRpcs = LabelingRpcs
.middleware(RpcAuthentication)
.merge(BillingRpcs) // runs without auth; not a compile error

✅ Correct — merge everything, then guard once:

packages/contracts/src/labeling.ts
export class LabelingRpcs extends RpcGroup.make(
Rpc.make("labeling.watchRules", { payload: WatchRulesRequest, success: RuleFeedEvent, error: FeedOverflow, stream: true }),
) {}
// packages/contracts/src/rpc.ts
export const AppRpcs = LabelingRpcs
.merge(BillingRpcs)
.middleware(RpcAuthentication) // provides CurrentPrincipal
.middleware(RpcErrorBoundary) // added last, so it wraps auth

Source: notes/08-application-surfaces/rpc-contracts.md · Decision 3

Make every feed a bounded, resumable snapshot-then-tail stream

Section titled “Make every feed a bounded, resumable snapshot-then-tail stream”

Impact: MEDIUM a sleeping tab or a dropped socket loses nothing silently

  • Every event on a feed carries a monotonically increasing sequence. The client reconnects with the last one it saw, on FeedOverflow, on close code 1012, and on any transport error. Nothing on the server replays for it, so the cursor is the only resume.
  • A cursor older than what the server retains gets a fresh snapshot, not an error.
  • The subscriber buffer is bounded at 1,000 items and fails when full. It never blocks the publisher and never drops silently. The number is a first guess, corrected by measurement.
  • One shape for every feed, so the client’s reconnect logic is written once.

❌ Incorrect — an unbounded subscriber, no cursor:

Rpc.make("labeling.watchRules", { success: RuleChanged, stream: true })
// server: Stream.fromPubSub(changes) unbounded queue behind a sleeping tab; a reconnect loses the gap

✅ Correct — snapshot or replay, then a bounded live tail:

export const WatchRulesRequest = Schema.Struct({ afterSequence: Schema.optional(Sequence) })
export const RuleFeedEvent = ForwardCompatibleUnion([RulesSnapshot, RuleChanged])
export class FeedOverflow extends Schema.TaggedError<FeedOverflow>()("@app/rpc/FeedOverflow", {
lastSequence: Sequence,
}) {}
snapshotOrReplay(afterSequence) // replay if the cursor is still retained, else a fresh snapshot
.pipe(Stream.concat(liveTail)) // liveTail: subscriber queue bounded at 1,000; full → FeedOverflow

Source: notes/08-application-surfaces/rpc-contracts.md · Decision 4

Wrap growable unions; a breaking change is a new tag

Section titled “Wrap growable unions; a breaking change is a new tag”

Impact: HIGH one new event would kill the stream on every open old tab

  • API evolution’s rules hold unchanged: additive first, expand/contract for breaking changes, growable enums read tolerantly.
  • A growable union, including every stream event union, is wrapped with ForwardCompatibleUnion, so an unknown member decodes to an Unknown case. The RPC client dies on an exit it cannot decode.
  • Every RPC client treats a per-call defect (an undecodable exit, an Unknown request tag from a rolled-back server) as one generic failure, caught with catchCause.
  • Accepted cost: only review catches a forgotten wrap, and tags gain ByOrg/V2-style names during expand/contract.

❌ Incorrect — a plain union, and a tag changed in place:

export const RuleFeedEvent = Schema.Union([RulesSnapshot, RuleChanged]) // a new member kills old tabs
Rpc.make("labeling.watchRules", { payload: WatchRulesByOrgRequest, … }) // same tag, new meaning

✅ Correct — a forward-compatible union, and a new tag across releases:

RuleFeedEvent = ForwardCompatibleUnion([RulesSnapshot, RuleChanged])
R12 labeling.watchRulesByOrg added; labeling.watchRules kept, flagged in its description
the web client switches in the same release
R13 labeling.watchRules removed (own client only)

Source: notes/08-application-surfaces/rpc-contracts.md · Decision 5

  • An effect/rpc surface (AppRpcs, /rpc, every rule after the first) — trigger: the first feature that needs a server-pushed subscription.
  • A shared client-runtime package (connection, reconnect, cursor) — trigger: a second client app consumes AppRpcs.
  • A credit window over per-chunk acks — trigger: one stream sends more than 100 chunks per second to one client.
  • A protocol-version handshake — trigger: the first RPC client that is not redeployed with the server (an installed mobile, desktop or CLI client).
  • layerSchemaBinary on the socket — trigger: measured p95 frame size on /rpc above 64 KiB.