Bind to another Worker's Durable Object
A Durable Object is hosted by exactly one Worker, but any
number of other Workers can bind to the same DO. This is how
you share state across Workers: one Worker hosts the DO, every
other Worker addresses it by scriptName and gets a typed RPC
stub.
By the end of this part you’ll have two Workers, WorkerA and
WorkerB, sharing a single Counter Durable Object. Writes
through either Worker are visible from the other.
Move the DO into its own module
Section titled “Move the DO into its own module”The DO class lives in a separate file so both Workers can import it as a typed identifier without pulling in the runtime implementation.
import type { RuntimeContext } from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";
export class Counter extends Cloudflare.DurableObjectNamespace< Counter, { increment: () => Effect.Effect<number, never, RuntimeContext>; get: () => Effect.Effect<number, never, RuntimeContext>; }>()("Counter") {}The class is purely a tagged identifier. Both Workers can import
it without dragging in the runtime — the bundler tree-shakes
.make() (which we’ll add next) out of any consumer that
doesn’t actually host the DO.
Add the runtime implementation
Section titled “Add the runtime implementation”Counter.make(...) provides the per-instance Effect that runs
inside the DO. Export it as default so it can be tree-shaken
when imported by name.
export class Counter extends Cloudflare.DurableObjectNamespace< Counter, { increment: () => Effect.Effect<number, never, RuntimeContext>; get: () => Effect.Effect<number, never, RuntimeContext>; } >()("Counter") {}
export default Counter.make( Effect.gen(function* () { return Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; let count = (yield* state.storage.get<number>("count")) ?? 0;
return { increment: () => Effect.gen(function* () { count += 1; yield* state.storage.put("count", count); return count; }), get: () => Effect.succeed(count), }; }); }),);Only Workers that import this file as import CounterLive from "./object.ts"
will pull in the runtime. Workers that only import the class
(import { Counter } from "./object.ts") get just the type.
Declare WorkerA as the host
Section titled “Declare WorkerA as the host”WorkerA hosts Counter — its bundle contains the DO runtime
and Cloudflare runs the DO class inside WorkerA’s script.
The key move is the third type argument on Cloudflare.Worker:
import * as Cloudflare from "alchemy/Cloudflare";import { Counter } from "./object.ts";
export class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()( "WorkerA", { main: import.meta.filename },) {}Worker<Self, Bindings, Deps> — the third slot Deps is what
the script publishes as part of its public contract. By writing
, Counter, we’re saying “WorkerA hosts Counter; other scripts
may bind to it.”
Provide the DO runtime from WorkerA
Section titled “Provide the DO runtime from WorkerA”WorkerA.make(...) is where the runtime lives. Provide
CounterLive (the default export from object.ts) so that
WorkerA’s bundle actually contains the DO class:
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 "./object.ts";import CounterLive, { Counter } from "./object.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; return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const name = new URL(request.url, "http://x").pathname.slice(1); const next = yield* counters.getByName(name).increment(); return HttpServerResponse.json({ value: next }); }), }; }).pipe(Effect.provide(CounterLive)),);yield* Counter inside WorkerA’s init binds the DO locally —
WorkerA’s env.Counter is wired up at deploy time and the class
ships in the bundle.
Bind WorkerA’s Counter from WorkerB
Section titled “Bind WorkerA’s Counter from WorkerB”WorkerB does not host the DO. It imports WorkerA purely
as a type and uses Counter.from(WorkerA) to declare a binding
to the existing namespace running under WorkerA’s script:
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 "./object.ts";import { WorkerA } from "./workerA.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 request = yield* HttpServerRequest; const name = new URL(request.url, "http://x").pathname.slice(1); const value = yield* counters.getByName(name).get(); return HttpServerResponse.json({ value }); }), }; }),) {}Counter.from(WorkerA) reads WorkerA’s Deps to confirm that
Counter is part of its public contract, then produces a typed
namespace stub. Under the hood it emits a Cloudflare binding with
scriptName: "WorkerA" so the runtime routes every call to
WorkerA’s hosted instance.
Deploy both Workers from one Stack
Section titled “Deploy both Workers from one Stack”The Stack composes both Workers. WorkerA’s Layer is provided so
its Counter runtime is hooked up; WorkerB consumes it via
binding only:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import WorkerALive, { WorkerA } from "./src/workerA.ts";import WorkerB from "./src/workerB.ts";
export default Alchemy.Stack( "CrossWorkerDO", { state: Cloudflare.state(), providers: Cloudflare.providers() }, Effect.gen(function* () { const a = yield* WorkerA; const b = yield* WorkerB; return { urlA: a.url.as<string>(), urlB: b.url.as<string>(), }; }).pipe(Effect.provide(WorkerALive)),);Effect.provide(WorkerALive) is the only place the DO runtime
enters the program. Without it, yield* WorkerA would fail to
resolve.
Verify shared state
Section titled “Verify shared state”Hit urlA/foo to increment, then urlB/foo to read — both
Workers route to the same DO instance by name:
import * as Cloudflare from "alchemy/Cloudflare";import * as Test from "alchemy/Test/Vitest";import { expect } from "@effect/vitest";import * as Effect from "effect/Effect";import * as HttpClient from "effect/unstable/http/HttpClient";import Stack from "../alchemy.run.ts";
const { test, beforeAll, deploy } = Test.make({ providers: Cloudflare.providers(),});
const stack = beforeAll(deploy(Stack));
test( "WorkerB reads counter written by WorkerA", Effect.gen(function* () { const { urlA, urlB } = yield* stack; const client = yield* HttpClient.HttpClient;
yield* client.post(`${urlA}/`); yield* client.post(`${urlA}/`);
const res = yield* client.get(`${urlB}/`); expect((yield* res.json) as { value: number }).toEqual({ value: 2 }); }),);Prefer Counter.from(Self) inside Layers
Section titled “Prefer Counter.from(Self) inside Layers”Inside WorkerA.make we wrote yield* Counter to bind the
locally-hosted DO. That works, but it’s not the form you want
when the code might be extracted into a reusable Layer or shared
between scripts.
Counter.from(WorkerA) also works inside WorkerA itself —
calling .from(Self) on the host resolves to the same local
namespace as yield* Counter:
export default WorkerA.make( Effect.gen(function* () { const counters = yield* Counter; const counters = yield* Counter.from(WorkerA); return { fetch: Effect.gen(function* () { ... }), }; }).pipe(Effect.provide(CounterLive)), );This matters most when you extract fetch logic into a Layer
(or any service) that wants a Counter namespace. With
Counter.from(Self), the service is explicit about which
script’s Counter it talks to. A Worker that hosts its own
isolated namespace uses .from(Self); a consumer Worker uses
.from(Host). The shape is identical, which keeps the Layer
host-agnostic.
Closing
Section titled “Closing”Two Workers, one DO, type-safe end-to-end. The third type argument
, Counter on WorkerA is the small but critical piece that
makes Counter.from(WorkerA) type-check from WorkerB — and
that same form (Counter.from(Self)) lets a host script bind to
its own DO from inside a Layer without caring whether it’s the
host or a consumer.