Skip to content

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.

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* () {
/* ... */
}),
);

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.

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 SandboxlayerContainer 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.

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(); // RPC
const body = yield* container.readObject("key"); // RPC

Use container.getTcpPort(port) when you do need a raw fetch into a server running inside the container process.

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 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 / list
const bucket = yield* Cloudflare.R2.WriteBucket(Bucket); // put / delete / multipart
const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket); // both

Pick 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 secretssecret_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.

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 } }.