Skip to content

RpcDurableObjectNamespace

Source: src/Cloudflare/Workers/RpcDurableObjectNamespace.ts

RpcDurableObjectNamespace is sugar over {@link DurableObjectNamespace} for Durable Objects whose surface is a typed Effect RpcGroup. The DO serves an RpcServer.toHttpEffect(group) on its own fetch, and consumers see namespace.getByName(id) as a typed RpcClient directly — no manual client wiring.

Use this over alchemy’s built-in DO method bridge whenever values crossing the DO boundary contain Schema.Class instances. The built-in bridge JSON.stringifys every method return value, which strips class identity (e.g. an effect/ai Response.Usage instance becomes a plain struct on the consumer side). With RpcDurableObjectNamespace, both ends go through the same RpcSerialization codec, so Schema.decode reconstructs class instances correctly.

The DO instance is the session, so the group payloads typically don’t include any per-session identifier — only the per-call inputs.

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

Mirrors Cloudflare.DurableObjectNamespace<Self>()(...) — same outer/inner Effect pattern. The outer Effect resolves shared deps; the per-instance inner Effect returns the RpcServer.toHttpEffect(schema)-piped Effect directly.

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 default class Counter extends Cloudflare.RpcDurableObjectNamespace<Counter>()(
"Counter",
{ schema: CounterRpcs },
Effect.gen(function* () {
// outer init: shared deps for all instances
return Effect.gen(function* () {
// per-instance init: state + handlers
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)),
);
});
}),
) {}

yield* Counter resolves to a value whose getByName(id) returns an Effect<RpcClient<CounterRpcs>>. Each rpc method is a typed Effect/Stream factory — no RpcClient.make setup needed. Yield the client inside a per-request Effect.scoped handler so it’s freed with the request.

import Counter from "./counter.ts";
Effect.gen(function* () {
const counters = yield* Counter;
const client = yield* counters.getByName("global");
yield* client.setTitle({ title: "Hello" });
const title = yield* client.getTitle({});
return title;
}).pipe(Effect.scoped);

Modular form: separate the class from its runtime

Section titled “Modular form: separate the class from its runtime”

The inline class form above bundles the runtime into the class declaration. The two-arg form (name, { schema }) declares the class as a pure tagged identifier; provide the runtime separately via Class.make(impl). Consumer Workers can import the class for binding (Counter.from(HostWorker)) without pulling the runtime into their bundle.

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 },
) {}
// Only the host script imports this default export.
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)),
);
});
}),
);

Cross-script binding via Counter.from(Worker)

Section titled “Cross-script binding via Counter.from(Worker)”

Hosting on WorkerA, binding from WorkerB

The host Worker declares Counter in its Deps (third type arg of Worker<Self, Bindings, Deps> or second of RpcWorker<Self, Deps>) and provides CounterLive. Any other Worker uses Counter.from(HostWorker) to bind to the same DO instances — writes through HostWorker.getByName(name) are visible from Counter.from(HostWorker).getByName(name).

// host worker (declares + provides Counter)
import CounterLive, { Counter } from "./counter.ts";
export class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()(
"WorkerA",
{ main: import.meta.filename },
) {}
export default WorkerA.make(
Effect.gen(function* () {
const counters = yield* Counter; // local host binding
// ... fetch handler ...
}).pipe(Effect.provide(CounterLive)),
);
// consumer worker (binds via .from)
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 client = yield* counters.getByName("shared");
yield* client.setTitle({ title: "via WorkerB" });
return HttpServerResponse.text("ok");
}).pipe(Effect.scoped),
};
}),
) {}

Self-hosted isolated namespace

A Worker that declares Counter in its own Deps and provides CounterLive hosts its own isolated namespace — instances under it are separate from any other host’s. Use Counter.from(Self) inside the host 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); // explicit self
// ... fetch handler ...
}).pipe(Effect.provide(CounterLive)),
);

Yielding the surrounding namespace from inside a DO

Section titled “Yielding the surrounding namespace from inside a DO”

Lets a DO instance refer to its own namespace — e.g. to fan a call out to sibling instances. Mirrors yield* DurableObjectNamespace on the regular DurableObjectNamespace.

Effect.gen(function* () {
const self = yield* Cloudflare.RpcDurableObjectNamespace;
const peer = yield* self.getByName("peer-1");
yield* peer.setTitle({ title: "Sibling call" });
}).pipe(Effect.scoped);