Skip to content

Effect RPC

Effect RPC exposes a typed, schema-validated surface across a trust boundary — a web app or an external service calling into your Worker. For internal Worker-to-Worker or Worker-to-DO calls, use Schemaless RPC instead; for choosing between the modalities, see RPC.

The HTTP API guide showed how to build REST-style endpoints with schema validation. Effect RPC takes a different angle — you define procedures instead of HTTP endpoints, and you get a fully typed client for free with no URL construction or manual serialization.

The transport is still HTTP under the hood, and both patterns produce the same HttpEffect type, so the wiring story is identical to the HTTP API guide:

  1. Define schemas outside. Domain types and tagged errors, importable by both server and client.
  2. Construct the service inside the Worker’s Construction phase. RpcGroup.toLayer is pure construction — safe to call at plan time. Don’t yield* the running server; it can’t run without a request.
  3. Return { fetch } where fetch is the HttpEffect produced by RpcServer.toHttpEffect.
  4. Bonus: deploy and call the procedures from a typed client that shares the exact same RpcGroup value.

Domain model and error types — pure schemas, no runtime concerns:

src/task.ts
import * as Schema from "effect/Schema";
export class Task extends Schema.Class<Task>("Task")({
id: Schema.String,
title: Schema.String,
completed: Schema.Boolean,
}) {}
export class TaskNotFound extends Schema.TaggedClass<TaskNotFound>()(
"TaskNotFound",
{ id: Schema.String },
) {}
export class CreateTaskFailed extends Schema.TaggedClass<CreateTaskFailed>()(
"CreateTaskFailed",
{ message: Schema.String },
) {}

RPC errors are schema-backed tagged classes. The client receives them as typed values you can match on — not raw HTTP status codes.

Each Rpc.make declares one procedure: a name, a payload schema, a success schema, and an error schema. RpcGroup.make collects them into a single value that both the server and the client will share.

src/rpcs.ts
import * as Schema from "effect/Schema";
import { Rpc, RpcGroup } from "effect/unstable/rpc";
import { Task, TaskNotFound, CreateTaskFailed } from "./task.ts";
const getTask = Rpc.make("getTask", {
success: Task,
error: TaskNotFound,
payload: { id: Schema.String },
});
const createTask = Rpc.make("createTask", {
success: Task,
error: CreateTaskFailed,
payload: { title: Schema.String },
});
export class TaskRpcs extends RpcGroup.make(getTask, createTask) {}

TaskRpcs is just a value-level description. Nothing executes yet.

Create src/worker.ts with an empty Construction phase:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
return {};
}),
);

The generator inside Cloudflare.Worker is the Construction phase — it runs both at plan time and at runtime. Only do pure construction or resource-binding factories here; never yield* work that needs an incoming request.

Tasks need durable storage. Declare an Bucket resource and bind it inside the constructor — bind() returns a typed handle whose get / put / delete / list methods we’ll call from the handlers below.

src/bucket.ts
import * as Cloudflare from "alchemy/Cloudflare";
export const Tasks = Cloudflare.R2.Bucket("Tasks");
import { Tasks } from "./bucket.ts";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
return {};
}),
);

We’ll provide the runtime side of this binding (Cloudflare.R2.ReadWriteBucketBinding) in step 3c when we wire up the fetch handler.

3b. Construct the handlers inside the constructor

Section titled “3b. Construct the handlers inside the constructor”

TaskRpcs.toLayer takes an Effect that returns one handler per procedure and produces a Layer. Like HttpApiBuilder.group, this is pure construction — it builds a value, it doesn’t run the server.

Don’t yield* TaskRpcs.toLayer(...) here. Building a layer is fine, but actually executing the procedures requires an incoming request. The constructor only constructs; the work happens later, on each fetch call.

Effect.gen(function* () {
const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
const handlersLayer = TaskRpcs.toLayer({
getTask: ({ id }) =>
Effect.gen(function* () {
const object = yield* tasks.get(id);
if (!object) {
return yield* Effect.fail(new TaskNotFound({ id }));
}
return Schema.decodeUnknownSync(Task)(JSON.parse(yield* object.text()));
}).pipe(Effect.orDie),
createTask: ({ title }) =>
Effect.gen(function* () {
const task = new Task({
id: crypto.randomUUID(),
title,
completed: false,
});
yield* tasks.put(task.id, JSON.stringify(task));
return task;
}).pipe(
Effect.catchTag("R2Error", (error) =>
Effect.fail(new CreateTaskFailed({ message: error.message })),
),
),
});
return {};
}),

Each handler receives the typed payload and returns an Effect that either succeeds with the declared success schema or fails with the declared error schema. getTask uses Effect.orDie to turn unexpected R2 failures into 500s — TaskNotFound is the only client-visible error. createTask maps R2 failures into the declared CreateTaskFailed error so the client can match on it.

RpcServer.toHttpEffect converts the RpcGroup into an HttpEffect — exactly the type Workers expect for fetch. We provide two layers to it via Layer.mergeAll:

  • The handlersLayer we just built.
  • RpcSerialization.layerNdjson — one frame per line, so streaming procedures work out of the box and the wire format matches what Cloudflare.RpcWorker.bind clients speak. When nothing streams and no bound consumer is involved, the buffered RpcSerialization.layerJson is also an option.

Unlike the HTTP API approach, RPC doesn’t need HttpPlatform.layer or Etag.layer — the RPC server handles message framing internally.

return {
fetch: RpcServer.toHttpEffect(TaskRpcs).pipe(
Effect.provide(
Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson),
),
),
};
src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
import { Tasks } from "./bucket.ts";
import { CreateTaskFailed, Task, TaskNotFound } from "./task.ts";
import { TaskRpcs } from "./rpcs.ts";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
const handlersLayer = TaskRpcs.toLayer({
getTask: ({ id }) =>
Effect.gen(function* () {
const object = yield* tasks.get(id);
if (!object) {
return yield* Effect.fail(new TaskNotFound({ id }));
}
return Schema.decodeUnknownSync(Task)(
JSON.parse(yield* object.text()),
);
}).pipe(Effect.orDie),
createTask: ({ title }) =>
Effect.gen(function* () {
const task = new Task({
id: crypto.randomUUID(),
title,
completed: false,
});
yield* tasks.put(task.id, JSON.stringify(task));
return task;
}).pipe(
Effect.catchTag("R2Error", (error) =>
Effect.fail(new CreateTaskFailed({ message: error.message })),
),
),
});
return {
fetch: RpcServer.toHttpEffect(TaskRpcs).pipe(
Effect.provide(
Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson),
),
),
};
}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
);
alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import Worker from "./src/worker.ts";
export default Alchemy.Stack(
"TaskRpc",
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
Effect.gen(function* () {
const worker = yield* Worker;
return { url: worker.url };
}),
);
Terminal window
alchemy deploy

Because TaskRpcs is just a value, the same group drives a fully typed client — no codegen. client.createTask accepts { title: string } and returns Effect<Task, CreateTaskFailed>.

scripts/client.ts
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
import { TaskRpcs } from "../src/rpcs.ts";
const program = Effect.gen(function* () {
const client = yield* RpcClient.make(TaskRpcs);
const task = yield* client.createTask({ title: "Write docs" });
console.log("Created:", task.id);
const fetched = yield* client.getTask({ id: task.id });
console.log("Fetched:", fetched.title);
});
Effect.runPromise(
program.pipe(
Effect.scoped,
Effect.provide(
RpcClient.layerProtocolHttp({ url: process.env.TASK_RPC_URL! }).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(RpcSerialization.layerNdjson),
),
),
),
);

Get the URL from the deploy output and run it:

Terminal window
TASK_RPC_URL=https://your-worker.workers.dev bun scripts/client.ts

The errors are typed values: client.getTask returns Effect<Task, TaskNotFound>, and you can Effect.catchTag( "TaskNotFound", ...) to handle the missing case explicitly.

An RPC’s success doesn’t have to be a single value. Wrapping it in RpcSchema.Stream(item, error) produces a procedure whose handler returns a Stream and whose client method also returns a Stream. The wire format is one frame per item — exactly what the RpcSerialization.layerNdjson layer we’re already on emits, so the fetch wiring doesn’t change.

src/rpcs.ts
import * as RpcSchema from "effect/unstable/rpc/RpcSchema";
const countTasks = Rpc.make("countTasks", {
payload: { upto: Schema.Number },
success: RpcSchema.Stream(Schema.Number, Schema.Never),
});
export class TaskRpcs extends RpcGroup.make(getTask, createTask, countTasks) {}

The handler returns a Stream<number> directly:

import * as Stream from "effect/Stream";
const handlersLayer = TaskRpcs.toLayer({
getTask: /* ... */,
createTask: /* ... */,
countTasks: ({ upto }) =>
Stream.fromIterable(Array.from({ length: upto }, (_, i) => i + 1)),
});

On the client, client.countTasks({ upto: 5 }) is a Stream<number> you consume with Stream.runCollect, Stream.runForEach, etc. Each emitted item arrives as soon as the server flushes its frame.

  • Schemas (Task, TaskNotFound, CreateTaskFailed) and the RPC group (TaskRpcs) live outside the Worker — pure descriptions, importable by clients.
  • The handlers are constructed inside the Worker’s Construction phase closure via TaskRpcs.toLayer. We build a Layer but never yield* the running server.
  • The Worker’s surface is { fetch }, where fetch is the HttpEffect produced by RpcServer.toHttpEffect.
  • The same TaskRpcs value drives a fully typed client via RpcClient.make, with errors as typed values rather than HTTP status codes.

The Cloudflare.Worker(...) + RpcServer.toHttpEffect(...) recipe is identical for every RPC Worker, so Alchemy ships a thin wrapper that takes the RpcGroup directly in props and removes the { fetch } wrapper:

import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
import { TaskRpcs } from "./rpcs.ts";
export default class Worker extends Cloudflare.RpcWorker<Worker>()(
"Worker",
{ main: import.meta.url, schema: TaskRpcs },
Effect.gen(function* () {
const handlers = TaskRpcs.toLayer({ getTask: /* ... */ });
return RpcServer.toHttpEffect(TaskRpcs).pipe(
Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)),
);
}),
) {}

Functionally identical to the long form above — yielding the class returns the same Worker resource — but props.schema lets a second Worker bind a typed client without re-importing RpcClient:

// INIT: register the binding, get the typed client
const tasks = yield* Cloudflare.RpcWorker.bind(TaskWorker);
// PER-REQUEST: just call methods directly
proxyGetTask: ({ id }) => tasks.getTask({ id }),

The bind goes over the in-account service binding (not the public internet) and the client is typed by TaskWorker’s declared schema. A Proxy defers each call’s underlying RpcClient construction so Cloudflare’s “no cross-request I/O” rule is satisfied transparently. For internal Worker-to-Worker calls that don’t need a schema at all, see Schemaless RPC on Workers.

RpcWorker also supports a modular form that separates the class declaration from its runtime — useful when a consumer Worker should be able to import the class for binding without pulling in the host’s runtime:

// Modular: class declaration carries no impl — just the schema.
export class TaskWorker extends Cloudflare.RpcWorker<TaskWorker>()(
"TaskWorker",
{ schema: TaskRpcs },
) {}
// Runtime lives in a separate Layer — only the host script imports it.
// `make` takes the Worker props (main, etc.) plus the impl.
export default TaskWorker.make(
{ main: import.meta.url },
Effect.gen(function* () {
const handlers = TaskRpcs.toLayer({ /* ... */ });
return RpcServer.toHttpEffect(TaskRpcs).pipe(
Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)),
);
}),
);

The class can also declare DOs it publishes via the second type argument — RpcWorker<Self, Deps>() mirrors Cloudflare.Worker<Self, Bindings, Deps> so cross-script Counter.from(TaskWorker) type-checks.

The same shape applies to Durable Objects. Cloudflare.RpcDurableObject<Self>()(...) mirrors the regular DO class. Return the RPC handler Layer from the inner Effect; Alchemy supplies the server and serialization. This example uses a shared CounterRpcs group:

rpcs.ts
import * as Schema from "effect/Schema";
import { Rpc, RpcGroup } from "effect/unstable/rpc";
export class CounterRpcs extends RpcGroup.make(
Rpc.make("setTitle", {
payload: { title: Schema.String },
success: Schema.Void,
}),
Rpc.make("getTitle", { payload: {}, success: Schema.String }),
) {}

Return its handler Layer from the per-instance Effect:

counter.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { CounterRpcs } from "./rpcs.ts";
export default class Counter extends Cloudflare.RpcDurableObject<Counter>()(
"Counter",
{ schema: CounterRpcs },
Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () {
return CounterRpcs.toLayer({
setTitle: ({ title }) => state.storage.put("title", title),
getTitle: () => state.storage.get<string>("title").pipe(
Effect.map((title) => title ?? ""),
),
});
});
}),
) {}

counters.getByName(id) returns an Effect<RpcClient<CounterRpcs>> (yield it inside a per-request scope) rather than alchemy’s built-in DO method bridge. Reach for this when an external client connects to the DO and speaks RPC over a WebSocket connection, or whenever DO method return values cross a Schema.Class boundary — the built-in bridge JSON.stringifys each value and loses class identity, while the RPC namespace round-trips through the shared RpcSerialization codec. The concept home for this trade-off is Effect RPC.

Like RpcWorker, the RPC DO supports a modular form that separates the class from its runtime so consumer Workers can bind to it cross-script:

// counter.ts — class declaration carries no impl.
export class Counter extends Cloudflare.RpcDurableObject<Counter>()(
"Counter",
{ schema: CounterRpcs },
) {}
// Runtime lives in a separate Layer — only the host Worker imports it.
export default Counter.make(
Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () {
return CounterRpcs.toLayer({
setTitle: ({ title }) => state.storage.put("title", title),
getTitle: () => state.storage.get<string>("title").pipe(
Effect.map((title) => title ?? ""),
),
});
});
}),
);

From a consumer Worker, Counter.from(HostWorker) produces a typed namespace bound to the host’s running DO instances:

const counters = yield* Counter.from(HostWorker);
const stub = yield* counters.getByName("shared");
yield* stub.setTitle({ title: "hi" });

For internal DO calls that don’t need a schema, see Schemaless RPC on Durable Objects.

Returning a handler Layer enables both HTTP and hibernating WebSocket RPC automatically. Keep the declaration as { schema: CounterRpcs }; the incoming request selects the transport.

WebSocket upgrades use Effect RPC’s JSON protocol. Ordinary HTTP requests and existing getByName clients continue to use NDJSON. A WebSocket is opened only when a client connects over one.

Existing implementations returning RpcServer.toHttpEffect(...) remain HTTP-only. Return the handler Layer instead to let Alchemy provide both transports.

The hosting Worker maps a URL to a named Durable Object. This Worker forwards /counters/alice to "alice" and /counters/bob to "bob":

worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import Counter from "./counter.ts";
export default class CounterWorker extends Cloudflare.Worker<CounterWorker>()(
"CounterWorker",
{ main: import.meta.url },
Effect.gen(function* () {
const counters = yield* Counter;
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const path = new URL(request.url, "https://worker").pathname;
const name = /^\/counters\/([a-zA-Z0-9_-]+)$/.exec(path)?.[1];
if (!name) return HttpServerResponse.empty({ status: 404 });
return yield* counters.fetch(name, request);
}),
};
}),
) {}

counters.fetch(name, request) selects that name in the Counter namespace and forwards the HTTP request or WebSocket upgrade. It is not an application RPC method, and /counters/:name is an application route, not a built-in Alchemy endpoint.

Once upgraded, every RPC on that socket goes to the selected object; method payloads do not need an object ID. Connections to the same name in the same namespace share the object’s state. Different names select different objects. A socket cannot switch objects: open another connection to target another name.

This routing-only example accepts any name matching the route. For a private API, authenticate the request and authorize access to the selected name before calling fetch, or derive the name from the authenticated user or tenant. Knowing an object’s URL must not grant access by itself.

Connect to the Worker’s /counters/alice route and create the browser’s Effect RPC client in a Layer. This client talks only to the "alice" object:

import * as RpcWebSocketClient from "alchemy/Cloudflare/RpcWebSocketClient";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as RpcClient from "effect/unstable/rpc/RpcClient";
import type { RpcClientError } from "effect/unstable/rpc/RpcClientError";
import * as Socket from "effect/unstable/socket/Socket";
import { CounterRpcs } from "./rpcs.ts";
class CounterClient extends Context.Service<
CounterClient,
RpcClient.FromGroup<typeof CounterRpcs, RpcClientError>
>()("CounterClient") {}
const CounterClientLive = RpcWebSocketClient.layer(
CounterClient,
CounterRpcs,
"wss://example.com/counters/alice",
).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal));
const program = Effect.gen(function* () {
const counter = yield* CounterClient;
yield* counter.setTitle({ title: "Hello over WebSockets" });
return yield* counter.getTitle({});
}).pipe(Effect.provide(CounterClientLive));

The dedicated alchemy/Cloudflare/RpcWebSocketClient entrypoint imports only Effect modules and is safe to bundle for browsers. The wrapper is optional; it composes Effect’s RPC client, socket protocol, WebSocket, and JSON serializer. Client middleware remains an explicit Layer dependency. Its socket, protocol, and client options pass through to Effect; serialization defaults to JSON and must match the server. See the RpcWebSocketClient.layer API reference.

The Layer owns the client and connection lifetime, so ordinary calls do not need an explicit Effect.scoped. It closes the connection when the provided program finishes, fails, or is interrupted. Provide it around the whole application or component program that shares the client, rather than constructing it for each call. Concurrent calls and streams share the socket; Effect handles typed errors, backpressure, and cancellation.

For a UI driven by callbacks, keep one managed runtime for the session:

import * as ManagedRuntime from "effect/ManagedRuntime";
const runtime = ManagedRuntime.make(CounterClientLive);
const getTitle = () => runtime.runPromise(
Effect.gen(function* () {
const counter = yield* CounterClient;
return yield* counter.getTitle({});
}),
);

On application or component teardown, release that runtime:

await runtime.dispose();

Your application wires teardown to its UI lifecycle; the Layer does not detect component unmounts. Keeping the browser connection open does not prevent Cloudflare from hibernating an idle Durable Object. When calling from another Cloudflare Worker rather than a browser, provide the client Layer inside each request; Worker sockets cannot be shared across request contexts.

Ordinary streaming RPCs return an Effect Stream; consume them with stream operators while the client Layer is alive. Requesting { asQueue: true } returns a queue acquisition that still requires a consumer Scope. The Layer owns the shared connection, not the lifetime of each subscription. Scope those queue consumers separately so ending one subscription does not close the client.

Compose the built-in Effect layers directly

Section titled “Compose the built-in Effect layers directly”

The convenience wrapper is equivalent to this composition, using the same CounterClient service and RPC group:

import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
const CounterClientLive = Layer.effect(
CounterClient,
RpcClient.make(CounterRpcs),
).pipe(
Layer.provide(
RpcClient.layerProtocolSocket().pipe(
Layer.provide([
Socket.layerWebSocket("wss://example.com/counters/alice"),
RpcSerialization.layerJson,
]),
Layer.provide(Socket.layerWebSocketConstructorGlobal),
),
),
);

Idle connections can survive Durable Object hibernation and accept new calls after the instance wakes. Alchemy restores connection metadata from socket attachments and handles protocol heartbeats internally. A restored socket with a different recorded serializer content type is reset. This is not a schema-version check: changes that keep the same content type are not detected, so coordinate incompatible schema or codec changes with clients.

In-flight calls and streams are not replayed. If the instance is reconstructed with unfinished work, the affected socket closes with code 1012; clients must handle the interruption. A platform reset can close the connection before Alchemy restores it, so other transport errors are also possible. Durable checkpoints and resumable subscriptions are not part of this API. Application RPC methods do not need acknowledgment or lifecycle methods.

RpcDurableObject.getByName(id) above returns a wired RpcClient directly — you rarely need more. This section shows what it does internally, and doubles as the escape hatch when the RpcServer runs inside a plain Cloudflare.DurableObject and you build the bridge yourself: Cloudflare.toHttpClient(stub) turns the DO stub into an HttpClient that plugs into RpcClient.layerProtocolHttp.

Put the procedures the DO implements into one group, and the Worker-only proxies into another. merge produces the public group the Worker exposes:

src/rpcs.ts
export const InnerRpcs = RpcGroup.make(getTask, createTask, countTasks);
export const DoRpcs = RpcGroup.make(
Rpc.make("getTaskDO", {
payload: { id: Schema.String },
success: Task,
error: TaskNotFound,
}),
Rpc.make("createTaskDO", {
payload: { title: Schema.String },
success: Task,
error: CreateTaskFailed,
}),
);
export class TaskRpcs extends InnerRpcs.merge(DoRpcs) {}

The DO’s constructor returns { fetch } produced by RpcServer.toHttpEffect(InnerRpcs). State lives in state.storage instead of R2:

src/object.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
import * as RpcServer from "effect/unstable/rpc/RpcServer";
import { InnerRpcs } from "./rpcs.ts";
import { Task, TaskNotFound } from "./task.ts";
export default class TasksObject extends Cloudflare.DurableObject<TasksObject>()(
"TasksObject",
Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () {
const handlersLayer = InnerRpcs.toLayer({
getTask: ({ id }) =>
state.storage.get<Task>(id).pipe(
Effect.flatMap((task) =>
task ? Effect.succeed(task) : Effect.fail(new TaskNotFound({ id })),
),
),
createTask: ({ title }) =>
Effect.sync(() => new Task({ id: crypto.randomUUID(), title, completed: false }))
.pipe(Effect.tap((task) => state.storage.put(task.id, task))),
countTasks: ({ upto }) =>
Stream.fromIterable(Array.from({ length: upto }, (_, i) => i + 1)),
});
return {
fetch: RpcServer.toHttpEffect(InnerRpcs).pipe(
Effect.provide(Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson)),
),
};
});
}),
) {}

Cloudflare.toHttpClient(stub) produces an HttpClient whose execute calls into the DO’s fetch. Plug it into RpcClient.layerProtocolHttp to get a typed RpcClient<InnerRpcs> — the group the DO actually serves — then the *DO handlers just forward the typed call. Here’s the whole Worker — the R2-backed handlers are unchanged, the new makeDOClient and getTaskDO / createTaskDO proxies are added:

src/worker.ts
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
const tasksDO = yield* TasksObject;
// A per-request typed RpcClient that short-circuits to the DO's
// `fetch`. The `url` is a placeholder — the request never hits the
// network; it routes straight to `tasksDO.getByName(id).fetch`.
const makeDOClient = (id: string = "default") =>
RpcClient.make(InnerRpcs).pipe(
Effect.provide(
RpcClient.layerProtocolHttp({ url: "http://localhost" }).pipe(
Layer.provide(
Layer.succeed(
HttpClient.HttpClient,
Cloudflare.toHttpClient(tasksDO.getByName(id)),
),
),
Layer.provide(RpcSerialization.layerNdjson),
),
),
);
const handlersLayer = TaskRpcs.toLayer({
// R2-backed handlers — unchanged
getTask: /* ... R2 implementation ... */,
createTask: /* ... R2 implementation ... */,
// DO-backed proxies — forward the typed call to the DO
getTaskDO: (payload) =>
makeDOClient().pipe(Effect.flatMap((client) => client.getTask(payload))),
createTaskDO: (payload) =>
makeDOClient().pipe(Effect.flatMap((client) => client.createTask(payload))),
});
return {
fetch: RpcServer.toHttpEffect(TaskRpcs).pipe(
Effect.provide(
Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson),
),
),
};
}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
);

For streaming RPCs the same proxy pattern works with Stream.unwrap — declare a streaming countTasksDO in DoRpcs (same shape as countTasks, with stream: true) and forward:

countTasksDO: (payload) =>
Stream.unwrap(
makeDOClient().pipe(Effect.map((client) => client.countTasks(payload))),
),

getTaskDO / createTaskDO now hit the DO; getTask / createTask still hit R2. One TaskRpcs value, one client, two storage backends.

  • Internal calls (Worker-to-Worker, Worker-to-DO) — Schemaless RPC: typed clients with no schema and no per-request validation cost.
  • External Effect/TypeScript consumers — this page.
  • External plain-HTTP consumersEffect HTTP: the same typed interface over real REST endpoints.

The full decision guide lives at RPC.