| 1 | --- |
| 2 | title: Bind to another Worker's Durable Object |
| 3 | description: Share a Durable Object across multiple Workers — one Worker hosts the runtime, others bind to it by scriptName for a typed RPC stub — and move the host later with the data intact. |
| 4 | sidebar: |
| 5 | order: 9 |
| 6 | --- |
| 7 | |
| 8 | A Durable Object is _hosted_ by exactly one Worker, but any |
| 9 | number of **other** Workers can bind to the same DO. This is how |
| 10 | you share state across Workers: one Worker hosts the DO, every |
| 11 | other Worker addresses it by `scriptName` and gets a typed RPC |
| 12 | stub. |
| 13 | |
| 14 | By the end of this part you'll have two Workers, `WorkerA` and |
| 15 | `WorkerB`, sharing a single `Counter` Durable Object. Writes |
| 16 | through either Worker are visible from the other. |
| 17 | |
| 18 | ## Move the DO into its own module |
| 19 | |
| 20 | The DO class lives in a separate file so both Workers can import |
| 21 | it as a typed identifier without pulling in the runtime |
| 22 | implementation. |
| 23 | |
| 24 | ```typescript |
| 25 | // src/object.ts |
| 26 | import type { RuntimeContext } from "alchemy"; |
| 27 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 28 | import * as Effect from "effect/Effect"; |
| 29 | |
| 30 | export class Counter extends Cloudflare.DurableObject< |
| 31 | Counter, |
| 32 | { |
| 33 | increment: () => Effect.Effect<number, never, RuntimeContext>; |
| 34 | get: () => Effect.Effect<number, never, RuntimeContext>; |
| 35 | } |
| 36 | >()("Counter") {} |
| 37 | ``` |
| 38 | |
| 39 | The class is purely a tagged identifier. Both Workers can import |
| 40 | it without dragging in the runtime — the bundler tree-shakes |
| 41 | `.make()` (which we'll add next) out of any consumer that |
| 42 | doesn't actually host the DO. |
| 43 | |
| 44 | ## Add the runtime implementation |
| 45 | |
| 46 | `Counter.make(...)` provides the per-instance Effect that runs |
| 47 | inside the DO. Export it as `default` so it can be tree-shaken |
| 48 | when imported by name. |
| 49 | |
| 50 | ```diff lang="typescript" |
| 51 | // src/object.ts |
| 52 | export class Counter extends Cloudflare.DurableObject< |
| 53 | Counter, |
| 54 | { |
| 55 | increment: () => Effect.Effect<number, never, RuntimeContext>; |
| 56 | get: () => Effect.Effect<number, never, RuntimeContext>; |
| 57 | } |
| 58 | >()("Counter") {} |
| 59 | |
| 60 | +export default Counter.make( |
| 61 | + Effect.gen(function* () { |
| 62 | + const state = yield* Cloudflare.DurableObjectState; |
| 63 | + return Effect.gen(function* () { |
| 64 | + let count = (yield* state.storage.get<number>("count")) ?? 0; |
| 65 | + |
| 66 | + return { |
| 67 | + increment: () => |
| 68 | + Effect.gen(function* () { |
| 69 | + count += 1; |
| 70 | + yield* state.storage.put("count", count); |
| 71 | + return count; |
| 72 | + }), |
| 73 | + get: () => Effect.succeed(count), |
| 74 | + }; |
| 75 | + }); |
| 76 | + }), |
| 77 | +); |
| 78 | ``` |
| 79 | |
| 80 | :::note |
| 81 | The `state` *reference* is resolved in the outer (Construction) Effect, but |
| 82 | `state.storage` is |
| 83 | [colored with `RuntimeContext`](/infrastructure-as-effects/layers#the-types-hold-the-boundary) |
| 84 | — so the storage reads/writes stay in the inner (runtime) Effect. |
| 85 | ::: |
| 86 | |
| 87 | Only Workers that import this file as `import CounterLive from "./object.ts"` |
| 88 | will pull in the runtime. Workers that only import the class |
| 89 | (`import { Counter } from "./object.ts"`) get just the type. |
| 90 | |
| 91 | ## Declare WorkerA as the host |
| 92 | |
| 93 | `WorkerA` _hosts_ `Counter` — its bundle contains the DO runtime |
| 94 | and Cloudflare runs the DO class inside WorkerA's script. |
| 95 | |
| 96 | The key move is the **third type argument** on `Cloudflare.Worker`: |
| 97 | |
| 98 | ```typescript |
| 99 | // src/workerA.ts |
| 100 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 101 | import { Counter } from "./object.ts"; |
| 102 | |
| 103 | export class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()( |
| 104 | "WorkerA", |
| 105 | ) {} |
| 106 | ``` |
| 107 | |
| 108 | `Worker<Self, Bindings, Deps>` — the third slot `Deps` is what |
| 109 | the script publishes as part of its public contract. By writing |
| 110 | `, Counter`, we're saying "WorkerA hosts Counter; other scripts |
| 111 | may bind to it." |
| 112 | |
| 113 | :::caution[The third type argument is what unlocks cross-script binding] |
| 114 | You can omit `, Counter` and the runtime still works — but |
| 115 | `Counter.from(WorkerA)` from another Worker will be a **TypeScript |
| 116 | error** because `WorkerA` doesn't declare `Counter` as part of its |
| 117 | contract. Always declare cross-script DOs in `Deps`. |
| 118 | ::: |
| 119 | |
| 120 | ## Provide the DO runtime from WorkerA |
| 121 | |
| 122 | `WorkerA.make(...)` is where the runtime lives. Provide |
| 123 | `CounterLive` (the default export from `object.ts`) so that |
| 124 | WorkerA's bundle actually contains the DO class: |
| 125 | |
| 126 | ```diff lang="typescript" |
| 127 | // src/workerA.ts |
| 128 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 129 | import * as Effect from "effect/Effect"; |
| 130 | +import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 131 | +import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 132 | -import { Counter } from "./object.ts"; |
| 133 | +import CounterLive, { Counter } from "./object.ts"; |
| 134 | |
| 135 | export class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()( |
| 136 | "WorkerA", |
| 137 | ) {} |
| 138 | |
| 139 | +export default WorkerA.make( |
| 140 | + { main: import.meta.url }, |
| 141 | + Effect.gen(function* () { |
| 142 | + const counters = yield* Counter; |
| 143 | + return { |
| 144 | + fetch: Effect.gen(function* () { |
| 145 | + const request = yield* HttpServerRequest; |
| 146 | + const name = new URL(request.url, "http://x").pathname.slice(1); |
| 147 | + const next = yield* counters.getByName(name).increment(); |
| 148 | + return HttpServerResponse.json({ value: next }); |
| 149 | + }), |
| 150 | + }; |
| 151 | + }).pipe(Effect.provide(CounterLive)), |
| 152 | +); |
| 153 | ``` |
| 154 | |
| 155 | `yield* Counter` inside WorkerA's constructor binds the DO _locally_ — |
| 156 | WorkerA's `env.Counter` is wired up at deploy time and the class |
| 157 | ships in the bundle. |
| 158 | |
| 159 | ## Bind WorkerA's Counter from WorkerB |
| 160 | |
| 161 | `WorkerB` does **not** host the DO. It imports `WorkerA` purely |
| 162 | as a type and uses `Counter.from(WorkerA)` to declare a binding |
| 163 | to the existing namespace running under WorkerA's script: |
| 164 | |
| 165 | ```typescript |
| 166 | // src/workerB.ts |
| 167 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 168 | import * as Effect from "effect/Effect"; |
| 169 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 170 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 171 | import { Counter } from "./object.ts"; |
| 172 | import { WorkerA } from "./workerA.ts"; |
| 173 | |
| 174 | export default class WorkerB extends Cloudflare.Worker<WorkerB>()( |
| 175 | "WorkerB", |
| 176 | { main: import.meta.url }, |
| 177 | Effect.gen(function* () { |
| 178 | const counters = yield* Counter.from(WorkerA); |
| 179 | return { |
| 180 | fetch: Effect.gen(function* () { |
| 181 | const request = yield* HttpServerRequest; |
| 182 | const name = new URL(request.url, "http://x").pathname.slice(1); |
| 183 | const value = yield* counters.getByName(name).get(); |
| 184 | return HttpServerResponse.json({ value }); |
| 185 | }), |
| 186 | }; |
| 187 | }), |
| 188 | ) {} |
| 189 | ``` |
| 190 | |
| 191 | `Counter.from(WorkerA)` reads `WorkerA`'s `Deps` to confirm that |
| 192 | `Counter` is part of its public contract, then produces a typed |
| 193 | namespace stub. Under the hood it emits a Cloudflare binding with |
| 194 | `scriptName: "WorkerA"` so the runtime routes every call to |
| 195 | WorkerA's hosted instance. |
| 196 | |
| 197 | :::tip[Only the host provides `CounterLive`] |
| 198 | WorkerB imports `Counter` but **never** `CounterLive`. Rolldown |
| 199 | tree-shakes the runtime out of WorkerB's bundle — only WorkerA |
| 200 | ships the DO class. This keeps consumer bundles small and avoids |
| 201 | duplicate runtime instances. |
| 202 | ::: |
| 203 | |
| 204 | ## Deploy both Workers from one Stack |
| 205 | |
| 206 | The Stack composes both Workers. WorkerA's Layer is provided so |
| 207 | its `Counter` runtime is hooked up; WorkerB consumes it via |
| 208 | binding only: |
| 209 | |
| 210 | ```typescript |
| 211 | // alchemy.run.ts |
| 212 | import * as Alchemy from "alchemy"; |
| 213 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 214 | import * as Effect from "effect/Effect"; |
| 215 | import WorkerALive, { WorkerA } from "./src/workerA.ts"; |
| 216 | import WorkerB from "./src/workerB.ts"; |
| 217 | |
| 218 | export default Alchemy.Stack( |
| 219 | "CrossWorkerDO", |
| 220 | { state: Cloudflare.state(), providers: Cloudflare.providers() }, |
| 221 | Effect.gen(function* () { |
| 222 | const a = yield* WorkerA; |
| 223 | const b = yield* WorkerB; |
| 224 | return { |
| 225 | urlA: a.url.as<string>(), |
| 226 | urlB: b.url.as<string>(), |
| 227 | }; |
| 228 | }).pipe(Effect.provide(WorkerALive)), |
| 229 | ); |
| 230 | ``` |
| 231 | |
| 232 | `Effect.provide(WorkerALive)` is the only place the DO runtime |
| 233 | enters the program. Without it, `yield* WorkerA` would fail to |
| 234 | resolve. |
| 235 | |
| 236 | ## Verify shared state |
| 237 | |
| 238 | Hit `urlA/foo` to increment, then `urlB/foo` to read — both |
| 239 | Workers route to the same DO instance by name: |
| 240 | |
| 241 | ```typescript |
| 242 | // test/integ.test.ts |
| 243 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 244 | import * as Test from "alchemy/Test/Vitest"; |
| 245 | import { expect } from "@effect/vitest"; |
| 246 | import * as Effect from "effect/Effect"; |
| 247 | import * as HttpClient from "effect/http/HttpClient"; |
| 248 | import Stack from "../alchemy.run.ts"; |
| 249 | |
| 250 | const { test, beforeAll, deploy } = Test.make({ |
| 251 | providers: Cloudflare.providers(), |
| 252 | }); |
| 253 | |
| 254 | const stack = beforeAll(deploy(Stack)); |
| 255 | |
| 256 | test( |
| 257 | "WorkerB reads counter written by WorkerA", |
| 258 | Effect.gen(function* () { |
| 259 | const { urlA, urlB } = yield* stack; |
| 260 | const client = yield* HttpClient.HttpClient; |
| 261 | |
| 262 | yield* client.post(`${urlA}/`); |
| 263 | yield* client.post(`${urlA}/`); |
| 264 | |
| 265 | const res = yield* client.get(`${urlB}/`); |
| 266 | expect((yield* res.json) as { value: number }).toEqual({ value: 2 }); |
| 267 | }), |
| 268 | ); |
| 269 | ``` |
| 270 | |
| 271 | ## Prefer `Counter.from(Self)` inside Layers |
| 272 | |
| 273 | Inside `WorkerA.make` we wrote `yield* Counter` to bind the |
| 274 | locally-hosted DO. That works, but it's not the form you want |
| 275 | when the code might be extracted into a reusable Layer or shared |
| 276 | between scripts. |
| 277 | |
| 278 | `Counter.from(WorkerA)` also works inside `WorkerA` itself — |
| 279 | calling `.from(Self)` on the host resolves to the same local |
| 280 | namespace as `yield* Counter`: |
| 281 | |
| 282 | ```diff lang="typescript" |
| 283 | // src/workerA.ts |
| 284 | export default WorkerA.make( |
| 285 | { main: import.meta.url }, |
| 286 | Effect.gen(function* () { |
| 287 | - const counters = yield* Counter; |
| 288 | + const counters = yield* Counter.from(WorkerA); |
| 289 | return { |
| 290 | fetch: Effect.gen(function* () { ... }), |
| 291 | }; |
| 292 | }).pipe(Effect.provide(CounterLive)), |
| 293 | ); |
| 294 | ``` |
| 295 | |
| 296 | This matters most when you extract `fetch` logic into a Layer |
| 297 | (or any service) that wants a `Counter` namespace. With |
| 298 | `Counter.from(Self)`, the service is explicit about _which_ |
| 299 | script's Counter it talks to. A Worker that hosts its own |
| 300 | isolated namespace uses `.from(Self)`; a consumer Worker uses |
| 301 | `.from(Host)`. The shape is identical, which keeps the Layer |
| 302 | host-agnostic. |
| 303 | |
| 304 | :::tip[A Worker can host its own isolated namespace] |
| 305 | If `WorkerC` declares `Counter` in its own contract |
| 306 | (`Worker<WorkerC, {}, Counter>`) and provides `CounterLive`, |
| 307 | then `Counter.from(WorkerC)` from inside `WorkerC` binds to a |
| 308 | **separate** DO namespace — instances under WorkerC are |
| 309 | isolated from WorkerA's. This is how you give each script its |
| 310 | own private set of DO instances using the same class. |
| 311 | ::: |
| 312 | |
| 313 | ## Move the Durable Object to a new host |
| 314 | |
| 315 | Hosting isn't forever — suppose `Counter` should now live under |
| 316 | `WorkerB` instead of `WorkerA`. A Durable Object class can move |
| 317 | between Workers **with its data intact**: Cloudflare's |
| 318 | `transferred_classes` migration re-homes the namespace (same |
| 319 | instances, same storage) onto the new host's script. Moves are |
| 320 | always *declared* — a class that disappears from one Worker and |
| 321 | appears on another is otherwise ambiguous between "transfer the |
| 322 | data" and "delete it, start fresh" — so the class states where it |
| 323 | came from. |
| 324 | |
| 325 | ### Declare where the class came from |
| 326 | |
| 327 | Add `transferredFrom` to the class declaration, naming the former |
| 328 | host: |
| 329 | |
| 330 | ```diff lang="typescript" |
| 331 | // src/object.ts |
| 332 | export class Counter extends Cloudflare.DurableObject< |
| 333 | Counter, |
| 334 | { |
| 335 | increment: () => Effect.Effect<number, never, RuntimeContext>; |
| 336 | get: () => Effect.Effect<number, never, RuntimeContext>; |
| 337 | } |
| 338 | ->()("Counter") {} |
| 339 | +>()("Counter", { transferredFrom: "WorkerA" }) {} |
| 340 | ``` |
| 341 | |
| 342 | `"WorkerA"` is the former host's Worker **logical id**, resolved to |
| 343 | its physical script through alchemy's ownership tags in the same |
| 344 | stack + stage (for a cross-stack move, use the physical script name |
| 345 | instead). Whichever Worker hosts `Counter` next will transfer the |
| 346 | namespace from it. |
| 347 | |
| 348 | :::tip[Prefer a typed reference over a string] |
| 349 | `transferredFrom` also accepts the Worker class or resource itself — |
| 350 | or a thunk for forward references and import cycles: |
| 351 | |
| 352 | ```typescript |
| 353 | { transferredFrom: () => WorkerA } |
| 354 | ``` |
| 355 | |
| 356 | The thunk is evaluated lazily at plan time and normalized to |
| 357 | WorkerA's logical id, so it stays safe in module cycles (here, |
| 358 | `object.ts` referencing `workerA.ts` while `workerA.ts` imports |
| 359 | `object.ts`). A reference never becomes a dependency edge — alchemy |
| 360 | takes the *identity*, not the deployed value, which keeps the deploy |
| 361 | order correct (the new host uploads first). |
| 362 | ::: |
| 363 | |
| 364 | ### Make WorkerB the host |
| 365 | |
| 366 | `WorkerB` takes over the host role: it declares `Counter` in its |
| 367 | `Deps`, provides `CounterLive`, and binds with `.from(Self)`: |
| 368 | |
| 369 | ```typescript |
| 370 | // src/workerB.ts |
| 371 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 372 | import * as Effect from "effect/Effect"; |
| 373 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 374 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 375 | import CounterLive, { Counter } from "./object.ts"; |
| 376 | |
| 377 | export class WorkerB extends Cloudflare.Worker<WorkerB, {}, Counter>()( |
| 378 | "WorkerB", |
| 379 | ) {} |
| 380 | |
| 381 | export default WorkerB.make( |
| 382 | { main: import.meta.url }, |
| 383 | Effect.gen(function* () { |
| 384 | const counters = yield* Counter.from(WorkerB); |
| 385 | return { |
| 386 | fetch: Effect.gen(function* () { |
| 387 | const request = yield* HttpServerRequest; |
| 388 | const name = new URL(request.url, "http://x").pathname.slice(1); |
| 389 | const value = yield* counters.getByName(name).get(); |
| 390 | return HttpServerResponse.json({ value }); |
| 391 | }), |
| 392 | }; |
| 393 | }).pipe(Effect.provide(CounterLive)), |
| 394 | ); |
| 395 | ``` |
| 396 | |
| 397 | ### Turn WorkerA into a consumer |
| 398 | |
| 399 | `WorkerA` stops shipping the runtime and binds the same namespace |
| 400 | cross-script, now hosted by `WorkerB`: |
| 401 | |
| 402 | ```diff lang="typescript" |
| 403 | // src/workerA.ts |
| 404 | -import CounterLive, { Counter } from "./object.ts"; |
| 405 | +import { Counter } from "./object.ts"; |
| 406 | +import { WorkerB } from "./workerB.ts"; |
| 407 | |
| 408 | -export class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()( |
| 409 | +export class WorkerA extends Cloudflare.Worker<WorkerA>()( |
| 410 | "WorkerA", |
| 411 | ) {} |
| 412 | |
| 413 | export default WorkerA.make( |
| 414 | { main: import.meta.url }, |
| 415 | Effect.gen(function* () { |
| 416 | - const counters = yield* Counter.from(WorkerA); |
| 417 | + const counters = yield* Counter.from(WorkerB); |
| 418 | return { |
| 419 | fetch: Effect.gen(function* () { ... }), |
| 420 | }; |
| 421 | - }).pipe(Effect.provide(CounterLive)), |
| 422 | + }), |
| 423 | ); |
| 424 | ``` |
| 425 | |
| 426 | ### Provide WorkerB's Layer in the Stack |
| 427 | |
| 428 | The Stack now provides both Layers — `WorkerBLive` carries the DO |
| 429 | runtime, `WorkerALive` no longer does: |
| 430 | |
| 431 | ```diff lang="typescript" |
| 432 | // alchemy.run.ts |
| 433 | import WorkerALive, { WorkerA } from "./src/workerA.ts"; |
| 434 | -import WorkerB from "./src/workerB.ts"; |
| 435 | +import WorkerBLive, { WorkerB } from "./src/workerB.ts"; |
| 436 | |
| 437 | Effect.gen(function* () { |
| 438 | const a = yield* WorkerA; |
| 439 | const b = yield* WorkerB; |
| 440 | return { |
| 441 | urlA: a.url.as<string>(), |
| 442 | urlB: b.url.as<string>(), |
| 443 | }; |
| 444 | - }).pipe(Effect.provide(WorkerALive)), |
| 445 | + }).pipe(Effect.provide([WorkerALive, WorkerBLive])), |
| 446 | ``` |
| 447 | |
| 448 | ### Deploy the move |
| 449 | |
| 450 | ```sh |
| 451 | bun alchemy deploy |
| 452 | ``` |
| 453 | |
| 454 | `WorkerA` references `WorkerB` through `Counter.from(WorkerB)`, so |
| 455 | the engine deploys `WorkerB` first. Its upload carries the |
| 456 | `transferred_classes` migration — the namespace and every stored |
| 457 | object move to `WorkerB`'s script — and `WorkerA`'s deploy then |
| 458 | converges on its own (no delete migration is emitted for a class |
| 459 | that moved away). Every counter keeps its count. Afterwards the |
| 460 | `transferredFrom` declaration is inert — on fresh stages and on |
| 461 | every later deploy it does nothing — so leave it in place. |
| 462 | |
| 463 | :::caution[Undeclared moves fail loudly] |
| 464 | If you move the host without declaring `transferredFrom`, the former |
| 465 | host's deploy fails **before any upload** with |
| 466 | `DurableObjectTransferRequired`, telling you exactly what to declare. |
| 467 | Durable Object data is never silently destroyed or forked. |
| 468 | ::: |
| 469 | |
| 470 | ### Keep the host history for chained moves |
| 471 | |
| 472 | If the class later moves again — say `WorkerB` → `WorkerC` — append |
| 473 | to the declaration rather than replacing it: |
| 474 | |
| 475 | ```typescript |
| 476 | // src/object.ts — after a second move |
| 477 | >()("Counter", { transferredFrom: ["WorkerA", "WorkerB"] }) {} |
| 478 | ``` |
| 479 | |
| 480 | The namespace transfers from whichever listed host currently holds |
| 481 | it, so a stage that lagged behind (or skipped the intermediate |
| 482 | release) still converges from wherever *its* namespace lives. |
| 483 | |
| 484 | Two rules for less common shapes: |
| 485 | |
| 486 | - **Pure moves** — the former host drops the DO entirely, keeping no |
| 487 | cross-script reference — take two deploys: first add the class to |
| 488 | the new host with `transferredFrom` and deploy, then remove it from |
| 489 | the former host and deploy. |
| 490 | - **Cross-stack moves** name the former host by physical script name, |
| 491 | and the new host's stack deploys first. |
| 492 | |
| 493 | ## Closing |
| 494 | |
| 495 | Two Workers, one DO, type-safe end-to-end. The third type argument |
| 496 | `, Counter` on `WorkerA` is the small but critical piece that |
| 497 | makes `Counter.from(WorkerA)` type-check from `WorkerB` — and |
| 498 | that same form (`Counter.from(Self)`) lets a host script bind to |
| 499 | its own DO from inside a Layer without caring whether it's the |
| 500 | host or a consumer. |