1---
2title: 2.0.0-beta.58 - Platform Ergonomics
3date: 2026-06-24T22:00:00Z
4category: release
5excerpt: v2.0.0-beta.58 improves the ergonomics of Layers that contain Resources, so you can bundle infrastructure into a reusable service — like an agent tool — and provide it to a Worker, Container, or Durable Object.
6---
7
8beta.58 improves the ergonomics of Layers that contain Resources, so
9you can bundle infrastructure into a reusable service — like an agent
10tool — and provide it to a Worker, Container, or Durable Object.
11
12A few changes get us there. First, a Platform tag (e.g. a Worker or
13Container) becomes **pure identity**: its props and its runtime
14implementation move out of the tag and into a Layer.
15
16```diff lang="typescript"
17- export class WorkerB extends Cloudflare.Worker<WorkerB>()(
18- "WorkerB",
19- { main: import.meta.filename }, // props on the tag
20- ) {}
21+ export class WorkerB extends Cloudflare.Worker<WorkerB>()("WorkerB") {}
22
23+ export default WorkerB.make(
24+ { main: import.meta.filename }, // props on the Layer
25+ Effect.gen(function* () {
26+ /* ... */
27+ }),
28+ );
29```
30
31Also, `DurableObjectState` can now be yielded at the top level,
32instead of having to be resolved inside the nested `Effect.gen`:
33
34```diff lang="typescript"
35 Effect.gen(function* () {
36+ const state = yield* Cloudflare.DurableObjectState;
37 return Effect.gen(function* () {
38- const state = yield* Cloudflare.DurableObjectState;
39 let count = (yield* state.storage.get<number>("count")) ?? 0;
40 /* ... */
41 });
42 }),
43```
44
45Initialization is where resources, bindings, and dependencies are
46declared, so a Layer can contain infrastructure and be provided to any
47Worker, Durable Object, or Container without changing its shape.
48
49:::caution
50**Breaking changes** — migration for each is below.
51
52- **Props move from the tag to `.make()`** on the Layer form of
53 `Worker` / `Container`.
54- **`DurableObjectState`** is resolved in the **outer** init Effect.
55- **Containers** replace `Cloudflare.Container.bind` /
56 `Cloudflare.start` with `yield*`-the-class + `Cloudflare.layerContainer`,
57 and `yield*`-ing the tag now returns a **running container**, not the
58 `ContainerApplication` resource.
59- **Resource bindings** replace a resource's single `.bind` with
60 namespaced, least-privilege capabilities (`Cloudflare.R2.ReadWriteBucket`).
61- **The `Build` module is removed** in favor of a new `Command`
62 module (`Build.Command` → `Command.Build`, `Build.DevServer` →
63 `Command.Dev`).
64- **`DynamicWorkerLoader` is renamed** to `WorkerLoader`.
65:::
66
67## One tag, many implementations
68
69Because the tag is pure identity, one tag can have **many**
70implementations. `.make(propsA, implA)` and `.make(propsB, implB)`
71produce two Layers for the same tag — swap a real implementation for
72a stub in tests, or vary config per stage:
73
74```typescript
75export const SandboxProd = Sandbox.make(
76 { main: import.meta.filename, instanceType: "standard-1" },
77 realImpl,
78);
79
80export const SandboxTest = Sandbox.make(
81 { main: import.meta.filename, instanceType: "dev" },
82 fakeImpl,
83);
84```
85
86The inline form is unchanged — pass the implementation as a third
87argument and props stay inline:
88
89```typescript
90export default Cloudflare.Worker(
91 "Worker",
92 { main: import.meta.filename }, // still inline here
93 Effect.gen(function* () {
94 /* ... */
95 }),
96);
97```
98
99## A tool on Durable Object storage
100
101Resolving `DurableObjectState` at init is what lets you capture the
102DO's storage in a **Layer** and expose it as a service — the shape a
103`Sql` tool takes. Resolve `state` (and anything else, like an R2
104bucket) at init, return the runtime methods:
105
106```typescript
107class Archive extends Context.Service<Archive, {
108 save: (key: string, body: string) =>
109 Effect.Effect<void, never, Alchemy.RuntimeContext>; // <- RuntimeContext-colored
110}>()("Archive") {}
111
112const ArchiveLive = Layer.effect(
113 Archive,
114 Effect.gen(function* () {
115 // Resolve both references at init.
116 const state = yield* Cloudflare.DurableObjectState;
117 const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
118
119 return Archive.of({
120 // The body runs at runtime — both calls are RuntimeContext-colored.
121 save: (key, body) =>
122 Effect.gen(function* () {
123 yield* bucket.put(key, body);
124 yield* state.storage.put(`saved:${key}`, Date.now());
125 }),
126 });
127 }),
128);
129```
130
131Provide `ArchiveLive` on the DO's init and `yield* Archive` from the
132inner Effect:
133
134```typescript
135export class Doc extends Cloudflare.DurableObjectNamespace<Doc>()(
136 "Doc",
137 Effect.gen(function* () {
138 const archive = yield* Archive; // resolved at init
139 return Effect.gen(function* () {
140 return {
141 save: (key: string, body: string) => archive.save(key, body),
142 };
143 });
144 }).pipe(Effect.provide(ArchiveLive)),
145) {}
146```
147
148Neither `DurableObjectState` nor R2 leaks into `Archive`'s public
149type — consumers see only `RuntimeContext`, so the service stays
150cloud-agnostic.
151
152## A tool on a running Container
153
154Containers used to be a three-step dance — `bind`, then `start`, then
155use. beta.58 collapses that: `yield*` the Container tag to get the
156**running container instance** (not the `ContainerApplication`
157resource), and provide `Cloudflare.layerContainer(Class, options)` to
158configure how it runs.
159
160```diff lang="typescript"
161 Effect.gen(function* () {
162- const sandbox = yield* Cloudflare.Container.bind(Sandbox);
163+ const sandbox = yield* Sandbox;
164
165 return Effect.gen(function* () {
166- const container = yield* Cloudflare.start(sandbox, {
167- enableInternet: true,
168- });
169 return {
170- exec: (cmd: string) => container.exec(cmd),
171+ exec: (cmd: string) => sandbox.exec(cmd),
172 };
173 });
174- }),
175+ }).pipe(
176+ Effect.provide(
177+ Cloudflare.layerContainer(Sandbox, { enableInternet: true }),
178+ ),
179+ ),
180```
181
182`layerContainer` binds, starts, and monitors the container, then
183satisfies the `Sandbox` tag with the running instance. Because it's a
184plain `Layer`, **any** Effect or Layer can `yield* Sandbox` and get a
185running container — so a `Bash` tool is just a Layer that captures the
186container and shells out to it:
187
188```typescript
189const Bash = Layer.effect(
190 BashTool,
191 Effect.gen(function* () {
192 const sandbox = yield* Sandbox; // running instance
193 return BashTool.of({
194 run: (cmd: string) => sandbox.exec(cmd),
195 });
196 }),
197);
198```
199
200The `Bash` Layer just *requires* `Sandbox` — `layerContainer` is
201provided once on the host's outermost init Effect, where it satisfies
202both `Bash` and anything else that needs the container:
203
204```typescript
205.pipe(
206 Effect.provide(
207 Layer.provideMerge(Bash, Cloudflare.layerContainer(Sandbox)),
208 ),
209)
210```
211
212External and remote `Container` images (built from a `context` /
213`dockerfile`, or pulled from a registry) have no runtime shape and no
214`.make()` — declare their props inline on the tag and register them
215with `Cloudflare.layerContainer` from the hosting Durable Object.
216
217## RPC works in Containers
218
219Container RPC always existed on paper, but was never properly
220implemented or tested. As of beta.58 it actually works. A `Container`
221declares a typed runtime shape just like a Worker or Durable Object,
222and those methods are callable over **RPC** — the container exposes
223Effect-returning methods alongside its `fetch` handler:
224
225```typescript
226export class MyContainer extends Cloudflare.Container<
227 MyContainer,
228 {
229 ping: () => Effect.Effect<string>;
230 readObject: (key: string) => Effect.Effect<string | null, never, RuntimeContext>;
231 }
232>()("MyContainer") {}
233
234export default MyContainer.make(
235 { main: import.meta.filename, dockerfile: "FROM oven/bun:latest" },
236 Effect.gen(function* () {
237 const bucket = yield* Cloudflare.R2.ReadWriteBucket(Storage);
238 return {
239 ping: () => Effect.succeed("pong"),
240 readObject: (key) => bucket.get(key).pipe(/* ... */),
241 fetch: Effect.gen(function* () {
242 /* ... */
243 }),
244 };
245 }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketHttp)),
246);
247```
248
249From the host, `yield* MyContainer` gives the running container and
250its methods round-trip through Cloudflare's RPC machinery as typed
251Effects — no `fetch` plumbing, no manual serialization:
252
253```typescript
254const container = yield* MyContainer;
255const pong = yield* container.ping(); // RPC
256const body = yield* container.readObject("key"); // RPC
257```
258
259Use `container.getTcpPort(port)` when you do need a raw `fetch` into a
260server running inside the container process.
261
262## `WorkerLoader`
263
264`DynamicWorkerLoader` is renamed to `WorkerLoader`, and `load(...)`
265now returns an Effect, so you `yield*` it
266([#653](https://github.com/alchemy-run/alchemy/pull/653)):
267
268```diff lang="typescript"
269- const loader = yield* Cloudflare.DynamicWorkerLoader("Loader");
270- const worker = loader.load({ compatibilityDate, mainModule, modules });
271+ const loader = yield* Cloudflare.WorkerLoader("Loader");
272+ const worker = yield* loader.load({ compatibilityDate, mainModule, modules });
273```
274
275## Bindings: least-privilege, native or HTTP
276
277Bindings move to a **namespaced convention**: the single `.bind` on a
278resource is gone, replaced by a capability namespaced per **access
279level**.
280
281```diff lang="typescript"
282- const bucket = yield* Cloudflare.R2Bucket.bind(Bucket);
283+ const bucket = yield* Cloudflare.R2.ReadBucket(Bucket); // get / head / list
284+ const bucket = yield* Cloudflare.R2.WriteBucket(Bucket); // put / delete / multipart
285+ const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket); // both
286```
287
288Pick the least privilege you need, then provide an implementation
289Layer: the native Worker binding, or a scoped HTTP API token.
290
291```typescript
292// native Worker binding (env.BUCKET)
293.pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding))
294
295// scoped HTTP API token — same client, runs anywhere (e.g. inside
296// a Container process that has no native binding)
297.pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketHttp))
298```
299
300The client interface is **identical** regardless of which Layer you
301provide. A Worker can read a bucket over its native binding while a
302container process reads the same bucket over a token — same code,
303different Layer.
304
305The `*Http` Layer is what makes the same client work where there's
306no native binding — inside a **Container** process, for example.
307Providing it mints a scoped `AccountApiToken` and binds a policy with
308only the permission groups that access level needs:
309
310```typescript
311const token = yield* AccountApiToken(`${self.LogicalId}Token`);
312yield* token.bind`${bucket.LogicalId}`({
313 policies: [
314 {
315 effect: "allow",
316 permissionGroups: options.permissionGroups, // e.g. R2 read-only
317 resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
318 },
319 ],
320});
321```
322
323The token's `value` is injected into the host's **secrets** —
324`secret_text` on a Worker, a secret on a Container. At runtime the
325client reads it and calls R2's REST API. No account-wide key, no
326manual token plumbing.
327
328The Read / Write / ReadWrite split and `*Binding` / `*Http`
329implementations follow the same convention across KV, Queues, and the
330other capability resources. This namespaced convention is the
331direction for all bindings — the full migration across AWS,
332Cloudflare, and the other providers lands in beta.59.
333
334## `Command`: run shell commands as resources
335
336The `Build` module is gone, replaced by a unified `Command` module
337that models shell commands as first-class resources sharing one
338executor ([#673](https://github.com/alchemy-run/alchemy/pull/673)).
339
340`Command.Build` (was `Build.Command`) runs a command that produces an
341output asset and tracks `outdir` in state. Inputs and outputs are
342content-hashed (opt-in via `memo`) so an unchanged project skips the
343rebuild:
344
345```typescript
346const build = yield* Command.Build("vite-build", {
347 command: "npm run build",
348 cwd: "./frontend",
349 outdir: "dist",
350});
351yield* Console.log(build.outdir); // path to dist, relative to process.cwd()
352```
353
354`Command.Exec` is new. It runs a command purely for its side effects —
355migrations, codegen, seeding — with no output contract: it succeeds as
356long as the command exits `0`. `memo` re-runs it only when its inputs
357change:
358
359```typescript
360yield* Command.Exec("migrate", {
361 command: "npm run db:migrate",
362 cwd: "./packages/api",
363});
364```
365
366`Command.Dev` (was `Build.DevServer`) is a long-lived dev server scoped
367to the stack instance. It runs in the dev sidecar so it survives
368user-code HMR, and exposes the first `http(s)://` URL it prints as its
369`url` output. It's a no-op during `alchemy deploy`:
370
371```typescript
372const dev = yield* Command.Dev("Frontend", {
373 command: "npm run dev",
374});
375yield* Console.log(dev.url); // e.g. "http://localhost:5173"
376```
377
378`StaticSite` (AWS + Cloudflare) and `Cloudflare.Workers.Worker` now
379build and dev-serve through `Command`. Resource type IDs were
380renamed, so existing state referencing `Build.Command` /
381`Build.DevServer` won't match, and `Command.Build`'s output hash
382changed from `{ outdir, hash }` to `{ outdir, hash: { input, output } }`.
383
384## Where to go next
385
386- [Run a Container](/cloudflare/compute/run-a-container)
387- [Add a Durable Object](/cloudflare/compute/durable-objects)
388- [Cross-Worker Durable Objects](/cloudflare/compute/cross-worker-durable-object)
389- [Runtime as a colored function](/infrastructure-as-effects/layers#the-types-hold-the-boundary)
390- [CHANGELOG](https://github.com/alchemy-run/alchemy/blob/main/CHANGELOG.md#v200-beta58)
391- [Compare v2.0.0-beta.57 → v2.0.0-beta.58](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.57...v2.0.0-beta.58)