Skip to content

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.

src/rpcs.ts
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.

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.

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.

src/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.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.

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.

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.

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.

The DO can only be reached through a Worker. A minimal one routes two HTTP verbs to getTitle / setTitle for the test to call:

src/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 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.

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(
"Counter",
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
Effect.gen(function* () {
const worker = yield* Worker;
return { url: worker.url.as<string>() };
}),
);
test/counter.test.ts
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.

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.Class instances 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 RpcGroup value 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:

src/counter.ts
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.

Counter.make(impl) returns a Layer<Counter> that populates the tag with a running instance. Only the host script imports this:

src/counter.ts
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.

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):

src/worker.ts
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.

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:

src/worker.ts
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.

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):

src/workerB.ts
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.

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:

src/workerC.ts
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 with Class.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 its Worker<Self, Bindings, Deps> third type arg so the .from(...) call type-checks.
  • counters.getByName(id) is Effect<RpcClient<...>> — yield it inside an Effect.scoped per-request handler to get a typed client.
  • A single Test.make + beforeAll(deploy(Stack)) + HttpClient pair drives the Worker, which in turn drives the DO.