1---
2title: Effect RPC
3description: Build a typed RPC API with Effect's Rpc module and deploy it as a Cloudflare Worker.
4sidebar:
5 order: 4
6---
7
8Effect RPC exposes a typed, schema-validated surface across a
9**trust boundary** — a web app or an external service calling into
10your Worker. For internal Worker-to-Worker or Worker-to-DO calls,
11use [Schemaless RPC](/apis/schemaless) instead; for choosing between
12the modalities, see [RPC](/apis).
13
14The [HTTP API guide](/cloudflare/apis/effect-http-api) showed how to build
15REST-style endpoints with schema validation. Effect RPC takes a
16different angle — you define **procedures** instead of HTTP
17endpoints, and you get a fully typed client for free with no URL
18construction or manual serialization.
19
20The transport is still HTTP under the hood, and both patterns
21produce the same `HttpEffect` type, so the wiring story is
22identical to the HTTP API guide:
23
241. **Define schemas outside.** Domain types and tagged errors,
25 importable by both server and client.
262. **Construct the service inside the Worker's Construction phase.**
27 `RpcGroup.toLayer` is pure construction — safe to call at plan
28 time. Don't `yield*` the running server; it can't run without a
29 request.
303. **Return `{ fetch }`** where `fetch` is the `HttpEffect`
31 produced by `RpcServer.toHttpEffect`.
324. **Bonus:** deploy and call the procedures from a typed client
33 that shares the exact same `RpcGroup` value.
34
35## 1. Define the schemas
36
37Domain model and error types — pure schemas, no runtime concerns:
38
39```typescript
40// src/task.ts
41import * as Schema from "effect/Schema";
42
43export class Task extends Schema.Class<Task>("Task")({
44 id: Schema.String,
45 title: Schema.String,
46 completed: Schema.Boolean,
47}) {}
48
49export class TaskNotFound extends Schema.TaggedClass<TaskNotFound>()(
50 "TaskNotFound",
51 { id: Schema.String },
52) {}
53
54export class CreateTaskFailed extends Schema.TaggedClass<CreateTaskFailed>()(
55 "CreateTaskFailed",
56 { message: Schema.String },
57) {}
58```
59
60RPC errors are schema-backed tagged classes. The client receives
61them as typed values you can `match` on — not raw HTTP status codes.
62
63## 2. Declare the RPCs
64
65Each `Rpc.make` declares one procedure: a name, a payload schema,
66a success schema, and an error schema. `RpcGroup.make` collects them
67into a single value that both the server and the client will share.
68
69```typescript
70// src/rpcs.ts
71import * as Schema from "effect/Schema";
72import { Rpc, RpcGroup } from "effect/rpc";
73import { Task, TaskNotFound, CreateTaskFailed } from "./task.ts";
74
75const getTask = Rpc.make("getTask", {
76 success: Task,
77 error: TaskNotFound,
78 payload: { id: Schema.String },
79});
80
81const createTask = Rpc.make("createTask", {
82 success: Task,
83 error: CreateTaskFailed,
84 payload: { title: Schema.String },
85});
86
87export class TaskRpcs extends RpcGroup.make(getTask, createTask) {}
88```
89
90`TaskRpcs` is just a value-level description. Nothing executes yet.
91
92## 3. Build the Worker
93
94Create `src/worker.ts` with an empty Construction phase:
95
96```typescript
97// src/worker.ts
98import * as Cloudflare from "alchemy/Cloudflare";
99import * as Effect from "effect/Effect";
100
101export default Cloudflare.Worker(
102 "Worker",
103 { main: import.meta.url },
104 Effect.gen(function* () {
105 return {};
106 }),
107);
108```
109
110The generator inside `Cloudflare.Worker` is the **Construction phase** — it
111runs both at *plan time* and at *runtime*. Only do pure construction
112or resource-binding factories here; never `yield*` work that needs
113an incoming request.
114
115### 3a. Bind an R2 bucket for storage
116
117Tasks need durable storage. Declare an `Bucket` resource and bind
118it inside the constructor — `bind()` returns a typed handle whose `get` /
119`put` / `delete` / `list` methods we'll call from the handlers
120below.
121
122```typescript
123// src/bucket.ts
124import * as Cloudflare from "alchemy/Cloudflare";
125
126export const Tasks = Cloudflare.R2.Bucket("Tasks");
127```
128
129```diff lang="typescript"
130+import { Tasks } from "./bucket.ts";
131
132export default Cloudflare.Worker(
133 "Worker",
134 { main: import.meta.url },
135 Effect.gen(function* () {
136+ const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
137
138 return {};
139 }),
140);
141```
142
143We'll provide the runtime side of this binding
144(`Cloudflare.R2.ReadWriteBucketBinding`) in step 3c when we wire up the
145`fetch` handler.
146
147### 3b. Construct the handlers inside the constructor
148
149`TaskRpcs.toLayer` takes an `Effect` that returns one handler per
150procedure and produces a `Layer`. Like `HttpApiBuilder.group`, this
151is pure construction — it builds a value, it doesn't run the server.
152
153> Don't `yield* TaskRpcs.toLayer(...)` here. Building a layer is
154> fine, but actually executing the procedures requires an incoming
155> request. The constructor only constructs; the work happens later, on each
156> `fetch` call.
157
158```diff lang="typescript"
159 Effect.gen(function* () {
160 const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
161
162+ const handlersLayer = TaskRpcs.toLayer({
163+ getTask: ({ id }) =>
164+ Effect.gen(function* () {
165+ const object = yield* tasks.get(id);
166+ if (!object) {
167+ return yield* Effect.fail(new TaskNotFound({ id }));
168+ }
169+ return Schema.decodeUnknownSync(Task)(JSON.parse(yield* object.text()));
170+ }).pipe(Effect.orDie),
171+ createTask: ({ title }) =>
172+ Effect.gen(function* () {
173+ const task = new Task({
174+ id: crypto.randomUUID(),
175+ title,
176+ completed: false,
177+ });
178+ yield* tasks.put(task.id, JSON.stringify(task));
179+ return task;
180+ }).pipe(
181+ Effect.catchTag("R2Error", (error) =>
182+ Effect.fail(new CreateTaskFailed({ message: error.message })),
183+ ),
184+ ),
185+ });
186
187 return {};
188 }),
189```
190
191Each handler receives the typed payload and returns an `Effect`
192that either succeeds with the declared success schema or fails with
193the declared error schema. `getTask` uses `Effect.orDie` to turn
194unexpected R2 failures into 500s — `TaskNotFound` is the only
195client-visible error. `createTask` maps R2 failures into the
196declared `CreateTaskFailed` error so the client can match on it.
197
198### 3c. Return `{ fetch }`
199
200`RpcServer.toHttpEffect` converts the `RpcGroup` into an
201`HttpEffect` — exactly the type Workers expect for `fetch`. We
202provide two layers to it via `Layer.mergeAll`:
203
204- The `handlersLayer` we just built.
205- `RpcSerialization.layerNdjson` — one frame per line, so streaming
206 procedures work out of the box and the wire format matches what
207 `Cloudflare.RpcWorker.bind` clients speak. When nothing streams
208 and no bound consumer is involved, the buffered
209 `RpcSerialization.layerJson` is also an option.
210
211Unlike the HTTP API approach, RPC doesn't need `HttpPlatform.layer`
212or `Etag.layer` — the RPC server handles message framing internally.
213
214```diff lang="typescript"
215 return {
216+ fetch: RpcServer.toHttpEffect(TaskRpcs).pipe(
217+ Effect.provide(
218+ Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson),
219+ ),
220+ ),
221 };
222```
223
224### Putting it together
225
226```typescript
227// src/worker.ts
228import * as Cloudflare from "alchemy/Cloudflare";
229import * as Effect from "effect/Effect";
230import * as Layer from "effect/Layer";
231import * as Schema from "effect/Schema";
232import { RpcSerialization, RpcServer } from "effect/rpc";
233import { Tasks } from "./bucket.ts";
234import { CreateTaskFailed, Task, TaskNotFound } from "./task.ts";
235import { TaskRpcs } from "./rpcs.ts";
236
237export default Cloudflare.Worker(
238 "Worker",
239 { main: import.meta.url },
240 Effect.gen(function* () {
241 const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
242
243 const handlersLayer = TaskRpcs.toLayer({
244 getTask: ({ id }) =>
245 Effect.gen(function* () {
246 const object = yield* tasks.get(id);
247 if (!object) {
248 return yield* Effect.fail(new TaskNotFound({ id }));
249 }
250 return Schema.decodeUnknownSync(Task)(
251 JSON.parse(yield* object.text()),
252 );
253 }).pipe(Effect.orDie),
254 createTask: ({ title }) =>
255 Effect.gen(function* () {
256 const task = new Task({
257 id: crypto.randomUUID(),
258 title,
259 completed: false,
260 });
261 yield* tasks.put(task.id, JSON.stringify(task));
262 return task;
263 }).pipe(
264 Effect.catchTag("R2Error", (error) =>
265 Effect.fail(new CreateTaskFailed({ message: error.message })),
266 ),
267 ),
268 });
269
270 return {
271 fetch: RpcServer.toHttpEffect(TaskRpcs).pipe(
272 Effect.provide(
273 Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson),
274 ),
275 ),
276 };
277 }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
278);
279```
280
281## 4. Add to the Stack
282
283```typescript
284// alchemy.run.ts
285import * as Alchemy from "alchemy";
286import * as Cloudflare from "alchemy/Cloudflare";
287import * as Effect from "effect/Effect";
288import Worker from "./src/worker.ts";
289
290export default Alchemy.Stack(
291 "TaskRpc",
292 { providers: Cloudflare.providers(), state: Cloudflare.state() },
293 Effect.gen(function* () {
294 const worker = yield* Worker;
295 return { url: worker.url };
296 }),
297);
298```
299
300## 5. Deploy
301
302```sh
303alchemy deploy
304```
305
306## Bonus: call it from a typed client
307
308Because `TaskRpcs` is just a value, the same group drives a fully
309typed client — no codegen. `client.createTask` accepts
310`{ title: string }` and returns `Effect<Task, CreateTaskFailed>`.
311
312```typescript
313// scripts/client.ts
314import * as Effect from "effect/Effect";
315import * as Layer from "effect/Layer";
316import * as FetchHttpClient from "effect/http/FetchHttpClient";
317import { RpcClient, RpcSerialization } from "effect/rpc";
318import { TaskRpcs } from "../src/rpcs.ts";
319
320const program = Effect.gen(function* () {
321 const client = yield* RpcClient.make(TaskRpcs);
322
323 const task = yield* client.createTask({ title: "Write docs" });
324 console.log("Created:", task.id);
325
326 const fetched = yield* client.getTask({ id: task.id });
327 console.log("Fetched:", fetched.title);
328});
329
330Effect.runPromise(
331 program.pipe(
332 Effect.scoped,
333 Effect.provide(
334 RpcClient.layerProtocolHttp({ url: process.env.TASK_RPC_URL! }).pipe(
335 Layer.provide(FetchHttpClient.layer),
336 Layer.provide(RpcSerialization.layerNdjson),
337 ),
338 ),
339 ),
340);
341```
342
343Get the URL from the deploy output and run it:
344
345```sh
346TASK_RPC_URL=https://your-worker.workers.dev bun scripts/client.ts
347```
348
349The errors are typed values: `client.getTask` returns
350`Effect<Task, TaskNotFound>`, and you can `Effect.catchTag(
351"TaskNotFound", ...)` to handle the missing case explicitly.
352
353## Bonus: streaming responses
354
355An RPC's `success` doesn't have to be a single value. Wrapping it in
356`RpcSchema.Stream(item, error)` produces a procedure whose handler
357returns a `Stream` and whose client method also returns a `Stream`.
358The wire format is one frame per item — exactly what the
359`RpcSerialization.layerNdjson` layer we're already on emits, so the
360`fetch` wiring doesn't change.
361
362```typescript
363// src/rpcs.ts
364import * as RpcSchema from "effect/rpc/RpcSchema";
365
366const countTasks = Rpc.make("countTasks", {
367 payload: { upto: Schema.Number },
368 success: RpcSchema.Stream(Schema.Number, Schema.Never),
369});
370
371export class TaskRpcs extends RpcGroup.make(getTask, createTask, countTasks) {}
372```
373
374The handler returns a `Stream<number>` directly:
375
376```diff lang="typescript"
377+import * as Stream from "effect/Stream";
378
379 const handlersLayer = TaskRpcs.toLayer({
380 getTask: /* ... */,
381 createTask: /* ... */,
382+ countTasks: ({ upto }) =>
383+ Stream.fromIterable(Array.from({ length: upto }, (_, i) => i + 1)),
384 });
385```
386
387On the client, `client.countTasks({ upto: 5 })` is a `Stream<number>`
388you consume with `Stream.runCollect`, `Stream.runForEach`, etc. Each
389emitted item arrives as soon as the server flushes its frame.
390
391## Recap
392
393- **Schemas** (`Task`, `TaskNotFound`, `CreateTaskFailed`) and the
394 **RPC group** (`TaskRpcs`) live outside the Worker — pure
395 descriptions, importable by clients.
396- The **handlers** are constructed inside the Worker's Construction phase
397 closure via `TaskRpcs.toLayer`. We build a `Layer` but never
398 `yield*` the running server.
399- The Worker's surface is `{ fetch }`, where `fetch` is the
400 `HttpEffect` produced by `RpcServer.toHttpEffect`.
401- The same `TaskRpcs` value drives a fully typed client via
402 `RpcClient.make`, with errors as typed values rather than HTTP
403 status codes.
404
405## Sugar: `Cloudflare.RpcWorker`
406
407The `Cloudflare.Worker(...)` + `RpcServer.toHttpEffect(...)` recipe is
408identical for every RPC Worker, so Alchemy ships a thin wrapper that
409takes the `RpcGroup` directly in props and removes the `{ fetch }`
410wrapper:
411
412```typescript
413import * as Cloudflare from "alchemy/Cloudflare";
414import * as Effect from "effect/Effect";
415import * as Layer from "effect/Layer";
416import { RpcSerialization, RpcServer } from "effect/rpc";
417import { TaskRpcs } from "./rpcs.ts";
418
419export default class Worker extends Cloudflare.RpcWorker<Worker>()(
420 "Worker",
421 { main: import.meta.url, schema: TaskRpcs },
422 Effect.gen(function* () {
423 const handlers = TaskRpcs.toLayer({ getTask: /* ... */ });
424 return RpcServer.toHttpEffect(TaskRpcs).pipe(
425 Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)),
426 );
427 }),
428) {}
429```
430
431Functionally identical to the long form above — yielding the class
432returns the same `Worker` resource — but `props.schema` lets a second
433Worker bind a typed client without re-importing `RpcClient`:
434
435```typescript
436// INIT: register the binding, get the typed client
437const tasks = yield* Cloudflare.RpcWorker.bind(TaskWorker);
438
439// PER-REQUEST: just call methods directly
440proxyGetTask: ({ id }) => tasks.getTask({ id }),
441```
442
443The bind goes over the in-account service binding (not the public
444internet) and the client is typed by `TaskWorker`'s declared schema.
445A `Proxy` defers each call's underlying `RpcClient` construction so
446Cloudflare's "no cross-request I/O" rule is satisfied transparently.
447For internal Worker-to-Worker calls that don't need a schema at all,
448see [Schemaless RPC on Workers](/cloudflare/compute/workers#call-another-worker).
449
450`RpcWorker` also supports a **modular form** that separates the
451class declaration from its runtime — useful when a consumer Worker
452should be able to import the class for binding without pulling in
453the host's runtime:
454
455```typescript
456// Modular: class declaration carries no impl — just the schema.
457export class TaskWorker extends Cloudflare.RpcWorker<TaskWorker>()(
458 "TaskWorker",
459 { schema: TaskRpcs },
460) {}
461
462// Runtime lives in a separate Layer — only the host script imports it.
463// `make` takes the Worker props (main, etc.) plus the impl.
464export default TaskWorker.make(
465 { main: import.meta.url },
466 Effect.gen(function* () {
467 const handlers = TaskRpcs.toLayer({ /* ... */ });
468 return RpcServer.toHttpEffect(TaskRpcs).pipe(
469 Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)),
470 );
471 }),
472);
473```
474
475The class can also declare DOs it publishes via the second type
476argument — `RpcWorker<Self, Deps>()` mirrors
477`Cloudflare.Worker<Self, Bindings, Deps>` so cross-script
478`Counter.from(TaskWorker)` type-checks.
479
480## Sugar: `Cloudflare.RpcDurableObject`
481
482The same shape applies to Durable Objects.
483`Cloudflare.RpcDurableObject<Self>()(...)` mirrors the
484regular DO class. Return the RPC handler Layer from the inner Effect;
485Alchemy supplies the server and serialization. This example uses a shared
486`CounterRpcs` group:
487
488```typescript
489// rpcs.ts
490import * as Schema from "effect/Schema";
491import { Rpc, RpcGroup } from "effect/rpc";
492
493export class CounterRpcs extends RpcGroup.make(
494 Rpc.make("setTitle", {
495 payload: { title: Schema.String },
496 success: Schema.Void,
497 }),
498 Rpc.make("getTitle", { payload: {}, success: Schema.String }),
499) {}
500```
501
502Return its handler Layer from the per-instance Effect:
503
504```typescript
505// counter.ts
506import * as Cloudflare from "alchemy/Cloudflare";
507import * as Effect from "effect/Effect";
508import { CounterRpcs } from "./rpcs.ts";
509
510export default class Counter extends Cloudflare.RpcDurableObject<Counter>()(
511 "Counter",
512 { schema: CounterRpcs },
513 Effect.gen(function* () {
514 const state = yield* Cloudflare.DurableObjectState;
515 return Effect.gen(function* () {
516 return CounterRpcs.toLayer({
517 setTitle: ({ title }) => state.storage.put("title", title),
518 getTitle: () => state.storage.get<string>("title").pipe(
519 Effect.map((title) => title ?? ""),
520 ),
521 });
522 });
523 }),
524) {}
525```
526
527`counters.getByName(id)` returns an `Effect<RpcClient<CounterRpcs>>`
528(yield it inside a per-request scope) rather than alchemy's built-in
529DO method bridge. Reach for this when an external client connects to
530the DO and speaks RPC over a WebSocket connection, or whenever DO
531method return values cross a `Schema.Class` boundary — the built-in bridge
532`JSON.stringify`s each value and loses class identity, while the RPC
533namespace round-trips through the shared `RpcSerialization` codec.
534The concept home for this trade-off is
535[Effect RPC](/apis/effect-rpc).
536
537Like `RpcWorker`, the RPC DO supports a **modular form** that
538separates the class from its runtime so consumer Workers can bind
539to it cross-script:
540
541```typescript
542// counter.ts — class declaration carries no impl.
543export class Counter extends Cloudflare.RpcDurableObject<Counter>()(
544 "Counter",
545 { schema: CounterRpcs },
546) {}
547
548// Runtime lives in a separate Layer — only the host Worker imports it.
549export default Counter.make(
550 Effect.gen(function* () {
551 const state = yield* Cloudflare.DurableObjectState;
552 return Effect.gen(function* () {
553 return CounterRpcs.toLayer({
554 setTitle: ({ title }) => state.storage.put("title", title),
555 getTitle: () => state.storage.get<string>("title").pipe(
556 Effect.map((title) => title ?? ""),
557 ),
558 });
559 });
560 }),
561);
562```
563
564From a consumer Worker, `Counter.from(HostWorker)` produces a
565typed namespace bound to the host's running DO instances:
566
567```typescript
568const counters = yield* Counter.from(HostWorker);
569const stub = yield* counters.getByName("shared");
570yield* stub.setTitle({ title: "hi" });
571```
572
573For internal DO calls that don't need a schema, see
574[Schemaless RPC on Durable Objects](/cloudflare/compute/durable-objects#schemaless-rpc).
575
576## HTTP and WebSocket RPC
577
578Returning a handler Layer enables both HTTP and hibernating WebSocket RPC
579automatically. Keep the declaration as `{ schema: CounterRpcs }`; the
580incoming request selects the transport.
581
582WebSocket upgrades use Effect RPC's JSON protocol. Ordinary HTTP requests
583and existing `getByName` clients continue to use NDJSON. A WebSocket is
584opened only when a client connects over one.
585
586Existing implementations returning `RpcServer.toHttpEffect(...)` remain
587HTTP-only. Return the handler Layer instead to let Alchemy provide both
588transports.
589
590## Forward browser connections
591
592The hosting Worker maps a URL to a named Durable Object. This Worker
593forwards `/counters/alice` to `"alice"` and `/counters/bob` to `"bob"`:
594
595```typescript
596// worker.ts
597import * as Cloudflare from "alchemy/Cloudflare";
598import * as Effect from "effect/Effect";
599import { HttpServerRequest } from "effect/http/HttpServerRequest";
600import * as HttpServerResponse from "effect/http/HttpServerResponse";
601import Counter from "./counter.ts";
602
603export default class CounterWorker extends Cloudflare.Worker<CounterWorker>()(
604 "CounterWorker",
605 { main: import.meta.url },
606 Effect.gen(function* () {
607 const counters = yield* Counter;
608 return {
609 fetch: Effect.gen(function* () {
610 const request = yield* HttpServerRequest;
611 const path = new URL(request.url, "https://worker").pathname;
612 const name = /^\/counters\/([a-zA-Z0-9_-]+)$/.exec(path)?.[1];
613 if (!name) return HttpServerResponse.empty({ status: 404 });
614 return yield* counters.fetch(name, request);
615 }),
616 };
617 }),
618) {}
619```
620
621`counters.fetch(name, request)` selects that name in the `Counter` namespace
622and forwards the HTTP request or WebSocket upgrade. It is not an application
623RPC method, and `/counters/:name` is an application route, not a built-in
624Alchemy endpoint.
625
626Once upgraded, every RPC on that socket goes to the selected object; method
627payloads do not need an object ID. Connections to the same name in the same
628namespace share the object's state. Different names select different objects.
629A socket cannot switch objects: open another connection to target another name.
630
631This routing-only example accepts any name matching the route. For a private
632API, authenticate the request and authorize access to the selected name before
633calling `fetch`, or derive the name from the authenticated user or tenant.
634Knowing an object's URL must not grant access by itself.
635
636## Connect over a WebSocket
637
638Connect to the Worker's `/counters/alice` route and create the browser's
639Effect RPC client in a Layer. This client talks only to the `"alice"` object:
640
641```typescript
642import * as RpcWebSocketClient from "alchemy/Cloudflare/RpcWebSocketClient";
643import * as Context from "effect/Context";
644import * as Effect from "effect/Effect";
645import * as Layer from "effect/Layer";
646import * as RpcClient from "effect/rpc/RpcClient";
647import type { RpcClientError } from "effect/rpc/RpcClientError";
648import * as Socket from "effect/socket/Socket";
649import { CounterRpcs } from "./rpcs.ts";
650
651class CounterClient extends Context.Service<
652 CounterClient,
653 RpcClient.FromGroup<typeof CounterRpcs, RpcClientError>
654>()("CounterClient") {}
655
656const CounterClientLive = RpcWebSocketClient.layer(
657 CounterClient,
658 CounterRpcs,
659 "wss://example.com/counters/alice",
660).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal));
661
662const program = Effect.gen(function* () {
663 const counter = yield* CounterClient;
664 yield* counter.setTitle({ title: "Hello over WebSockets" });
665 return yield* counter.getTitle({});
666}).pipe(Effect.provide(CounterClientLive));
667```
668
669The dedicated `alchemy/Cloudflare/RpcWebSocketClient` entrypoint imports only
670Effect modules and is safe to bundle for browsers. The wrapper is optional;
671it composes Effect's RPC client, socket protocol, WebSocket, and JSON serializer.
672Client middleware remains an explicit Layer dependency. Its `socket`, `protocol`,
673and `client` options pass through to Effect; `serialization` defaults to JSON
674and must match the server. See the
675[`RpcWebSocketClient.layer` API reference](/providers/cloudflare/workers/layer).
676
677The Layer owns the client and connection lifetime, so ordinary calls do not need
678an explicit `Effect.scoped`. It closes the connection when the provided program
679finishes, fails, or is interrupted. Provide it around the whole application or
680component program that shares the client, rather than constructing it for each
681call. Concurrent calls and streams share the socket; Effect handles typed errors,
682backpressure, and cancellation.
683
684### Keep a client for a browser session
685
686For a UI driven by callbacks, keep one managed runtime for the session:
687
688```typescript
689import * as ManagedRuntime from "effect/ManagedRuntime";
690
691const runtime = ManagedRuntime.make(CounterClientLive);
692
693const getTitle = () => runtime.runPromise(
694 Effect.gen(function* () {
695 const counter = yield* CounterClient;
696 return yield* counter.getTitle({});
697 }),
698);
699```
700
701On application or component teardown, release that runtime:
702
703```typescript
704await runtime.dispose();
705```
706
707Your application wires teardown to its UI lifecycle; the Layer does not detect
708component unmounts. Keeping the browser connection open does not prevent
709Cloudflare from hibernating an idle Durable Object. When calling from another
710Cloudflare Worker rather than a browser, provide the client Layer inside each
711request; Worker sockets cannot be shared across request contexts.
712
713### Subscription scopes
714
715Ordinary streaming RPCs return an Effect `Stream`; consume them with stream
716operators while the client Layer is alive. Requesting `{ asQueue: true }`
717returns a queue acquisition that still requires a consumer `Scope`. The Layer
718owns the shared connection, not the lifetime of each subscription. Scope those
719queue consumers separately so ending one subscription does not close the client.
720
721### Compose the built-in Effect layers directly
722
723The convenience wrapper is equivalent to this composition, using the same
724`CounterClient` service and RPC group:
725
726```typescript
727import * as RpcSerialization from "effect/rpc/RpcSerialization";
728
729const CounterClientLive = Layer.effect(
730 CounterClient,
731 RpcClient.make(CounterRpcs),
732).pipe(
733 Layer.provide(
734 RpcClient.layerProtocolSocket().pipe(
735 Layer.provide([
736 Socket.layerWebSocket("wss://example.com/counters/alice"),
737 RpcSerialization.layerJson,
738 ]),
739 Layer.provide(Socket.layerWebSocketConstructorGlobal),
740 ),
741 ),
742);
743```
744
745## WebSocket RPC and hibernation
746
747Idle connections can survive Durable Object hibernation and accept new
748calls after the instance wakes. Alchemy restores connection metadata
749from socket attachments and handles protocol heartbeats internally. A restored
750socket with a different recorded serializer content type is reset. This is not
751a schema-version check: changes that keep the same content type are not detected,
752so coordinate incompatible schema or codec changes with clients.
753
754In-flight calls and streams are not replayed. If the instance is
755reconstructed with unfinished work, the affected socket closes with
756code `1012`; clients must handle the interruption. A platform reset can
757close the connection before Alchemy restores it, so other transport errors
758are also possible. Durable checkpoints and resumable subscriptions are
759not part of this API. Application RPC methods do not need acknowledgment
760or lifecycle methods.
761
762## Under the hood: the manual DO bridge
763
764`RpcDurableObject.getByName(id)` above returns a wired `RpcClient`
765directly — you rarely need more. This section shows what it does
766internally, and doubles as the escape hatch when the `RpcServer`
767runs inside a plain `Cloudflare.DurableObject` and you build the
768bridge yourself: `Cloudflare.toHttpClient(stub)` turns the DO stub
769into an `HttpClient` that plugs into `RpcClient.layerProtocolHttp`.
770
771### Split the RPCs into two groups
772
773Put the procedures the DO implements into one group, and the
774Worker-only proxies into another. `merge` produces the public group
775the Worker exposes:
776
777```typescript
778// src/rpcs.ts
779export const InnerRpcs = RpcGroup.make(getTask, createTask, countTasks);
780
781export const DoRpcs = RpcGroup.make(
782 Rpc.make("getTaskDO", {
783 payload: { id: Schema.String },
784 success: Task,
785 error: TaskNotFound,
786 }),
787 Rpc.make("createTaskDO", {
788 payload: { title: Schema.String },
789 success: Task,
790 error: CreateTaskFailed,
791 }),
792);
793
794export class TaskRpcs extends InnerRpcs.merge(DoRpcs) {}
795```
796
797### Implement the DO
798
799The DO's constructor returns `{ fetch }` produced by
800`RpcServer.toHttpEffect(InnerRpcs)`. State lives in
801`state.storage` instead of R2:
802
803```typescript
804// src/object.ts
805import * as Cloudflare from "alchemy/Cloudflare";
806import * as Effect from "effect/Effect";
807import * as Layer from "effect/Layer";
808import * as Stream from "effect/Stream";
809import * as RpcSerialization from "effect/rpc/RpcSerialization";
810import * as RpcServer from "effect/rpc/RpcServer";
811import { InnerRpcs } from "./rpcs.ts";
812import { Task, TaskNotFound } from "./task.ts";
813
814export default class TasksObject extends Cloudflare.DurableObject<TasksObject>()(
815 "TasksObject",
816 Effect.gen(function* () {
817 const state = yield* Cloudflare.DurableObjectState;
818 return Effect.gen(function* () {
819 const handlersLayer = InnerRpcs.toLayer({
820 getTask: ({ id }) =>
821 state.storage.get<Task>(id).pipe(
822 Effect.flatMap((task) =>
823 task ? Effect.succeed(task) : Effect.fail(new TaskNotFound({ id })),
824 ),
825 ),
826 createTask: ({ title }) =>
827 Effect.sync(() => new Task({ id: crypto.randomUUID(), title, completed: false }))
828 .pipe(Effect.tap((task) => state.storage.put(task.id, task))),
829 countTasks: ({ upto }) =>
830 Stream.fromIterable(Array.from({ length: upto }, (_, i) => i + 1)),
831 });
832
833 return {
834 fetch: RpcServer.toHttpEffect(InnerRpcs).pipe(
835 Effect.provide(Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson)),
836 ),
837 };
838 });
839 }),
840) {}
841```
842
843### Bridge the DO into the Worker
844
845`Cloudflare.toHttpClient(stub)` produces an `HttpClient` whose
846`execute` calls into the DO's `fetch`. Plug it into
847`RpcClient.layerProtocolHttp` to get a typed `RpcClient<InnerRpcs>`
848— the group the DO actually serves — then the `*DO` handlers just
849forward the typed call. Here's the
850whole Worker — the R2-backed handlers are unchanged, the new
851`makeDOClient` and `getTaskDO` / `createTaskDO` proxies are added:
852
853```typescript
854// src/worker.ts
855export default Cloudflare.Worker(
856 "Worker",
857 { main: import.meta.url },
858 Effect.gen(function* () {
859 const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
860 const tasksDO = yield* TasksObject;
861
862 // A per-request typed RpcClient that short-circuits to the DO's
863 // `fetch`. The `url` is a placeholder — the request never hits the
864 // network; it routes straight to `tasksDO.getByName(id).fetch`.
865 const makeDOClient = (id: string = "default") =>
866 RpcClient.make(InnerRpcs).pipe(
867 Effect.provide(
868 RpcClient.layerProtocolHttp({ url: "http://localhost" }).pipe(
869 Layer.provide(
870 Layer.succeed(
871 HttpClient.HttpClient,
872 Cloudflare.toHttpClient(tasksDO.getByName(id)),
873 ),
874 ),
875 Layer.provide(RpcSerialization.layerNdjson),
876 ),
877 ),
878 );
879
880 const handlersLayer = TaskRpcs.toLayer({
881 // R2-backed handlers — unchanged
882 getTask: /* ... R2 implementation ... */,
883 createTask: /* ... R2 implementation ... */,
884 // DO-backed proxies — forward the typed call to the DO
885 getTaskDO: (payload) =>
886 makeDOClient().pipe(Effect.flatMap((client) => client.getTask(payload))),
887 createTaskDO: (payload) =>
888 makeDOClient().pipe(Effect.flatMap((client) => client.createTask(payload))),
889 });
890
891 return {
892 fetch: RpcServer.toHttpEffect(TaskRpcs).pipe(
893 Effect.provide(
894 Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson),
895 ),
896 ),
897 };
898 }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
899);
900```
901
902For streaming RPCs the same proxy pattern works with `Stream.unwrap` —
903declare a streaming `countTasksDO` in `DoRpcs` (same shape as
904`countTasks`, with `stream: true`) and forward:
905
906```typescript
907countTasksDO: (payload) =>
908 Stream.unwrap(
909 makeDOClient().pipe(Effect.map((client) => client.countTasks(payload))),
910 ),
911```
912
913`getTaskDO` / `createTaskDO` now hit the DO; `getTask` / `createTask`
914still hit R2. One `TaskRpcs` value, one client, two storage backends.
915
916## Choosing a modality
917
918- **Internal calls** (Worker-to-Worker, Worker-to-DO) —
919 [Schemaless RPC](/apis/schemaless): typed clients with no schema
920 and no per-request validation cost.
921- **External Effect/TypeScript consumers** — this page.
922- **External plain-HTTP consumers** —
923 [Effect HTTP](/cloudflare/apis/effect-http-api): the same typed
924 interface over real REST endpoints.
925
926The full decision guide lives at [RPC](/apis).