rpc-toolkit
Effect RPC and HttpApi Cannotations, WebSocket clients, and Cloudflare runtime and deployment integrations
Big picture
Effect RPC and Effect HttpApi give you middleware, but every app re-invents the same layer on top: a declaration on an endpoint that says how it may be called, a server half that checks it, a client half that attaches credentials. rpc-toolkit names that layer a Cannotation and ships it once for each transport. The declaration lives in shared contract code; the implementations stay on their own side of the wire.
The same pressure shows up at runtime. Streaming RPC over Cloudflare Durable Objects only pays off with hibernation, and hibernation breaks streams unless the server checkpoints them and the client restarts them after a reconnect. The rpc/cloudflare and rpc/websocket-client subpaths are the two halves of that fix, and the alchemy subpaths deploy them as one unit. Cannotation and browser entry points never import Cloudflare or Alchemy.
Vocabulary is in CONTEXT.md and the decisions behind the shape are in docs/adr/. A full contract, server, worker, and browser example is in docs/websocket-example.md; the package layout and migration checklist are in docs/integration-migration.md. Long-form guides for the two runtime subpaths are their module READMEs: hibernating-rpc and websocket-client.
Install
pnpm add rpc-toolkit effect
effect(peer, required): every subpath builds oneffect/unstable/rpcoreffect/unstable/httpapi.alchemy(peer, optional): needed only byrpc/cloudflare/alchemy/*, which wraps Alchemy's Cloudflare resources.
Exports
rpc-toolkit/rpc/cannotation
| Export | What it does |
|---|---|
Cannotation |
Namespace holding the RPC flavour of Cannotation. |
Cannotation.make |
Builds a Declaration for Rpc and RpcGroup targets; the result carries with, get, layer, clientLayer, middleware, and value. |
rpc-toolkit/http/cannotation
| Export | What it does |
|---|---|
Cannotation |
Namespace holding the HttpApi flavour of Cannotation. |
Cannotation.make |
Same shape as the RPC flavour over HttpApiEndpoint and HttpApiGroup, plus a security option that feeds OpenAPI and hands the credential to the server implementation. |
rpc-toolkit/rpc/invocation
| Export | What it does |
|---|---|
InvocationKind |
Context reference set by the server to fresh or replay; middleware reads it to skip admission on Hibernation Replay. |
rpc-toolkit/rpc/websocket-client
Long-form guide: src/rpc/websocket-client/README.md.
| Export | What it does |
|---|---|
layerWebSocketProtocol |
Layer that provides an RpcClient.Protocol over a WebSocket, plus RpcConnection and RpcClient.ConnectionHooks. |
resolveWebSocketUrl |
Turns a relative or http(s) URL into the ws(s) URL the browser should open. |
RpcConnection |
Service exposing connectionStatus, keepSubscribed, and the raw connection hooks. |
connectionStatus |
Stream of connecting, connected, or reconnecting, deduplicated and primed with the current value. |
keepSubscribed |
Re-runs a subscription stream after every reconnect until the consumer interrupts it. |
rpc-toolkit/rpc/cloudflare/hibernating-rpc
Long-form guide: src/rpc/cloudflare/hibernating-rpc/README.md.
| Export | What it does |
|---|---|
makeHibernatingWebSocketRpc |
Builds accept, message, and close callbacks that serve an RpcGroup over hibernatable Durable Object sockets. |
StreamCheckpoint |
Inside a streaming handler, gives get, put, and clear for a small cursor that survives hibernation. |
fromDurableObjectState |
Builds the state and upgrade ports from a raw workerd DurableObjectState when Alchemy is not in use. |
rpc-toolkit/rpc/cloudflare/alchemy/rpc-worker
| Export | What it does |
|---|---|
RpcWorker |
Re-export of Alchemy's Cloudflare.RpcWorker for Effect RPC over a Worker service binding. |
rpc-toolkit/rpc/cloudflare/alchemy/durable-rpc-worker
| Export | What it does |
|---|---|
DurableRpcWorker |
Declares an Alchemy Worker plus a single Durable Object that serves an RpcGroup over hibernating WebSockets behind one URL. |
Usage
Declare who may call an RPC
The contract declares a Role Cannotation and attaches it to a group and to one endpoint. The server layer verifies headers and provides CurrentUser; the client layer adds the headers. Lifted from src/rpc/cannotation/cannotation.test.ts.
import { Context, Effect, Layer, Option, Schema } from 'effect';
import { Headers } from 'effect/unstable/http';
import { Rpc, RpcGroup } from 'effect/unstable/rpc';
import { Cannotation } from 'rpc-toolkit/rpc/cannotation';
class CurrentUser extends Context.Service<
CurrentUser,
{ readonly id: string; readonly role: string }
>()('app/CurrentUser') {}
class Forbidden extends Schema.Error<Forbidden>('app/Forbidden')({
_tag: Schema.tag('Forbidden'),
reason: Schema.String,
}) {}
type Role = 'admin' | 'user';
const Role = Cannotation.make<Role>()('app/Role', {
provides: CurrentUser,
error: Forbidden,
client: true,
});
const WhoAmI = Rpc.make('WhoAmI', { success: Schema.String });
const Ban = Rpc.make('Ban', { success: Schema.String }).pipe(
Role.with('admin'),
);
const Users = Role.with('user')(RpcGroup.make(WhoAmI, Ban));
// server
const RoleLive = Role.layer(({ value, headers }) =>
Effect.gen(function* () {
const role = headers['x-role'] ?? 'user';
if (Option.isSome(value) && value.value === 'admin' && role !== 'admin') {
return yield* new Forbidden({ reason: 'admin only' });
}
return { id: headers['x-user'] ?? 'anon', role };
}),
);
// client
const RoleClient = Role.clientLayer(({ request, next }) =>
next({
...request,
headers: Headers.fromInput({ 'x-user': 'u1', 'x-role': 'user' }),
}),
);
Role.get(Users.requests.get('Ban'))isadmin;WhoAmIinheritsuser. Nearest Wins, no merging.layerreceives the resolved value and the native middleware options; what it returns is provided asCurrentUserto handlers.- A Cannotation that
requiresa service must be attached before the one thatprovidesit. The later attachment wraps the earlier one. client: truemakes the client layer mandatory when building anRpcClient.
Serve a stream that survives hibernation
The server rechecks authorization on every call, charges admission only on fresh calls, and checkpoints the stream cursor. DurableRpcWorker deploys it as one Worker plus one Durable Object. Trimmed from docs/websocket-example.md.
// server.ts
import { Effect, Layer, Option, Schema, Stream } from 'effect';
import { StreamCheckpoint } from 'rpc-toolkit/rpc/cloudflare/hibernating-rpc';
import { InvocationKind } from 'rpc-toolkit/rpc/invocation';
import { Access, Counter } from './contract.js';
export const makeHandlers = (auth: {
authorize: (token?: string) => Effect.Effect<void, Forbidden>;
checkRateLimit: (token?: string) => Effect.Effect<void, Forbidden>;
}) =>
Layer.merge(
Access.layer(({ headers }) =>
Effect.gen(function* () {
yield* auth.authorize(headers.authorization);
if ((yield* InvocationKind) === 'fresh') {
yield* auth.checkRateLimit(headers.authorization);
}
}),
),
Counter.toLayer({
watch: () =>
Stream.unwrap(
Effect.gen(function* () {
const checkpoint = yield* StreamCheckpoint(Schema.Number);
let cursor = Option.getOrElse(
yield* checkpoint.get().pipe(Effect.orDie),
() => 0,
);
return Stream.repeatEffect(
Effect.gen(function* () {
yield* Effect.sleep('1 second');
yield* checkpoint.put(++cursor).pipe(Effect.orDie);
return cursor;
}),
);
}),
),
}),
);
// counter-worker.ts
export default class CounterWorker extends DurableRpcWorker<CounterWorker>()(
'CounterWorker',
{ main: import.meta.filename, schema: Counter },
Effect.sync(() => makeHandlers(authorization)),
) {}
- On wake, every persisted streaming request is replayed through server middleware with
InvocationKindset toreplay. Authorization must check current permission each time; Connection Identity alone is not current authorization. - Replay reuses the original request headers. It does not fetch fresh client credentials.
- Streaming handlers are re-run, not resumed. Read the checkpoint first and keep the pre-stream section idempotent.
- Attachments hold about 2 KB per socket, shared by the connection value and every in-flight stream. Store cursors, not payloads.
- Continuous revocation during an uninterrupted stream stays application policy; the toolkit only rechecks at fresh calls and replays.
Keep a browser subscription alive across reconnects
layerWebSocketProtocol wires the transport; keepSubscribed restarts the stream whenever a new connection is established. Trimmed from docs/websocket-example.md.
import { Effect, Layer, Stream } from 'effect';
import { Headers } from 'effect/unstable/http';
import { RpcClient, RpcSerialization } from 'effect/unstable/rpc';
import {
keepSubscribed,
layerWebSocketProtocol,
} from 'rpc-toolkit/rpc/websocket-client';
import { Access, Counter } from './contract.js';
export const watchCounter = (
url: string,
getToken: () => string,
onValue: (value: number) => void,
) =>
Effect.gen(function* () {
const client = yield* RpcClient.make(Counter);
yield* keepSubscribed(() => client.watch()).pipe(
Stream.runForEach((value) => Effect.sync(() => onValue(value))),
);
}).pipe(
Effect.provide(
Layer.merge(
layerWebSocketProtocol({
url,
serialization: RpcSerialization.layerJson,
}),
Access.clientLayer(({ request, next }) =>
next({
...request,
headers: Headers.fromInput({ authorization: getToken() }),
}),
),
),
),
Effect.scoped,
);
- A subscription restarted after reconnect is a Fresh Call: client middleware runs again, so
getTokenis called again, and the server charges admission again. - Hibernation Replay happens on the existing connection and is invisible to the client; only a dropped socket triggers
keepSubscribed. - Errors propagate. Interrupting the consumer stops the restart loop for good.
- Use
Layer.provideMergewhen your own code also needsRpcConnection; plainLayer.providehides it.