You are an Effect TypeScript expert specializing in serving RPC groups with RpcServer from effect/unstable/rpc.
Everything ships from the effect package under effect/unstable/rpc — there is no @effect/rpc package in v4. This skill covers the server side: implementing handlers, transports, serialization, middleware, and lifecycle. For defining Rpc/RpcGroup contracts see the effect-rpc-api skill; for building clients see the effect-rpc-client skill; for cluster entities see the effect-rpc-cluster skill; for HttpRouter/HttpServer fundamentals see the effect-http-server skill.
Effect Source Reference
The Effect v4 source is at ~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/. Read it directly when in doubt — these modules change between betas.
Key files:
packages/effect/src/unstable/rpc/RpcServer.ts—make,makeNoSerialization,layer,layerHttp, everylayerProtocol*/makeProtocol*,toHttpEffect*, theProtocolservicepackages/effect/src/unstable/rpc/RpcGroup.ts—toLayer,toLayerHandler,toHandlers,accessHandler,of, handler type derivationpackages/effect/src/unstable/rpc/Rpc.ts—ServerClient,Handler,ToHandlerFn,ResultFrom,fork,uninterruptible,ServicesServerpackages/effect/src/unstable/rpc/RpcMiddleware.ts—Serviceconstructor, server middleware function shape,layerClientpackages/effect/src/unstable/rpc/RpcSerialization.ts—json,ndjson,jsonRpc,ndJsonRpc,msgPackparsers and their layerspackages/effect/src/unstable/rpc/RpcMessage.ts— the wire vocabulary (Request,Ack,Interrupt,Eof,Chunk,Exit,Defect,ClientEnd)packages/effect/src/unstable/rpc/RpcWorker.ts—InitialMessagefor worker transportspackages/effect/src/unstable/rpc/RpcTest.ts— in-process test clientpackages/effect/src/unstable/rpc/RpcSchema.ts—ClientAbortcause annotation, stream schema markerspackages/platform/node/test/RpcServer.test.ts+test/fixtures/rpc-{schemas,e2e}.ts— the best end-to-end reference for real wiring across http/ws/tcp transports and every serializationpackages/platform/browser/test/fixtures/rpc-worker.ts— minimal worker-side server entrypoint
Core Model
A running RPC server is four layers snapped together:
RpcGroup.toLayer(handlers) → Layer<Rpc.ToHandler<Rpcs>> (your logic)
Layer.succeed(MyMiddleware)(...) → Layer<Rpc.Middleware<Rpcs>> (wraps handlers)
RpcServer.layerProtocol* → Layer<RpcServer.Protocol> (transport)
RpcSerialization.layer* → Layer<RpcSerialization> (wire format)
RpcServer.layer(group, options?) → consumes all four, runs forever
RpcServer.layer decodes incoming Request messages with the rpc's payload schema, runs the matching handler (wrapped in middleware and a per-request Scope), and encodes the resulting Exit — or stream Chunks — back through the protocol. A handler is just a function:
// Rpc.ToHandlerFn — what you write for each rpc tag
(
payload: Rpc.Payload<R>,
options: {
readonly client: Rpc.ServerClient; // client.id: number; client.annotations; client.annotate(key, value)
readonly requestId: RpcMessage.RequestId; // branded string | number
readonly headers: Headers; // transport headers merged with per-call headers
readonly rpc: R;
}
) =>
| Effect<Success | Deferred<Success, Error>, Error, R> // non-stream rpc
| Stream<Elem, Error, R> // stream rpc
| Effect<Queue.Dequeue<Elem, Error | Cause.Done>, Error, R>; // stream rpc, queue form
Imports used throughout:
import { Cause, Context, Deferred, Effect, Layer, Queue, Schema, Stream } from 'effect';
import { Headers, HttpRouter } from 'effect/unstable/http';
import {
Rpc,
RpcGroup,
RpcMessage,
RpcMiddleware,
RpcSchema,
RpcSerialization,
RpcServer,
RpcTest,
RpcWorker
} from 'effect/unstable/rpc';
Running example group (definition details belong to the effect-rpc-api skill):
class User extends Schema.Class<User>('User')({
id: Schema.String,
name: Schema.String
}) {}
class GetUser extends Rpc.make('GetUser', {
success: User,
payload: { id: Schema.String }
}) {}
class StreamUsers extends Rpc.make('StreamUsers', {
success: User, // element type when stream: true
payload: { id: Schema.String },
stream: true
}) {}
const UserRpcs = RpcGroup.make(GetUser, StreamUsers);
1. Implementing Handlers
group.toLayer(handlers | Effect<handlers>) — the 80% case
Build every handler at once. The build argument can be a plain handlers object or an Effect (for pulling dependencies). Always wrap the object in group.of(...) so type errors point at the offending handler:
const UsersLive = UserRpcs.toLayer(
Effect.gen(function* () {
const db = yield* Database;
return UserRpcs.of({
GetUser: (payload, { headers, client }) => db.findUser(payload.id),
StreamUsers: (payload) => db.changeFeed(payload.id) // Stream<User>
});
})
);
// Layer<Rpc.ToHandler<typeof GetUser | typeof StreamUsers>, never, Database>
The services captured when the layer builds are stored alongside each handler and provided automatically on every request — handler R beyond middleware-provided services becomes a requirement of the layer, not of the server.
Handlers run with a per-request Scope already in context: Effect.addFinalizer / Effect.forkScoped inside a handler are scoped to that single request and cleaned up when it completes, fails, or is interrupted. Scope never appears in the layer's requirements.
Handler metadata
The second argument carries request metadata. client is a Rpc.ServerClient — not a raw number:
GetUser: (payload, { client, requestId, headers }) =>
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ clientId: client.id });
// client.annotations is a Context.Context<never> middleware may have extended
return yield* db.findUser(payload.id);
});
Deferred responses
A non-stream handler may succeed with a Deferred<Success, Error> instead of the value. The server completes the handler fiber immediately (releasing its concurrency permit) and sends the final Exit only when the deferred resolves — invisible to the client:
GetUserDeferred: (payload) => {
const deferred = Deferred.makeUnsafe<User>();
// complete later — from a webhook, another fiber, a queue worker...
Deferred.doneUnsafe(deferred, Effect.succeed(new User({ id: '1', name: 'John' })));
return Effect.succeed(deferred);
};
Rpc.fork and Rpc.uninterruptible — handler wrappers
These wrap the handler's return value (they are not rpc options):
GetUser: (payload) => db.findUser(payload.id).pipe(Rpc.fork), // skip the concurrency semaphore
Charge: (payload) => chargeOnce(payload).pipe(Rpc.uninterruptible), // run even through aborts/shutdown
Both: (payload) => work(payload).pipe(Rpc.wrap({ fork: true, uninterruptible: true }))
Rpc.fork exempts one handler from the server's concurrency semaphore. Rpc.uninterruptible forks the handler fiber uninterruptibly, so client aborts and server shutdown cannot cancel it mid-flight.
group.toLayerHandler(tag, handler | Effect<handler>) — one handler per layer
Keeps handlers with wildly different dependencies in separate files. The server requires the union Rpc.ToHandler<Rpcs>, so a missing tag is a compile error at the composition site:
const GetUserLive = UserRpcs.toLayerHandler(
'GetUser',
Effect.gen(function* () {
const db = yield* Database;
return (payload) => db.findUser(payload.id);
})
);
const HandlersLive = Layer.mergeAll(GetUserLive, StreamUsersLive);
group.toHandlers(handlers) — raw context form
Returns Effect<Context.Context<Rpc.ToHandler<R>>> instead of a Layer. Use when providing handlers manually to RpcServer.make or composing contexts by hand.
group.accessHandler(tag) — call one handler directly
Resolves a single handler with its captured services already attached. The returned function takes (payload, { client, requestId, headers }) — the rpc field is injected for you (pass a fresh mutable object; the implementation assigns options.rpc). The easiest way to unit-test one handler:
const user = yield* UserRpcs.accessHandler('GetUser').pipe(
Effect.flatMap((handler) =>
handler(
{ id: 'u1' },
{
client: new Rpc.ServerClient(0),
requestId: RpcMessage.RequestId(1),
headers: Headers.empty
}
)
),
Effect.provide(UsersLive)
);
2. Starting a Server — RpcServer.layer / make / layerHttp
RpcServer.layer(group, options?)
Transport-agnostic. Requires a Protocol, the handlers, any middleware implementations, and Rpc.ServicesServer<Rpcs> (services your schemas need to decode payloads / encode results — usually never):
const ServerLayer = RpcServer.layer(UserRpcs, {
concurrency: 'unbounded', // default
disableFatalDefects: false, // default — see below
disableTracing: false,
spanPrefix: 'RpcServer', // span per request: `${spanPrefix}.${tag}`
spanAttributes: { service: 'users' }
}).pipe(
Layer.provide(UsersLive),
Layer.provide(RpcServer.layerProtocolHttp({ path: '/rpc' })),
Layer.provide(RpcSerialization.layerNdjson)
// the http/websocket protocols additionally need HttpRouter (see §3)
);
Option semantics (verified against makeNoSerialization):
concurrency: number | 'unbounded'(default'unbounded') — a singleSemaphorefor the whole server instance, shared across all clients and requests; it is not per-connection.Rpc.fork(...)opts an individual handler out.disableFatalDefects: boolean(defaultfalse) — by default a defect in a handler (adiewith no interruption) is treated as a protocol-level fault: the server sends a connectionDefectmessage and the client fails every in-flight request on that connection withCause.die. Withtrue, the defect is delivered as that one request'sExit. Production servers usually wanttrue. Either way the defect crosses the wire encoded with the rpc's defect schema — the defaultSchema.Defect()keeps anError's name/message but drops stacks; declaredefect: Schema.Defect({ includeStack: true })on the contract (see theeffect-rpc-apiskill) to preserve them.- Undecodable payloads never reach the handler: the server answers that one request with a die exit carrying the schema issue string (regardless of
disableFatalDefects); the connection stays up. disableTracing/spanPrefix/spanAttributes— each request runs in a span named${spanPrefix}.${tag}(default prefixRpcServer), with the client's span as parent when the transport supports span propagation.
RpcServer.make(group, options?) is the Effect form — Effect<never, ...> that runs the server loop forever; layer is exactly Layer.effectDiscard(Effect.forkScoped(make(group, options))).
RpcServer.layerHttp({ group, path, protocol?, ...options }) — one-call HTTP setup
Note the different calling convention: the group goes inside the options object, and protocol defaults to 'websocket', not 'http':
const ServerLayer = RpcServer.layerHttp({
group: UserRpcs,
path: '/rpc',
protocol: 'http', // or 'websocket' (the default!)
disableFatalDefects: true,
streamBufferSize: 16 // framed HTTP only; default 16
}).pipe(
Layer.provide(UsersLive),
Layer.provide(RpcSerialization.layerNdjson)
);
Serving multiple groups
layer/layerHttp take exactly one group. Either merge the contracts — UserRpcs.merge(AdminRpcs) (see the effect-rpc-api skill) — and serve one server, or build a complete RpcServer.layer(...) + layerProtocolHttp({ path }) composition per group at distinct paths and Layer.mergeAll them: each composition encapsulates its own Protocol, so several can register routes on the same HttpRouter without clashing.
Full Node wiring
The protocol layers register routes on HttpRouter; HttpRouter.serve provides the router and turns it into an HTTP app:
import { createServer } from 'node:http';
import { NodeHttpServer, NodeRuntime } from '@effect/platform-node';
const Main = HttpRouter.serve(ServerLayer).pipe(
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
);
NodeRuntime.runMain(Layer.launch(Main));
See the effect-http-server skill for HttpRouter.serve options (middleware, disableLogger, disableListenLog) and adding sibling routes.
3. Protocol Layers — Picking a Transport
| Layer | Requires | supportsAck | span propagation | transferables |
|---|---|---|---|---|
RpcServer.layerProtocolHttp({ path, streamBufferSize? }) |
RpcSerialization, HttpRouter |
no | no | no |
RpcServer.layerProtocolWebsocket({ path }) |
RpcSerialization, HttpRouter |
yes | yes | no |
RpcServer.layerProtocolSocketServer |
RpcSerialization, SocketServer |
yes | yes | no |
RpcServer.layerProtocolStdio |
RpcSerialization, Stdio |
yes | yes | no |
RpcServer.layerProtocolWorkerRunner |
WorkerRunner.WorkerRunnerPlatform |
yes | yes | yes |
supportsAck is what enables stream backpressure (§7). Each layer has a makeProtocol* Effect counterpart for inline composition, and makeProtocolWithHttpEffect / makeProtocolWithHttpEffectWebsocket return { protocol, httpEffect } when you need both (§5).
- HTTP (
layerProtocolHttp) registers aPOSTroute. Each HTTP request is a short-lived client: all messages in the body are processed, then the response is either buffered or streamed depending on serialization framing (§4). For framed responses,streamBufferSizebounds the response queue and defaults to16; pass'unbounded'to restore unbounded buffering. The option is also accepted bylayerHttp,makeProtocolHttp,makeProtocolWithHttpEffect, andtoHttpEffect. HTTP request headers are prepended to every rpc request's headers — soauthorizationetc. is visible to middleware without client cooperation. - WebSocket (
layerProtocolWebsocket) registers aGETroute that upgrades the connection. Upgrade-request headers are merged into every rpc's headers. Full duplex, acks, span propagation. - TCP (
layerProtocolSocketServer) serves raw sockets:
import { NodeSocketServer } from '@effect/platform-node';
const TcpServer = RpcServer.layer(UserRpcs).pipe(
Layer.provide(UsersLive),
Layer.provide(RpcServer.layerProtocolSocketServer),
Layer.provide(NodeSocketServer.layer({ port: 9000 })),
Layer.provide(RpcSerialization.layerMsgPack) // must be a framed format (§4)
);
SocketServer layers and the raw socket surface underneath are covered by the effect-socket skill.
- Stdio (
layerProtocolStdio) serves RPC over the current process's stdin/stdout — for CLI subprocess protocols (LSP-style tooling, plugin hosts). When stdin ends, the protocol interrupts the server fiber so the process can exit:
import { NodeRuntime, NodeStdio } from '@effect/platform-node';
const StdioMain = RpcServer.layer(UserRpcs).pipe(
Layer.provide(UsersLive),
Layer.provide(RpcServer.layerProtocolStdio),
Layer.provide(RpcSerialization.layerNdjson),
Layer.provide(NodeStdio.layer)
);
NodeRuntime.runMain(Layer.launch(StdioMain));
- Worker — see §9.
Inspecting the live protocol
RpcServer.Protocol is a Context.Service you can read to branch on transport capabilities (tests use this to skip backpressure assertions on HTTP):
const { supportsAck, supportsTransferables, supportsSpanPropagation, clientIds, initialMessage } =
yield* RpcServer.Protocol;
The complete protocol surface also includes:
{
readonly supportsNotifications: boolean;
}
supportsNotifications is true for sockets, stdio, workers, and framed HTTP; it is false for buffered unframed HTTP.
Server-originated calls and notifications use RpcMessage.RequestEncoded in FromServerEncoded. Set isNotification: true for notifications; JSON-RPC serialization omits their id. Unframed HTTP buffers normal responses and intentionally drops notifications because it cannot deliver them before the response closes.
4. Serialization — Picking a Wire Format
Provide exactly one RpcSerialization layer. The load-bearing property is includesFraming — whether the format can split a byte stream back into messages:
| Layer | Content-Type | Framed? | Notes |
|---|---|---|---|
RpcSerialization.layerJson |
application/json |
no | whole-payload JSON |
RpcSerialization.layerNdjson |
application/ndjson |
yes | newline-delimited JSON |
RpcSerialization.layerJsonRpc({ contentType? }) |
application/json |
no | JSON-RPC 2.0 interop |
RpcSerialization.layerNdJsonRpc({ contentType? }) |
application/json-rpc |
yes | JSON-RPC 2.0, newline-framed |
RpcSerialization.layerMsgPack |
application/msgpack |
yes | msgpackr useRecords: true; schema payloads still use JSON codecs |
RpcSerialization.layerSchemaBinary(options?) |
application/vnd.effect.rpc+schema-binary |
yes | schema-derived binary payloads and envelopes |
Schema-aware serialization (rc.112)
RpcSerialization.RpcSerialization, RpcClient.Protocol, and
RpcServer.Protocol now require codecFor: RpcSerialization.CodecFor:
type CodecFor = <S extends Schema.Top>(schema: S) =>
Schema.Codec<S['Type'], unknown, S['DecodingServices'], S['EncodingServices']>;
Forward the selected serialization's codecFor when implementing a protocol.
This selects codecs for payloads, successes, errors, defects, and stream elements;
envelope framing remains the serialization's responsibility. Existing JSON,
NDJSON, JSON-RPC, and MsgPack wire formats keep their JSON-compatible schema
codecs. Workers supply Schema.toCodecJson themselves over structured clone and
still need no serialization layer. Cluster network traffic follows the protocol
codec; cluster persistence continues to use JSON.
layerSchemaBinary({ maxFrameSize?, fingerprintPayloads? }) must be selected on
both peers. The default maximum frame size is 16 MiB. Envelopes use fingerprints
and a connection-local string dictionary. Payload fingerprints default to false
to allow compatible schema evolution; enable them for strict layout agreement.
This is an Effect RPC format, not generic MsgPack or JSON-RPC interoperability.
See effect-schema-composition for binary layout/ownership constraints.
Rules, verified against the protocol implementations and the e2e matrix:
- Raw TCP sockets need a framed format (
ndjson,ndJsonRpc,msgPack). Plainjsoncannot split the byte stream — decoding breaks as soon as two messages share a chunk. - WebSocket frames messages itself, so any format works there — the e2e suite runs ws with json, ndjson, msgpack, and jsonRpc.
- HTTP POST works with both, with different response behavior: with an unframed format the server buffers all responses and returns one JSON array when every request finishes (a streaming rpc arrives as one big batch at the end); with a framed format the server returns a chunked streaming response and chunks arrive incrementally. Use
layerNdjson(or msgpack) over HTTP if you serve streaming rpcs. - The response
content-typeis the serialization'scontentType. RpcSerialization.layerJsonRpc()/layerNdJsonRpc()speak JSON-RPC 2.0: rpc tags map tomethod, batched arrays are preserved, and internal signals travel as@effect/rpc/Ack-style methods. Use for interop with non-Effect JSON-RPC clients.RpcSerialization.makeMsgPack(options)customizes msgpackr (useRecords,useFloat32, ...); wrap withLayer.succeed(RpcSerialization.RpcSerialization)(RpcSerialization.makeMsgPack({ ... })).
Client and server must use the same serialization.
5. Embedding in an Existing HTTP App
When you want the RPC handler as a value to mount yourself — alongside other routes, behind your own middleware, or in a Fetch-style handler — use the toHttpEffect helpers instead of layerProtocolHttp.
RpcServer.toHttpEffect(group, options?) starts the server in the current Scope and returns the request-handling Effect (Effect<HttpServerResponse, never, Scope | HttpServerRequest>). toHttpEffectWebsocket is the upgrade-handler equivalent. HTTP options are disableTracing / spanPrefix / spanAttributes / disableFatalDefects / streamBufferSize — note there is no concurrency option on these two.
const RpcRoute = Layer.effectDiscard(
Effect.gen(function* () {
const router = yield* HttpRouter.HttpRouter;
const rpcHandler = yield* RpcServer.toHttpEffect(UserRpcs, {
disableFatalDefects: true
});
yield* router.add('POST', '/rpc', rpcHandler);
})
).pipe(Layer.provide([UsersLive, RpcSerialization.layerNdjson]));
// merge with your other route layers and serve as usual
const Main = HttpRouter.serve(Layer.mergeAll(RpcRoute, HealthRoutes)).pipe(
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
);
Layer.effectDiscard supplies the Scope that keeps the forked RPC server alive for the lifetime of the layer.
Lower still: RpcServer.makeProtocolWithHttpEffect({ streamBufferSize? }) / makeProtocolWithHttpEffectWebsocket give you { protocol, httpEffect } so you can provide the Protocol to RpcServer.make yourself — useful when one process must mount the same server behind several routes or compose with a hand-built runtime. makeProtocolWithHttpEffect is a function and must be called, even when no options are supplied: yield* RpcServer.makeProtocolWithHttpEffect().
6. Implementing Middleware
A middleware is a Context.Service whose value is a function wrapping handler execution. Definition (shared with the contract — see the effect-rpc-api skill) and server implementation:
class CurrentUser extends Context.Service<CurrentUser, User>()('CurrentUser') {}
class Unauthorized extends Schema.Error<Unauthorized>('Unauthorized')({
_tag: Schema.tag('Unauthorized')
}) {}
class AuthMiddleware extends RpcMiddleware.Service<AuthMiddleware, {
provides: CurrentUser; // injected into the handler
requires: never; // services the middleware itself needs at runtime
clientError: never; // error type only the client-side wrapper can produce
}>()('AuthMiddleware', {
error: Unauthorized, // wire-encodable failure this middleware may produce
requiredForClient: true // clients must provide RpcMiddleware.layerClient(...) or fail to compile
}) {}
// Attach: per-rpc `rpc.middleware(AuthMiddleware)` or whole-group `group.middleware(AuthMiddleware)`
const SecureRpcs = UserRpcs.middleware(AuthMiddleware);
Server implementation layer
The middleware function receives (effect, options) where options is { client, requestId, rpc, payload, headers } (note: payload is unknown here — middleware is generic over rpcs). Provide it as a Layer:
const AuthLive = Layer.effect(AuthMiddleware)(
Effect.gen(function* () {
const tokens = yield* TokenService; // middleware dependencies
return AuthMiddleware.of((effect, { headers, client }) =>
tokens.verify(headers.authorization).pipe(
Effect.mapError(() => new Unauthorized()),
Effect.flatMap((user) => {
client.annotate(CurrentUser, user); // optional: visible to later requests' middleware via client.annotations
return Effect.provideService(effect, CurrentUser, user);
})
)
);
})
);
// dependency-free middleware: Layer.succeed(AuthMiddleware)(AuthMiddleware.of(...))
Then Layer.provide(AuthLive) to RpcServer.layer along with the handlers — the server's Rpc.Middleware<Rpcs> requirement makes forgetting it a compile error.
Semantics
- Ordering: middlewares are applied in attach order, each wrapping the previous result — so the last attached middleware is outermost and runs first on the way in; services it provides are visible to earlier-attached middleware and the handler. In the platform fixture,
Rpc.make(...).middleware(TimingMiddleware)inside a group with.middleware(AuthMiddleware)runs Auth (outer) then Timing (inner). - Failure: failing with the declared
errorschema becomes that request'sExitand is typed on the client. Dying inside middleware follows the same fatal-defect rules as handlers (§2). - Streams: for stream rpcs the middleware wraps the entire stream-running effect — it runs once per request and stays active for the stream's lifetime; provided services reach the stream handler.
- Chaining: a middleware config may declare
requires: SomeServicethat another middlewareprovides— the type system forces the providing middleware to be attached too. - Cross-cutting observability: middleware with no
providesis the place for metrics/timing — wrap withEffect.tap/Effect.tapDefect/Effect.ensuring:
class TimingMiddleware extends RpcMiddleware.Service<TimingMiddleware>()('TimingMiddleware') {}
const TimingLive = Layer.succeed(TimingMiddleware)(
TimingMiddleware.of((effect) =>
effect.pipe(
Effect.tap(Metric.update(rpcSuccesses, 1)),
Effect.tapDefect(() => Metric.update(rpcDefects, 1)),
Effect.ensuring(Metric.update(rpcCount, 1))
)
)
);
requiredForClient: true middlewares additionally need RpcMiddleware.layerClient(...) on every client (including RpcTest) — see the effect-rpc-client skill.
7. Streaming Handlers
A stream: true rpc's handler returns either a Stream or an Effect producing a Queue.Dequeue:
// Stream form — simplest
StreamUsers: (payload) =>
db.changeFeed(payload.id).pipe(Stream.map((row) => new User(row)));
// Queue form — push values from background fibers; the per-request Scope cleans up
StreamUsers: Effect.fnUntraced(function* (payload) {
const queue = yield* Queue.bounded<User, Cause.Done>(16);
yield* Effect.addFinalizer(() => Effect.log('subscription closed'));
yield* pollSource(payload.id, queue).pipe(Effect.forkScoped); // dies with the request
return queue;
});
Semantics, verified against RpcServer.makeNoSerialization:
- The server batches available values into
Chunkmessages (Stream.runForEachArray/Queue.takeAll), so one message can carry many elements. - Backpressure exists only on ack-supporting transports (everything except plain HTTP): after writing a chunk the server waits for the client's
Ackbefore pulling more. Over plain HTTP the producer is never throttled. - Ending: a
Streamending ends the response; for the queue form callQueue.end(queue)—Cause.Doneis the end-of-stream signal, andQueue.endonly typechecks when the queue's error channel includes it, so create the queue asQueue.bounded<User, Cause.Done>(16). The client then sees the stream complete with a final voidExit. - Failures: failing the stream with the rpc's declared error fails the client's stream with that typed error.
- The client consumes the result as a
Streamby default or aQueue.Dequeuewith{ asQueue: true }— see theeffect-rpc-clientskill.
8. Interruption, Client Aborts & Graceful Shutdown
Client aborts
When a client interrupts an in-flight call (or a streaming subscription), the server interrupts the handler fiber with the RpcSchema.ClientAbort cause annotation. The request scope closes and finalizers run. To distinguish a client abort from server shutdown, inspect the cause's interrupt reasons:
Never: () =>
Effect.never.pipe(
Effect.onExit((exit) => {
const clientAborted =
exit._tag === 'Failure' &&
exit.cause.reasons.some(
(r) => r._tag === 'Interrupt' && r.annotations.has(RpcSchema.ClientAbort.key)
);
return Effect.log(clientAborted ? 'client cancelled' : 'other interrupt');
})
);
Effect.onInterrupt only receives interruptor fiber ids — use Effect.onExit / Effect.onError when you need the annotation.
Disconnects
- HTTP: if the request's scope closes before responses finish (client went away), the protocol sends an
Interruptfor every request id from that HTTP call. - Sockets/WebSockets: a closed connection is pushed to the protocol's
disconnectsqueue; the server interrupts all of that client's in-flight fibers. - An
Interruptfor an unknown/finished request id is answered withExit.interrupt()— interruption is idempotent.
Rpc.uninterruptible
Handlers that must complete (payment capture, append-then-ack) should return value.pipe(Rpc.uninterruptible) — the fiber is forked uninterruptibly, so client aborts and shutdown wait for it.
Graceful shutdown
When the server layer's scope closes (e.g. NodeRuntime.runMain received SIGINT):
- New messages are rejected (
Effect.interrupt). - Every in-flight handler fiber is interrupted — without the
ClientAbortannotation;Rpc.uninterruptiblehandlers run to completion. - Each finished request's final
Exit(interrupt or result) is still flushed to its client, then aClientEndis sent per client. - The server's finalizer waits on a latch until every client has been ended — shutdown does not race the final writes.
9. Worker-Backed Servers
Run the RPC server inside a Web/Node worker; the parent process is the client (RpcClient.layerProtocolWorker — see the effect-rpc-client skill). The worker protocol does its own structured-clone transport, so no RpcSerialization layer is needed and supportsTransferables is true.
Worker entrypoint (browser shown; for Node use NodeWorkerRunner.layer from @effect/platform-node):
// worker.ts
import { BrowserWorkerRunner } from '@effect/platform-browser';
import { Effect, Layer } from 'effect';
import { RpcServer } from 'effect/unstable/rpc';
const MainLive = RpcServer.layer(UserRpcs).pipe(
Layer.provide(UsersLive),
Layer.provide(RpcServer.layerProtocolWorkerRunner),
Layer.provide(BrowserWorkerRunner.layer)
);
Effect.runFork(Layer.launch(MainLive));
Initial message
A worker client can send one schema-encoded value before any rpc traffic — spawn-time config without an extra round-trip. Server side, read it before/while serving:
class WorkerConfig extends Schema.Class<WorkerConfig>('WorkerConfig')({
apiUrl: Schema.String
}) {}
// inside the worker (requires RpcServer.Protocol in context):
const config = yield* RpcWorker.initialMessage(WorkerConfig);
// fails with NoSuchElementError if the parent provided none
The parent provides it with RpcWorker.layerInitialMessage(WorkerConfig, buildEffect) on the client protocol. Only the worker protocol supports initial messages — protocol.initialMessage is Option.none() everywhere else.
10. Testing Servers
RpcTest.makeClient(group) — in-process, no transport
Wires RpcServer.makeNoSerialization to a no-serialization client. Requests, stream chunks, acks, interrupts, headers, and middleware all behave like production (acks are enabled, so backpressure is testable). Required context: Scope | Rpc.ToHandler<Rpcs> | Rpc.Middleware<Rpcs> | Rpc.MiddlewareClient<Rpcs> — handler layers, middleware layers, and any requiredForClient client layers:
import { assert, it } from '@effect/vitest';
import { RpcClient } from 'effect/unstable/rpc';
class UsersClient extends Context.Service<
UsersClient,
RpcClient.RpcClient<RpcGroup.Rpcs<typeof SecureRpcs>>
>()('UsersClient') {
static layerTest = Layer.effect(UsersClient)(RpcTest.makeClient(SecureRpcs)).pipe(
Layer.provide([UsersLive, AuthLive, TimingLive, AuthClient]) // AuthClient = RpcMiddleware.layerClient(...)
);
}
it.effect('GetUser', () =>
Effect.gen(function* () {
const client = yield* UsersClient;
const user = yield* client.GetUser({ id: '1' });
assert.deepStrictEqual(user, new User({ id: '1', name: 'Logged in user' }));
}).pipe(Effect.provide(UsersClient.layerTest)));
makeClient accepts { flatten?: boolean } mirroring RpcClient.make.
Transport integration tests
For exercising a real transport in-process, use NodeHttpServer.layerTest (provides both HttpServer and HttpClient on a random port) under your normal server+client layers — packages/platform/node/test/RpcServer.test.ts is the template. Framed formats support byte streams; unframed HTTP buffers the response. Verify the selected transport/serialization pair rather than assuming every pairing streams.
Unit-testing one handler
Use group.accessHandler(tag) (§1) — no client, no protocol, just the handler with its services.
11. Custom Protocols & makeNoSerialization
Custom Protocol
RpcServer.Protocol.make (built on the withRun buffering helper) builds a transport from a callback that receives writeRequest — the function you call with decoded FromClientEncoded messages. You return the rest of the service:
const myProtocol = RpcServer.Protocol.make((writeRequest) =>
Effect.gen(function* () {
const disconnects = yield* Queue.make<number>();
// wire your transport: on inbound data → writeRequest(clientId, message)
return {
codecFor: Schema.toCodecJson, // or the selected serialization.codecFor
disconnects,
send: (clientId, response, _transferables) => sendToTransport(clientId, response),
end: (clientId) => Effect.void,
clientIds: Effect.sync(() => connectedIds),
initialMessage: Effect.succeedNone,
supportsAck: true,
supportsTransferables: false,
supportsSpanPropagation: false,
supportsNotifications: true
};
})
);
Writes made before the server loop starts are buffered and replayed — you don't need to sequence startup manually. The message vocabulary lives in RpcMessage (FromClientEncoded = RequestEncoded | AckEncoded | InterruptEncoded | Ping | Eof; FromServerEncoded = ResponseChunkEncoded | ResponseExitEncoded | ResponseDefectEncoded | Pong | ClientProtocolError | RequestEncoded). The final RequestEncoded case is the server-originated request/notification surface.
RpcServer.makeNoSerialization(group, options)
The decoded core with no transport at all: you push FromClient<Rpcs> messages via the returned server.write(clientId, message) / server.disconnect(clientId), and receive decoded FromServer<Rpcs> responses through options.onFromServer. Extra options beyond make: disableSpanPropagation, disableClientAcks (the serialized make derives both from the protocol's capabilities). This is what RpcTest and the cluster runtime build on — reach for it for in-process bridges where schema encoding would be wasted.
Key Patterns
Production HTTP server (handlers + auth middleware + http protocol)
// server/main.ts
import { createServer } from 'node:http';
import { NodeHttpServer, NodeRuntime } from '@effect/platform-node';
import { Effect, Layer } from 'effect';
import { HttpRouter } from 'effect/unstable/http';
import { RpcSerialization, RpcServer } from 'effect/unstable/rpc';
import { SecureRpcs } from '../domain/rpc.ts';
const UsersLive = SecureRpcs.toLayer(
Effect.gen(function* () {
const db = yield* Database;
return SecureRpcs.of({
GetUser: (payload) => db.findUser(payload.id),
StreamUsers: (payload) => db.changeFeed(payload.id)
});
})
);
const RpcLayer = RpcServer.layerHttp({
group: SecureRpcs,
path: '/rpc',
protocol: 'http',
disableFatalDefects: true
}).pipe(
Layer.provide([UsersLive, AuthLive]),
Layer.provide(RpcSerialization.layerNdjson) // framed → streaming rpcs stream over HTTP
);
const Main = HttpRouter.serve(RpcLayer).pipe(
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
);
NodeRuntime.runMain(Layer.launch(Main));
Switch protocol: 'websocket' (and have clients connect a socket) to gain acks/backpressure and span propagation with no other changes.
RPC endpoint inside a larger router
const RpcRoute = Layer.effectDiscard(
Effect.gen(function* () {
const router = yield* HttpRouter.HttpRouter;
yield* router.add('POST', '/rpc', yield* RpcServer.toHttpEffect(UserRpcs, {
disableFatalDefects: true
}));
})
).pipe(Layer.provide([UsersLive, RpcSerialization.layerNdjson]));
const Main = HttpRouter.serve(Layer.mergeAll(RpcRoute, ApiRoutes, HealthRoute)).pipe(
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
);
Streaming subscription with cleanup and abort logging
StreamUsers: Effect.fnUntraced(function* (payload) {
const queue = yield* Queue.bounded<User, Cause.Done>(16);
yield* Effect.addFinalizer(() => Effect.log('feed closed', { id: payload.id }));
yield* db.subscribe(payload.id, (row) => Queue.offerUnsafe(queue, new User(row))).pipe(
Effect.forkScoped // tied to this request; interrupted on client abort
);
return queue;
});
Read-heavy group with bounded writes
const ServerLayer = RpcServer.layer(StoreRpcs, { concurrency: 1 }).pipe(
Layer.provide(
StoreRpcs.toLayer(
Effect.gen(function* () {
const state = yield* Ref.make(initialState);
return StoreRpcs.of({
// reads bypass the server-wide semaphore
Get: () => Ref.get(state).pipe(Rpc.fork),
// writes serialize through concurrency: 1 and survive aborts
Put: (payload) =>
Ref.update(state, applyPut(payload)).pipe(Rpc.uninterruptible)
});
})
)
)
);
Common Mistakes
- Importing from
@effect/rpc. v3 habit; the package does not exist in v4. Everything iseffect/unstable/rpc(and platform layers come from@effect/platform-node/-bun/-browser). - Forgetting
protocol: 'http'onlayerHttp. The default is'websocket'— yourPOST /rpccurl returns 404 and only aGETupgrade route exists. Also notelayerHttptakes{ group, path, ... }as one options bag, whilelayer(group, options)takes the group positionally. - Providing handlers/middleware but no
ProtocolorRpcSerialization.RpcServer.layerrequires all of: handler layer(s), middleware implementation layers, alayerProtocol*, and (for non-worker protocols) aRpcSerialization.layer*. Missing ones surface as unresolved layer requirements. layerJsonon a raw TCP socket server. No framing — decode breaks when messages span chunks. Sockets needlayerNdjson,layerNdJsonRpc(), orlayerMsgPack. (WebSocket is fine withlayerJson— ws frames messages itself.)- Streaming rpcs over
layerProtocolHttp+layerJsonand wondering why chunks arrive all at once. Unframed HTTP buffers the whole response until every request in the call finishes. Framed HTTP streams incrementally with a bounded response queue (streamBufferSize, default 16). HTTP has no RPC acknowledgment protocol, but the bounded queue still backpressures producers; full-duplex transports additionally support RPC acks. - Leaving
disableFatalDefects: falsein production. One handlerdiethen nukes every in-flight request on that connection with a connection-level defect. Settrueto confine defects to the failing request. - Assuming
concurrencyis per-client. It is one semaphore per server instance shared by all clients. UseRpc.forkto exempt cheap read handlers instead of raising the global limit. - Treating
Rpc.fork/Rpc.uninterruptibleas rpc options. They wrap the handler's returned Effect/Stream:db.get(id).pipe(Rpc.fork). There is no{ fork: true }key onRpc.make. - Typing the handler metadata as
clientId: number. It isclient: Rpc.ServerClientwithclient.id,client.annotations, andclient.annotate(key, value). - Implementing middleware as
(payload, next) => .... Server middleware is(effect, { client, requestId, rpc, payload, headers }) => Effect— you wrap the already-built handler effect (andpayloadisunknown). The{ request, next }shape belongs to client middleware (RpcMiddleware.layerClient). - Expecting first-attached middleware to run first. The last attached mid
…(truncated)