Add a typed RPC Durable Object
The Durable Objects tutorial
uses alchemy’s built-in DO method bridge: any function returned from
the inner Effect becomes a typed method on the stub. That bridge
JSON.stringifys each return value, which strips class identity —
fine for primitives, but a problem when values contain Schema.Class
instances. Cloudflare.RpcDurableObjectNamespace runs an Effect RPC
server on the DO’s fetch so both ends share one codec.
This tutorial walks through a single typed DO end-to-end: declare the schema, implement the DO, wire a thin Worker driver, deploy it, and test it.
Declare a procedure
Section titled “Declare a procedure”import * as Schema from "effect/Schema";import { Rpc } from "effect/unstable/rpc";
const setTitle = Rpc.make("setTitle", { payload: { title: Schema.String }, success: Schema.Void,});DO-scoped procedures don’t need a per-session id in the payload — the DO instance is the session.
Add a second procedure
Section titled “Add a second procedure”const setTitle = Rpc.make("setTitle", { payload: { title: Schema.String }, success: Schema.Void,});
const getTitle = Rpc.make("getTitle", { payload: {}, success: Schema.String,});Each Rpc.make is independent, so adding procedures is purely
additive.
Group them into an RpcGroup
Section titled “Group them into an RpcGroup”import * as Schema from "effect/Schema";import { Rpc } from "effect/unstable/rpc";import { Rpc, RpcGroup } from "effect/unstable/rpc";
// ...
export class CounterRpcs extends RpcGroup.make(setTitle, getTitle) {}CounterRpcs is the single value the DO, the consumer, and any
tests all import.
Define the DO class
Section titled “Define the DO class”import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { CounterRpcs } from "./rpcs.ts";
export default class Counter extends Cloudflare.RpcDurableObjectNamespace<Counter>()( "Counter", { schema: CounterRpcs }, Effect.gen(function* () { return Effect.gen(function* () { return Effect.succeed(undefined as never); }); }),) {}Cloudflare.RpcDurableObjectNamespace<Self>()(...) mirrors
Cloudflare.DurableObjectNamespace<Self>()(...) — same outer/inner
Effect pattern. The outer Effect resolves shared deps; the inner
Effect runs once per DO instance.
Pull state out of context
Section titled “Pull state out of context”Effect.gen(function* () { return Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; return Effect.succeed(undefined as never); });}),Cloudflare.DurableObjectState is the same per-instance handle
Cloudflare exposes for storage, setAlarm, acceptWebSocket, and
friends.
Wire the handlers
Section titled “Wire the handlers”Effect.gen(function* () { return Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; const handlers = CounterRpcs.toLayer({ setTitle: ({ title }) => state.storage.put("title", title), getTitle: () => Effect.map(state.storage.get<string>("title"), (t) => t ?? ""), }); });}),CounterRpcs.toLayer({...}) is type-checked against the group —
every procedure must be implemented, with the right payload and
return type.
Return the piped Effect
Section titled “Return the piped Effect”import * as Layer from "effect/Layer";import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
// ... const handlers = CounterRpcs.toLayer({ /* ... */ }); return RpcServer.toHttpEffect(CounterRpcs).pipe( Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)), ); });}),The inner Effect returns the piped RpcServer.toHttpEffect Effect
directly — no { fetch } wrapper. NDJSON is required if any
procedure is a streaming RPC; use layerJson if every procedure is
request/response.
Define a thin Worker driver
Section titled “Define a thin Worker driver”The DO can only be reached through a Worker. A minimal one routes
two HTTP verbs to getTitle / setTitle for the test to call:
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 Worker extends Cloudflare.Worker<Worker>()( "Worker", { main: import.meta.filename }, Effect.gen(function* () { const counters = yield* Counter; return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const id = new URL(request.url).searchParams.get("id") ?? "default";
const client = yield* counters.getByName(id);
if (request.method === "POST") { const { title } = (yield* request.json) as { title: string }; yield* client.setTitle({ title }).pipe(Effect.orDie); return HttpServerResponse.text("ok"); } const title = yield* client.getTitle({}).pipe(Effect.orDie); return HttpServerResponse.text(title); }).pipe(Effect.scoped), }; }),) {}yield* Counter registers the binding on the surrounding Worker;
counters.getByName(id) returns an Effect<RpcClient<CounterRpcs>>
in the per-request scope — Effect.scoped on the handler keeps the
client alive for the duration of the request.
Deploy
Section titled “Deploy”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( "Counter", { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { const worker = yield* Worker; return { url: worker.url.as<string>() }; }),);Drive it from a test
Section titled “Drive it from a test”import { expect } from "@effect/vitest";import * as Cloudflare from "alchemy/Cloudflare";import * as Test from "alchemy/Test/Vitest";import * as Effect from "effect/Effect";import * as Schedule from "effect/Schedule";import * as HttpClient from "effect/unstable/http/HttpClient";import Stack from "../alchemy.run.ts";
const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers: Cloudflare.providers(),});
const stack = beforeAll(deploy(Stack));afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
test( "setTitle / getTitle round-trip", Effect.gen(function* () { const { url } = yield* stack; const client = yield* HttpClient.HttpClient;
yield* client .post(`${url}?id=alpha`, { body: HttpClient.body("text/plain")(`{"title":"Hello"}`) }) .pipe( Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 5, }), );
const res = yield* client.get(`${url}?id=alpha`); const title = yield* res.text; expect(title).toBe("Hello"); }), { timeout: 60_000 },);Test.make({ providers: Cloudflare.providers() }) deploys the stack
once, the test drives the Worker (which proxies into the DO via
getByName(id)), and asserts on the round-trip.
When to reach for this
Section titled “When to reach for this”Use the built-in DO bridge when method return values are primitives
or Schema.encodeUnknown-compatible structs — it’s lighter and
ergonomic.
Switch to RpcDurableObjectNamespace when:
- Return values contain
Schema.Classinstances that downstream code branches on — the built-in bridge will silently flatten them. - You want streaming procedures (NDJSON codec round-trips
Stream.Stream). - You want a single shared schema across multiple call-sites
(Worker, script, tests) — the same
RpcGroupvalue drives every client.
Bonus: yield the namespace from inside the DO
Section titled “Bonus: yield the namespace from inside the DO”Mirrors yield* DurableObjectNamespace on the regular class — useful
for fanning a call out to sibling instances:
Effect.gen(function* () { const self = yield* Cloudflare.RpcDurableObjectNamespace; const peer = yield* self.getByName("peer-1"); yield* peer.setTitle({ title: "Sibling call" });});Modular form: split the class from its runtime
Section titled “Modular form: split the class from its runtime”The single-arg form above bundles the runtime into the class
declaration. For cross-script binding (one Worker hosts the DO,
others bind to it) you want the class to be a pure tagged
identifier so consumers can import it without pulling the runtime
into their bundle. Use the two-arg form — (name, { schema })
with no impl — and provide the runtime via static make:
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 { CounterRpcs } from "./rpcs.ts";
export class Counter extends Cloudflare.RpcDurableObjectNamespace<Counter>()( "Counter", { schema: CounterRpcs },) {}This is just a tagged identifier — both the host and any
consumer Worker can import { Counter } without dragging in
the runtime.
Provide the runtime as a Layer
Section titled “Provide the runtime as a Layer”Counter.make(impl) returns a Layer<Counter> that populates
the tag with a running instance. Only the host script imports
this:
export class Counter extends Cloudflare.RpcDurableObjectNamespace<Counter>()( "Counter", { schema: CounterRpcs }, ) {}
export default Counter.make( Effect.gen(function* () { return Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; const handlers = CounterRpcs.toLayer({ setTitle: ({ title }) => state.storage.put("title", title), getTitle: () => Effect.map(state.storage.get<string>("title"), (t) => t ?? ""), }); return RpcServer.toHttpEffect(CounterRpcs).pipe( Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)), ); }); }),);The impl shape is identical to the inline form — outer Effect for
shared deps, inner Effect for per-instance setup, returning the
piped RpcServer.toHttpEffect. The only difference is that
Counter.make(...) returns a Layer instead of binding the impl
to the class declaration.
Declare the host Worker
Section titled “Declare the host Worker”The Worker that hosts the DO names it in its third type argument
(Worker<Self, Bindings, Deps> — Deps is what the script
publishes for cross-script binding):
import { Counter } from "./counter.ts";
export default class Worker extends Cloudflare.Worker<Worker>()(export class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()( "Worker", { main: import.meta.filename }, Effect.gen(function* () { const counters = yield* Counter; ... }), ) {}Worker<WorkerA, {}, Counter> declares that WorkerA publishes
Counter — without this third arg, Counter.from(WorkerA) from
another Worker would be a TypeScript error.
Provide the runtime from the host
Section titled “Provide the runtime from the host”WorkerA.make(...) is where the runtime lives. Provide
CounterLive (the default export of counter.ts) so the DO class
actually ships in WorkerA’s bundle:
export default WorkerA.make( Effect.gen(function* () { const counters = yield* Counter; return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const id = new URL(request.url).searchParams.get("id") ?? "default"; const client = yield* counters.getByName(id); if (request.method === "POST") { const { title } = (yield* request.json) as { title: string }; yield* client.setTitle({ title }).pipe(Effect.orDie); return HttpServerResponse.text("ok"); } const title = yield* client.getTitle({}).pipe(Effect.orDie); return HttpServerResponse.text(title); }).pipe(Effect.scoped), }; }).pipe(Effect.provide(CounterLive)),);yield* Counter resolves the tag populated by CounterLive.
Inside the host script this is equivalent to Counter.from(WorkerA)
— see the next section for why the .from form is sometimes
preferred.
Bind from a second Worker
Section titled “Bind from a second Worker”A second Worker can address the same DO instances by binding to
WorkerA’s namespace — no public URL, no service binding, just
Counter.from(WorkerA):
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { Counter } from "./counter.ts";import { WorkerA } from "./worker.ts";
export default class WorkerB extends Cloudflare.Worker<WorkerB>()( "WorkerB", { main: import.meta.filename }, Effect.gen(function* () { const counters = yield* Counter.from(WorkerA); return { fetch: Effect.gen(function* () { const stub = yield* counters.getByName("shared"); const title = yield* stub.getTitle({}).pipe(Effect.orDie); return HttpServerResponse.text(title); }).pipe(Effect.scoped), }; }),) {}Counter.from(WorkerA) reads WorkerA’s Deps to confirm Counter
is part of its contract, then emits a Cloudflare binding with
scriptName: "WorkerA". Writes through WorkerA’s getByName(name)
are visible from WorkerB’s getByName(name) — same DO instance.
WorkerB never imports CounterLive, so Rolldown tree-shakes the
runtime out of WorkerB’s bundle.
Host an isolated namespace
Section titled “Host an isolated namespace”A Worker can also host its own isolated Counter namespace
by declaring Counter in its Deps and providing CounterLive.
Inside that Worker, use Counter.from(Self) to be explicit about
which script’s namespace you’re binding to:
export class WorkerC extends Cloudflare.Worker<WorkerC, {}, Counter>()( "WorkerC", { main: import.meta.filename },) {}
export default WorkerC.make( Effect.gen(function* () { const counters = yield* Counter.from(WorkerC); // ... fetch handler ... }).pipe(Effect.provide(CounterLive)),);Instances under WorkerC are separate from WorkerA’s — same class, different namespace, completely isolated state.
Cloudflare.RpcDurableObjectNamespace<Self>()(name, { schema }, impl)is the inline form — class declaration carries the runtime.(name, { schema })(no impl) is the modular form — pair it withClass.make(impl)so consumer scripts can import the class without bundling the runtime.Class.from(Worker)binds to a DO hosted by another script. The host declares the DO in itsWorker<Self, Bindings, Deps>third type arg so the.from(...)call type-checks.counters.getByName(id)isEffect<RpcClient<...>>— yield it inside anEffect.scopedper-request handler to get a typed client.- A single
Test.make+beforeAll(deploy(Stack))+HttpClientpair drives the Worker, which in turn drives the DO.