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.DurableObject< 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.DurableObject< Counter, { increment: () => Effect.Effect<number, never, RuntimeContext>; get: () => Effect.Effect<number, never, RuntimeContext>; } >()("Counter") {}
export default Counter.make( Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; return Effect.gen(function* () { 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",) {}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", ) {}
export default WorkerA.make( { main: import.meta.url }, 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.url }, 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( { main: import.meta.url }, 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.
Move the Durable Object to a new host
Section titled “Move the Durable Object to a new host”Hosting isn’t forever — suppose Counter should now live under
WorkerB instead of WorkerA. A Durable Object class can move
between Workers with its data intact: Cloudflare’s
transferred_classes migration re-homes the namespace (same
instances, same storage) onto the new host’s script. Moves are
always declared — a class that disappears from one Worker and
appears on another is otherwise ambiguous between “transfer the
data” and “delete it, start fresh” — so the class states where it
came from.
Declare where the class came from
Section titled “Declare where the class came from”Add transferredFrom to the class declaration, naming the former
host:
export class Counter extends Cloudflare.DurableObject< Counter, { increment: () => Effect.Effect<number, never, RuntimeContext>; get: () => Effect.Effect<number, never, RuntimeContext>; }>()("Counter") {}>()("Counter", { transferredFrom: "WorkerA" }) {}"WorkerA" is the former host’s Worker logical id, resolved to
its physical script through alchemy’s ownership tags in the same
stack + stage (for a cross-stack move, use the physical script name
instead). Whichever Worker hosts Counter next will transfer the
namespace from it.
Make WorkerB the host
Section titled “Make WorkerB the host”WorkerB takes over the host role: it declares Counter in its
Deps, provides CounterLive, and binds with .from(Self):
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 CounterLive, { Counter } from "./object.ts";
export class WorkerB extends Cloudflare.Worker<WorkerB, {}, Counter>()( "WorkerB",) {}
export default WorkerB.make( { main: import.meta.url }, Effect.gen(function* () { const counters = yield* Counter.from(WorkerB); 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 }); }), }; }).pipe(Effect.provide(CounterLive)),);Turn WorkerA into a consumer
Section titled “Turn WorkerA into a consumer”WorkerA stops shipping the runtime and binds the same namespace
cross-script, now hosted by WorkerB:
import CounterLive, { Counter } from "./object.ts";import { Counter } from "./object.ts";import { WorkerB } from "./workerB.ts";
export class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()(export class WorkerA extends Cloudflare.Worker<WorkerA>()( "WorkerA", ) {}
export default WorkerA.make( { main: import.meta.url }, Effect.gen(function* () { const counters = yield* Counter.from(WorkerA); const counters = yield* Counter.from(WorkerB); return { fetch: Effect.gen(function* () { ... }), }; }).pipe(Effect.provide(CounterLive)), }), );Provide WorkerB’s Layer in the Stack
Section titled “Provide WorkerB’s Layer in the Stack”The Stack now provides both Layers — WorkerBLive carries the DO
runtime, WorkerALive no longer does:
import WorkerALive, { WorkerA } from "./src/workerA.ts";import WorkerB from "./src/workerB.ts";import WorkerBLive, { WorkerB } from "./src/workerB.ts";
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)), }).pipe(Effect.provide([WorkerALive, WorkerBLive])),Deploy the move
Section titled “Deploy the move”bun alchemy deployWorkerA references WorkerB through Counter.from(WorkerB), so
the engine deploys WorkerB first. Its upload carries the
transferred_classes migration — the namespace and every stored
object move to WorkerB’s script — and WorkerA’s deploy then
converges on its own (no delete migration is emitted for a class
that moved away). Every counter keeps its count. Afterwards the
transferredFrom declaration is inert — on fresh stages and on
every later deploy it does nothing — so leave it in place.
Keep the host history for chained moves
Section titled “Keep the host history for chained moves”If the class later moves again — say WorkerB → WorkerC — append
to the declaration rather than replacing it:
// src/object.ts — after a second move>()("Counter", { transferredFrom: ["WorkerA", "WorkerB"] }) {}The namespace transfers from whichever listed host currently holds it, so a stage that lagged behind (or skipped the intermediate release) still converges from wherever its namespace lives.
Two rules for less common shapes:
- Pure moves — the former host drops the DO entirely, keeping no
cross-script reference — take two deploys: first add the class to
the new host with
transferredFromand deploy, then remove it from the former host and deploy. - Cross-stack moves name the former host by physical script name, and the new host’s stack deploys first.
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.