2.0.0-beta.58 - Platform Ergonomics
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.
A few changes get us there. First, a Platform tag (e.g. a Worker or Container) becomes pure identity: its props and its runtime implementation move out of the tag and into a Layer.
export class WorkerB extends Cloudflare.Worker<WorkerB>()( "WorkerB", { main: import.meta.filename }, // props on the tag) {}export class WorkerB extends Cloudflare.Worker<WorkerB>()("WorkerB") {}
export default WorkerB.make( { main: import.meta.filename }, // props on the Layer Effect.gen(function* () { /* ... */ }),);Also, DurableObjectState can now be yielded at the top level,
instead of having to be resolved inside the nested Effect.gen:
Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; return Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; let count = (yield* state.storage.get<number>("count")) ?? 0; /* ... */ });}),Initialization is where resources, bindings, and dependencies are declared, so a Layer can contain infrastructure and be provided to any Worker, Durable Object, or Container without changing its shape.
One tag, many implementations
Section titled “One tag, many implementations”Because the tag is pure identity, one tag can have many
implementations. .make(propsA, implA) and .make(propsB, implB)
produce two Layers for the same tag — swap a real implementation for
a stub in tests, or vary config per stage:
export const SandboxProd = Sandbox.make( { main: import.meta.filename, instanceType: "standard-1" }, realImpl,);
export const SandboxTest = Sandbox.make( { main: import.meta.filename, instanceType: "dev" }, fakeImpl,);The inline form is unchanged — pass the implementation as a third argument and props stay inline:
export default Cloudflare.Worker( "Worker", { main: import.meta.filename }, // still inline here Effect.gen(function* () { /* ... */ }),);A tool on Durable Object storage
Section titled “A tool on Durable Object storage”Resolving DurableObjectState at init is what lets you capture the
DO’s storage in a Layer and expose it as a service — the shape a
Sql tool takes. Resolve state (and anything else, like an R2
bucket) at init, return the runtime methods:
class Archive extends Context.Service<Archive, { save: (key: string, body: string) => Effect.Effect<void, never, Alchemy.RuntimeContext>; // <- RuntimeContext-colored}>()("Archive") {}
const ArchiveLive = Layer.effect( Archive, Effect.gen(function* () { // Resolve both references at init. const state = yield* Cloudflare.DurableObjectState; const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
return Archive.of({ // The body runs at runtime — both calls are RuntimeContext-colored. save: (key, body) => Effect.gen(function* () { yield* bucket.put(key, body); yield* state.storage.put(`saved:${key}`, Date.now()); }), }); }),);Provide ArchiveLive on the DO’s init and yield* Archive from the
inner Effect:
export class Doc extends Cloudflare.DurableObjectNamespace<Doc>()( "Doc", Effect.gen(function* () { const archive = yield* Archive; // resolved at init return Effect.gen(function* () { return { save: (key: string, body: string) => archive.save(key, body), }; }); }).pipe(Effect.provide(ArchiveLive)),) {}Neither DurableObjectState nor R2 leaks into Archive’s public
type — consumers see only RuntimeContext, so the service stays
cloud-agnostic.
A tool on a running Container
Section titled “A tool on a running Container”Containers used to be a three-step dance — bind, then start, then
use. beta.58 collapses that: yield* the Container tag to get the
running container instance (not the ContainerApplication
resource), and provide Cloudflare.layerContainer(Class, options) to
configure how it runs.
Effect.gen(function* () { const sandbox = yield* Cloudflare.Container.bind(Sandbox); const sandbox = yield* Sandbox;
return Effect.gen(function* () { const container = yield* Cloudflare.start(sandbox, { enableInternet: true, }); return { exec: (cmd: string) => container.exec(cmd), exec: (cmd: string) => sandbox.exec(cmd), }; });}),}).pipe( Effect.provide( Cloudflare.layerContainer(Sandbox, { enableInternet: true }), ),),layerContainer binds, starts, and monitors the container, then
satisfies the Sandbox tag with the running instance. Because it’s a
plain Layer, any Effect or Layer can yield* Sandbox and get a
running container — so a Bash tool is just a Layer that captures the
container and shells out to it:
const Bash = Layer.effect( BashTool, Effect.gen(function* () { const sandbox = yield* Sandbox; // running instance return BashTool.of({ run: (cmd: string) => sandbox.exec(cmd), }); }),);The Bash Layer just requires Sandbox — layerContainer is
provided once on the host’s outermost init Effect, where it satisfies
both Bash and anything else that needs the container:
.pipe( Effect.provide( Layer.provideMerge(Bash, Cloudflare.layerContainer(Sandbox)), ),)External and remote Container images (built from a context /
dockerfile, or pulled from a registry) have no runtime shape and no
.make() — declare their props inline on the tag and register them
with Cloudflare.layerContainer from the hosting Durable Object.
RPC works in Containers
Section titled “RPC works in Containers”Container RPC always existed on paper, but was never properly
implemented or tested. As of beta.58 it actually works. A Container
declares a typed runtime shape just like a Worker or Durable Object,
and those methods are callable over RPC — the container exposes
Effect-returning methods alongside its fetch handler:
export class MyContainer extends Cloudflare.Container< MyContainer, { ping: () => Effect.Effect<string>; readObject: (key: string) => Effect.Effect<string | null, never, RuntimeContext>; }>()("MyContainer") {}
export default MyContainer.make( { main: import.meta.filename, dockerfile: "FROM oven/bun:latest" }, Effect.gen(function* () { const bucket = yield* Cloudflare.R2.ReadWriteBucket(Storage); return { ping: () => Effect.succeed("pong"), readObject: (key) => bucket.get(key).pipe(/* ... */), fetch: Effect.gen(function* () { /* ... */ }), }; }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketHttp)),);From the host, yield* MyContainer gives the running container and
its methods round-trip through Cloudflare’s RPC machinery as typed
Effects — no fetch plumbing, no manual serialization:
const container = yield* MyContainer;const pong = yield* container.ping(); // RPCconst body = yield* container.readObject("key"); // RPCUse container.getTcpPort(port) when you do need a raw fetch into a
server running inside the container process.
WorkerLoader
Section titled “WorkerLoader”DynamicWorkerLoader is renamed to WorkerLoader, and load(...)
now returns an Effect, so you yield* it
(#653):
const loader = yield* Cloudflare.DynamicWorkerLoader("Loader");const worker = loader.load({ compatibilityDate, mainModule, modules });const loader = yield* Cloudflare.WorkerLoader("Loader");const worker = yield* loader.load({ compatibilityDate, mainModule, modules });Bindings: least-privilege, native or HTTP
Section titled “Bindings: least-privilege, native or HTTP”Bindings move to a namespaced convention: the single .bind on a
resource is gone, replaced by a capability namespaced per access
level.
const bucket = yield* Cloudflare.R2Bucket.bind(Bucket);const bucket = yield* Cloudflare.R2.ReadBucket(Bucket); // get / head / listconst bucket = yield* Cloudflare.R2.WriteBucket(Bucket); // put / delete / multipartconst bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket); // bothPick the least privilege you need, then provide an implementation Layer: the native Worker binding, or a scoped HTTP API token.
// native Worker binding (env.BUCKET).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding))
// scoped HTTP API token — same client, runs anywhere (e.g. inside// a Container process that has no native binding).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketHttp))The client interface is identical regardless of which Layer you provide. A Worker can read a bucket over its native binding while a container process reads the same bucket over a token — same code, different Layer.
The *Http Layer is what makes the same client work where there’s
no native binding — inside a Container process, for example.
Providing it mints a scoped AccountApiToken and binds a policy with
only the permission groups that access level needs:
const token = yield* AccountApiToken(`${self.LogicalId}Token`);yield* token.bind`${bucket.LogicalId}`({ policies: [ { effect: "allow", permissionGroups: options.permissionGroups, // e.g. R2 read-only resources: { [`com.cloudflare.api.account.${accountId}`]: "*" }, }, ],});The token’s value is injected into the host’s secrets —
secret_text on a Worker, a secret on a Container. At runtime the
client reads it and calls R2’s REST API. No account-wide key, no
manual token plumbing.
The Read / Write / ReadWrite split and *Binding / *Http
implementations follow the same convention across KV, Queues, and the
other capability resources. This namespaced convention is the
direction for all bindings — the full migration across AWS,
Cloudflare, and the other providers lands in beta.59.
Command: run shell commands as resources
Section titled “Command: run shell commands as resources”The Build module is gone, replaced by a unified Command module
that models shell commands as first-class resources sharing one
executor (#673).
Command.Build (was Build.Command) runs a command that produces an
output asset and tracks outdir in state. Inputs and outputs are
content-hashed (opt-in via memo) so an unchanged project skips the
rebuild:
const build = yield* Command.Build("vite-build", { command: "npm run build", cwd: "./frontend", outdir: "dist",});yield* Console.log(build.outdir); // path to dist, relative to process.cwd()Command.Exec is new. It runs a command purely for its side effects —
migrations, codegen, seeding — with no output contract: it succeeds as
long as the command exits 0. memo re-runs it only when its inputs
change:
yield* Command.Exec("migrate", { command: "npm run db:migrate", cwd: "./packages/api",});Command.Dev (was Build.DevServer) is a long-lived dev server scoped
to the stack instance. It runs in the dev sidecar so it survives
user-code HMR, and exposes the first http(s):// URL it prints as its
url output. It’s a no-op during alchemy deploy:
const dev = yield* Command.Dev("Frontend", { command: "npm run dev",});yield* Console.log(dev.url); // e.g. "http://localhost:5173"StaticSite (AWS + Cloudflare) and Cloudflare.Workers.Worker now
build and dev-serve through Command. Resource type IDs were
renamed, so existing state referencing Build.Command /
Build.DevServer won’t match, and Command.Build’s output hash
changed from { outdir, hash } to { outdir, hash: { input, output } }.