2.0.0-beta.86 - Domain on Rivet & Effect Cluster, Sandboxes
beta.86 is 150 pull requests from 24 contributors: 65 features and 60 bug fixes.
Alchemy Domain models now run on Rivet and Effect Cluster, as well as on
Cloudflare. Which platform hosts the model is one Layer, and your model code
stays the same. Both platforms are new packages, @alchemy.run/rivet and
@alchemy.run/effect-cluster, and both deploy to Docker, AWS, GCP, Fly,
Hetzner, Kubernetes and more.
The release also adds six sandbox providers for running untrusted code:
E2B, Daytona, Modal, Archil, Boat and AWS ECS, where each sandbox is a
Nitro Sandbox in your own account. Each one creates sandboxes from any
Worker, Lambda or container, and they share one handle (exec, start,
files, pty, getTcpPort), so you can pick a provider by providing its
Layer.
Resource.lookup now binds resources your stack doesn’t manage, on
every provider, and RemovalPolicy.protect() refuses any plan that would
replace or delete a resource.
Actors outside of Cloudflare
Section titled “Actors outside of Cloudflare”Two new packages run actors, stateful objects addressed by name, on any cloud: Rivet’s Durable Objects and Effect Cluster’s entities.
@alchemy.run/rivet deploys Durable Objects to Rivet
control planes. The Durable Object is what you deploy. The control plane and
the pool its nodes run in are Layers, so moving to another cloud changes two
lines (#2849):
export const Cluster = RivetDocker.SwarmControlPlane("Rivet");export const Default = RivetDocker.SwarmPool("Default").pipe(export const Cluster = RivetAWS.Ec2ControlPlane("Rivet");export const Default = RivetAWS.EcsPool("Default").pipe( Layer.provide(Cluster),);export default class Room extends Rivet.DurableObject<Room>()( "Room", { main: import.meta.url }, Effect.gen(function* () { return Effect.gen(function* () { const state = yield* Rivet.DurableObjectState; return { join: () => state.storage.transaction( Effect.gen(function* () { const n = ((yield* state.storage.get<number>("n")) ?? 0) + 1; yield* state.storage.put("n", n); return n; }), ), }; }); }).pipe(Effect.provide(Default)),) {}Any runtime calls an instance by name: a Worker, a Lambda, a container or another Durable Object.
const rooms = yield* Room;const visitors = yield* rooms.getByName("lobby").join();Every cloud has a control plane and a pool, and a pool can join a control plane on another cloud:
// Docker Swarm, on your machine or a serverRivetDocker.SwarmControlPlane("Rivet");RivetDocker.SwarmPool("Default");
// AWSRivetAWS.Ec2ControlPlane("Rivet"); // or EcsControlPlaneRivetAWS.EcsPool("Default"); // or Ec2Pool
// GCPRivetGCP.ComputeEngineControlPlane("Rivet");RivetGCP.CloudRunPool("Default"); // or ComputeEnginePool
// Fly MachinesRivetFly.MachineControlPlane("Rivet");RivetFly.MachinePool("Default");
// HetznerRivetHetzner.ServerControlPlane("Rivet");RivetHetzner.ServerPool("Default");
// Kubernetes: EKS, GKE, kind or any clusterRivetKubernetes.StatefulSetControlPlane("Rivet", { cluster: Eks });RivetKubernetes.DeploymentPool("Default");
// Railway, against a control plane on another cloudRivetRailway.ServicePool("Default");
// Rivet CloudRivetCloud.ControlPlane("Rivet");RivetCloud.Pool("Default");
// a control plane you already runRivet.ControlPlane("Rivet", { endpoint, token });A self-hosted control plane serves its gateway over HTTPS without a domain of your own, and its engines have no public address.
Durable Streams are typed logs next to your objects:
export class ChatLog extends Rivet.DurableStream<ChatLog>()("ChatLog", { schema: Schema.Struct({ user: Schema.String, text: Schema.String }), ttl: "7 days",}) {}
const log = yield* ChatLog;yield* log.append("lobby", { user: "sam", text: "hello" });const live = log.tail("lobby"); // a Stream that reconnects from its offsetThe wire format is a Layer. Use StreamFormat.Json, StreamFormat.Ndjson,
or StreamFormat.SchemaBinary() for Effect’s compact binary codec, derived
from the stream’s Schema:
Effect.provide(Layer.mergeAll(Default, Services, StreamFormat.SchemaBinary()));Rust Actors written with Rivet’s rivetkit crate deploy on every pool.
Alchemy compiles the crate into the pool’s node image, with dependencies in
their own cached layer
(#3118):
export const Counter = Rivet.DurableObject("Counter", { crate: "./crates/counter", build: { toolchain: "1.85", runtimePackages: ["libssl3"] },}).pipe(Effect.provide(Counters));Docs: Rivet · What is Rivet? · Pools · Durable Streams · Stream formats · Rust Actors.
Effect Cluster
Section titled “Effect Cluster”@alchemy.run/effect-cluster deploys Effect
Cluster entities, singletons, crons and workflows.
The database the runners coordinate through and the pool they run in are
Layers, so moving to another host changes two lines
(#2924):
export const Database = ClusterDocker.Database("Cluster");export const Default = ClusterDocker.SwarmPool("Default").pipe(export const Database = ClusterAWS.Database("Cluster"); // RDSexport const Default = ClusterAWS.EcsPool("Default").pipe( // Fargate Layer.provide(Database),);An entity is declared like Effect Cluster’s Entity.make, and implemented as
a Layer:
export const Increment = Rpc.make("Increment", { payload: { amount: Schema.Int }, success: Schema.Int,});
export class Counter extends Cluster.Entity<Counter>()("Counter", [ Increment,]) {}
// src/CounterLive.tsexport default Counter.make( { main: import.meta.url }, Effect.gen(function* () { return Effect.gen(function* () { const count = yield* Ref.make(0); return Counter.of({ Increment: ({ payload }) => Ref.updateAndGet(count, (n) => n + payload.amount), }); }); }).pipe(Effect.provide(Default)),);Any runtime calls it through the pool’s gateway:
const counters = yield* Counter;const counter = yield* counters.getByName("visits");yield* counter.Increment({ amount: 1 });Every host is a database and a pool:
// Docker Swarm, on your machine or a serverClusterDocker.Database("Cluster");ClusterDocker.SwarmPool("Default");
// AWS: RDS and ECS FargateClusterAWS.Database("Cluster");ClusterAWS.EcsPool("Default");
// GCP: Compute EngineClusterGCP.Database("Cluster");ClusterGCP.ComputeEnginePool("Default");
// Fly MachinesClusterFly.Database("Cluster");ClusterFly.MachinePool("Default");
// HetznerClusterHetzner.Database("Cluster");ClusterHetzner.ServerPool("Default");
// Kubernetes: EKS, GKE, kind or any clusterClusterKubernetes.Database("Cluster", { cluster: Eks });ClusterKubernetes.DeploymentPool("Default");
// Cloudflare Durable Objects: no databaseClusterCloudflare.DurableObjectPool("Default");
// a Postgres you already run: Neon, PlanetScale, RDSCluster.Database.fromUrl({ url: Config.Redacted("DATABASE_URL") });Cluster.Workflow, Cluster.Singleton and Cluster.Cron
work the same way, and an existing Effect Cluster program runs as it is on a
pool of its own. Every host passes the same 20 conformance cases.
Docs: Effect Cluster · What is Effect Cluster? · Pools · Databases · Workflows.
Alchemy Domain
Section titled “Alchemy Domain”Run a model on Rivet or Effect Cluster
Section titled “Run a model on Rivet or Effect Cluster”A Domain model used to run on Cloudflare Durable Objects. It now runs on Rivet Durable Objects and on Effect Cluster entities too. The model, its handlers and its Apis don’t change. The host is one Layer (#3143, #3161):
// src/BankLive.ts: the model on Cloudflare Durable Objectsexport default Bank.layer(BankHandlers).pipe( Layer.provide(DomainCloudflare.layerDurableObjects()),);// src/BankLive.ts: the same model on Rivetexport default Bank.layer(BankHandlers).pipe( Layer.provide(DomainCloudflare.layerDurableObjects()), Layer.provide( DomainRivet.layerDurableObjects({ name: "Bank", main: import.meta.url, pool: Default, }), ),);DomainCluster.layerEntities takes the same options for Effect Cluster. The
pool’s nodes load the file’s default export and host the model there.
Provide BankLive wherever you serve the Api: a Cloudflare Worker, a Lambda
or a container. On Rivet and Effect Cluster, that caller deploys the class and
reaches it through the pool’s gateway, so it needs no database access or
route into the pool.
import BankLive from "./BankLive.ts";
export default class BankWorker extends Cloudflare.Worker<BankWorker>()( "BankWorker", { main: import.meta.url }, Effect.gen(function* () { const api = yield* CustomerApi; return { fetch: api.fetch }; }).pipe(Effect.provide(BankLive)),) {}Every aggregate instance, view key and policy runs as its own Rivet actor or
Cluster entity, named like Bank.AccountAggregate/a-1. Sagas run on Rivet’s
Durable Object workflow engine or Effect Cluster’s ClusterWorkflowEngine.
One Domain conformance suite runs on all three platforms.
Docs: Domain on Rivet · Domain on Effect Cluster · Domain on Cloudflare.
One Durable Object class per model
Section titled “One Durable Object class per model”On Cloudflare, a model now lives in one Durable Object class named after it, instead of one class per aggregate, view, policy and saga. Every instance is still its own object, named by what it holds. A Worker declares one class per model however many definitions it has, so it stays far from Cloudflare’s limit of 100 classes per upload (#3049):
Worker class Bank.AccountAggregate class Bank.AccountSummaryView class Bank.OpenAccountWorkflow class Bank.DomainBank.Domain / "Bank.AccountAggregate/a-1"Bank.Domain / "Bank.AccountSummaryView/a-1"Bank.Domain / "Bank.OpenAccountWorkflow/s-1"You don’t migrate anything. The next deploy moves every object of the old classes into its model’s class while the Worker keeps serving, then deletes the old classes. A deploy killed partway leaves every key served, and the next deploy finishes the move:
Moving 2538 Domain objects of 4 classes to Bank.Domain ...Moved 2538 Domain objects to Bank.DomainDeleting 4 old Domain classes: every object of theirs has moved.Classes from before beta.85 move the same way, without a rename (#3165). If two models in one Worker listed the same aggregate, they shared its class back then. The old data goes to every model by default, so the models that should start empty say so:
export const AuditLive = Audit.layer(AuditHandlers, { legacyData: false });Handlers live on Layers
Section titled “Handlers live on Layers”Every Domain declaration is now plain data plus, where it needs one, a Layer. Rules that only compute stay on the declaration. Code that reads services or checks who may read lives on a Layer (#3078):
export class AccountSummary extends Domain.View("AccountSummary", { from: [Account], key: Account, middleware: [Session], rejects: [NotOwner], state: Summary, initial, evolve: { MoneyWithdrawn: setBalance },}) {}
export const AccountSummaryLive = AccountSummary.toLayer( Effect.succeed( Effect.fn(function* (_request, summary) { yield* mustOwn(summary?.customerId ?? null); }), ),);Bank.layer(handlers) is a type error until every guarded view and stream
has its Layer. Clients never read an aggregate. They read views, streams and
queries.
Docs: View security · Aggregate.
Queries
Section titled “Queries”Domain.Query is a typed read across keys, answered from a store a
projection writes. After its own command, a client passes the receipt, and
the query waits until that write is in the store
(#2803,
#3012). Thanks
Taras Kornichuk!
export class AccountLedger extends Domain.Query("AccountLedger", { input: { account: AccountId }, success: LedgerRow, paged: true, waitsFor: [Ledger], middleware: [Session], rejects: [NotOwner],}) {}
const receipt = yield* bank.WithdrawMoney(accountId, { amount: 30 });const page = yield* bank.AccountLedger( { account: accountId }, { atLeast: receipt, take: 20 },);A paged query also streams every entry, fetching each page on demand, so
Stream.take stops early
(#3174):
const latest = yield* bank.AccountLedger.entries(input, { take: 100 }).pipe( Stream.take(10), Stream.runCollect,);Queries are served over HTTP (as QUERY /AccountLedger), the CLI and MCP.
$ bank account-ledger --account a-1 --take 20Docs: Query.
Expose only what you list
Section titled “Expose only what you list”toHttpApi, toMcp, toToolkit and toCli publish only the calls you
list in expose, and the list is typed
(#2950):
const BankHttp = DomainHttp.toHttpApi(CustomerApi, { expose: ["DepositMoney", "AccountSummary.get", "AccountSummary.watch"],});
DomainAi.toMcp(CustomerApi, { expose: ["AccountSummary.watch"] });// type error: a stream is not a toolAnnotations on every declaration
Section titled “Annotations on every declaration”Every declaration takes annotations as its last argument. A description
becomes the MCP tool’s text, the CLI help and the OpenAPI description
(#2990):
export class Withdraw extends Domain.Command( "Withdraw", { target: WalletId, input: { amount: Schema.Int }, events: [Withdrawn] }, { description: "Take money out of a wallet. The amount is in cents." },) {}Docs: Annotations.
Stories run every order of concurrent work
Section titled “Stories run every order of concurrent work”A story now runs every order of its concurrent work that can change the
outcome, and every run must pass. When the model doesn’t order two events,
say so with Story.anyOrder
(#3144):
story( Story.when(subject, new Request()), Story.then(subject, new Requested(), new Settled()), Story.then( registry, Story.anyOrder(new Recorded(), new Admitted({ payloadHash })), ),);// runs twice, policy first and saga first; both must passA then that holds in only some orders fails every time, and names the
order that broke it, instead of flaking.
Docs: Story.
Sandboxes for untrusted code
Section titled “Sandboxes for untrusted code”Six new providers create sandboxes: VMs you can throw away, for code you didn’t write. Use them for agents, code interpreters, CI jobs and previews: E2B, Daytona, Modal, Archil, Boat and AWS ECS (#2930, #2931, #2934, #2933, #3156, #3242).
Each provider builds what its sandboxes start from at deploy, and a runtime binds to it and starts a sandbox per request (#3171).
E2B builds a template from a Dockerfile and snapshots it after setup. Sandboxes start from the snapshot in about a second, pause idle with their memory, and resume on the next call:
// src/Sandbox.ts: built once, at deployexport const Node = E2B.Template("Node", { dockerfile: Dockerfile.inline` FROM node:22-bookworm RUN apt-get update && apt-get install -y git `,});export const Warm = E2B.Snapshot("Warm", { template: Node, run: ["git clone --depth 1 https://github.com/sindresorhus/is repo"],});// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime worksexport default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const sandboxes = yield* E2B.CreateSandbox(Warm); return { fetch: Effect.gen(function* () { const sandbox = yield* sandboxes.create({ timeout: 120 }); const result = yield* sandbox.exec("npm test", { cwd: "/home/user/repo", }); return yield* HttpServerResponse.json(result); }), }; }).pipe(Effect.provide(E2B.CreateSandboxHttp)),) {}Daytona
Section titled “Daytona”Daytona gives the whole toolbox (processes, files, git, LSP, code interpreter, computer use, SSH), snapshots built from other snapshots, and warm pools that keep sandboxes ready to hand out:
// src/Sandbox.ts: built once, at deployexport const Base = Daytona.Snapshot("Base", { image: "node:22-bookworm", cpu: 2, memory: 4,});export const Warm = Daytona.Snapshot("Warm", { from: Base, run: ["git clone --depth 1 https://github.com/sindresorhus/is repo"],});export const Pool = Daytona.WarmPool("Pool", { snapshot: Warm, size: 4 });// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime worksexport default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const sandboxes = yield* Daytona.CreateSandbox(Warm); return { fetch: Effect.gen(function* () { const sandbox = yield* sandboxes.create(); const result = yield* sandbox.exec("npm test", { cwd: "/home/daytona/repo", }); return yield* HttpServerResponse.json(result); }), }; }).pipe(Effect.provide(Daytona.CreateSandboxHttp)),) {}Modal builds images on its own builders from a base or another image, and keeps persistent sandboxes whose files survive between sessions:
// src/Sandbox.ts: built once, at deployexport const Coder = Modal.App("Coder", {});export const Node = Modal.Image("Node", { app: Coder, image: "node:22-bookworm", commands: ["RUN apt-get update && apt-get install -y git"],});export const Warm = Modal.Image("Warm", { app: Coder, from: Node, run: ["git clone --depth 1 https://github.com/sindresorhus/is /work"],});// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime worksexport default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const sandboxes = yield* Modal.CreateSandbox(Warm); return { fetch: Effect.gen(function* () { const sandbox = yield* sandboxes.create({ timeout: "5 minutes" }); const result = yield* sandbox.exec("npm test", { cwd: "/work", }); return yield* HttpServerResponse.json(result); }), }; }).pipe(Effect.provide(Modal.CreateSandboxHttp)),) {}Modal goes beyond sandboxes, with Functions, Servers, Volumes, Dicts, Queues
and model Endpoints. A Modal.Function holds its own code, runs on a GPU,
and binds Volumes, Dicts and other Functions like any other runtime. Callers
reach its methods by name, each with its own key, and the Function calls
Modal without a stored token
(#3137):
export default class Embed extends Modal.Function<Embed>()( "Embed", { main: import.meta.url, app: Coder, gpu: "L4" }, Effect.gen(function* () { const weights = yield* Modal.MountVolume(Weights, { path: "/models", readOnly: true, }); const cache = yield* Modal.ReadWriteDict(Cache); const model = yield* loadModel(weights.path); return { embed: Effect.fn(function* (text: string) { const vector = yield* model.embed(text); yield* cache.put(text, vector); return vector; }), }; }).pipe(Effect.provide(Modal.ReadWriteDictHttp)),) {}// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime worksexport default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const embed = yield* Modal.InvokeFunction(Embed); return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const result = yield* embed.embed(yield* request.text); return yield* HttpServerResponse.json(result); }), }; }).pipe(Effect.provide(Modal.InvokeFunctionHttp)),) {}Archil
Section titled “Archil”Archil sandboxes run on disks that branch and checkpoint. A sandbox set up at deploy is forked for each request, and the same disks mount into a Cloudflare Container or an ECS task, with S3-style object access on a host’s own least-privilege keys:
// src/Sandbox.ts: set up once, at deployexport const Golden = Archil.Sandbox("Golden", { run: ["git clone --depth 1 https://github.com/sindresorhus/is /work/is"],});// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime worksexport default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const sandboxes = yield* Archil.CreateSandbox(Golden); return { fetch: Effect.gen(function* () { const sandbox = yield* sandboxes.create({ name: `run-${crypto.randomUUID()}`, }); const result = yield* sandbox.exec("npm test", { cwd: "/work/is", }); return yield* HttpServerResponse.json(result); }), }; }).pipe(Effect.provide(Archil.CreateSandboxHttp)),) {}Thanks Dallen Pyrah for typing Archil’s errors (#3154)!
Boat stops a sandbox with its disk, forks it into a copy, and snapshots it. Environments hold the secrets for the sandboxes started in them, and it works in your Boat organization, which pays for its sandboxes and shares its environments (#3206):
// src/Sandbox.ts: built once, at deployexport const Golden = Boat.Snapshot("Golden", { run: ["git clone --depth 1 https://github.com/sindresorhus/is ~/repo"],});// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime worksexport default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const sandboxes = yield* Boat.CreateSandbox(Golden); return { fetch: Effect.gen(function* () { const sandbox = yield* sandboxes.create({ autoStop: "5 minutes" }); const result = yield* sandbox.exec("npm test", { cwd: "/home/user/repo", }); const copy = yield* sandbox.fork({ autoStop: "10 minutes" }); return yield* HttpServerResponse.json(result); }), }; }).pipe(Effect.provide(Boat.CreateSandboxHttp)),) {}AWS ECS with Nitro Sandboxes
Section titled “AWS ECS with Nitro Sandboxes”AWS.ECS.CreateSandbox starts sandboxes in your own account, from a Lambda,
a Task or any other AWS runtime. Each one is an ECS Task in its own
Nitro Sandbox,
a child VM on the instance. The image is pushed unchanged, and the handle is
the same one every other provider returns
(#3242):
export const Python = AWS.ECS.Task("Python", { image: "python:3.12-slim", // or context: "./sandbox" cpu: 1024, memory: 2048, isolation: "isolated",});
// Managed Instances; roles, security groups and instance types are createdexport const Capacity = AWS.ECS.CapacityProvider("Capacity", { cluster: Cluster.clusterName, managedInstancesProvider: { instanceLaunchTemplate: { networkConfiguration: { subnets: Network.privateSubnetIds }, nitroEnclavesEnabled: true, }, },});export default class Api extends AWS.Lambda.Function<Api>()( "Api", { main: import.meta.url, functionUrl: true }, Effect.gen(function* () { const sandboxes = yield* AWS.ECS.CreateSandbox(Capacity, Python); return { fetch: Effect.gen(function* () { const sandbox = yield* sandboxes.create({ tags: { user: "user-42" }, idleTimeout: "15 minutes", }); yield* sandbox.files.write("/work/main.py", `print("hi")`); const result = yield* sandbox.exec("python3 main.py", { cwd: "/work", }); return yield* HttpServerResponse.json(result); }), }; }).pipe(Effect.provide(AWS.ECS.CreateSandboxHttp)),) {}The binding grants the host RunTask, StopTask and DescribeTasks on the
cluster, puts a Lambda in the capacity’s subnets, and opens the sandboxes’
ports to the host only. Every call carries a token minted for that sandbox.
A Task can also be an Effect program, and its methods appear on the handle,
typed.
The Nitro Sandbox settings are props on the resources you already use,
named as in the AWS and Kubernetes APIs: trustedExecutionConfiguration on
an ECS task definition, runtimeClassName: "isolated-sandbox" on an EKS Auto
Mode Deployment, and enclaveOptions on an EC2 host
(#3205).
Create a sandbox per request
Section titled “Create a sandbox per request”Every provider has a CreateSandbox binding. A Worker creates a sandbox for
a request, runs the code with no network access, and deletes the sandbox:
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const sandboxes = yield* E2B.CreateSandbox(Agent); return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const sandbox = yield* sandboxes.create({ timeout: 120, network: { allowOut: [] }, }); yield* sandbox.files.write("main.mjs", yield* request.text); const result = yield* sandbox.exec("node main.mjs"); yield* sandbox.kill(); return yield* HttpServerResponse.json(result); }), }; }).pipe(Effect.provide(E2B.CreateSandboxHttp)),) {}Each binding mints its host’s own key, scoped to what the binding needs. Sandboxes a host creates are labeled with its stack and stage, and deleted when the host is destroyed.
One handle on every provider
Section titled “One handle on every provider”Every provider’s handle has the same calls for what every platform does (#2928), plus its own platform’s calls next to them:
// a non-zero exit is returned, not failedyield* sandbox.exec(["pnpm", "test"], { cwd: "/work" });// a scoped process: output, write, kill, waitconst proc = yield* sandbox.start("pnpm dev");yield* sandbox.files.write("/work/a.txt", "hi");const pty = yield* sandbox.pty.open({ cols: 120, rows: 40 });// a port, as on Cloudflare Containers: proxy the incoming requestconst port = yield* sandbox.getTcpPort(3000);yield* port.fetch(yield* HttpServerRequest);Pick a provider with a Layer
Section titled “Pick a provider with a Layer”A Worker uses a sandbox through a service of your own, and switching providers is the one line that provides it:
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const runner = yield* Runner; return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const result = yield* runner.run(yield* request.text); return yield* HttpServerResponse.json(result); }), }; }).pipe(Effect.provide(E2BRunner)), }).pipe(Effect.provide(DaytonaRunner)),) {}The service is written once, with one Layer per provider:
interface RunResult { exitCode: number; output: string;}
export class Runner extends Context.Service< Runner, { run: (code: string) => Effect.Effect<RunResult, never, RuntimeContext> }>()("Runner") {}
export const E2BRunner = Layer.effect( Runner, Effect.gen(function* () { const sandboxes = yield* E2B.CreateSandbox(NodeTemplate); return { run: Effect.fn(function* (code) { const sandbox = yield* sandboxes.create({ network: { allowOut: [] } }); const r = yield* sandbox.exec(["node", "-e", code]); yield* sandbox.kill(); return { exitCode: r.exitCode, output: r.stdout }; }), }; }),).pipe(Layer.provide(E2B.CreateSandboxHttp));
export const DaytonaRunner = Layer.effect( Runner, Effect.gen(function* () { const sandboxes = yield* Daytona.CreateSandbox(NodeSnapshot); return { run: Effect.fn(function* (code) { const sandbox = yield* sandboxes.create(); const r = yield* sandbox.exec(["node", "-e", code]); yield* sandboxes.delete(sandbox.sandboxId); return { exitCode: r.exitCode, output: r.result }; }), }; }),).pipe(Layer.provide(Daytona.CreateSandboxHttp));Images build remotely, without Docker
Section titled “Images build remotely, without Docker”E2B templates, Daytona snapshots and Modal images build on the platform from a Dockerfile, so a deploy needs no local Docker. A Dockerfile the platform can’t build fails at plan, before anything is uploaded:
export const Agent = E2B.Template("Agent", { dockerfile: Dockerfile.inline` FROM node:22-bookworm RUN apt-get update && apt-get install -y git ripgrep `,});E2B.Template cannot build this Dockerfile: line 3: FROM: multi-stage builds are not supported line 4: COPY --from: multi-stage builds are not supportedRun your own program in a sandbox
Section titled “Run your own program in a sandbox”Give a sandbox main, and it runs your Effect program, with bindings, at its
own URL. A code change uploads the program and restarts it in place:
export default class Search extends Boat.Sandbox<Search>()( "Search", { main: import.meta.url, public: true }, Effect.gen(function* () { return { fetch: HttpServerResponse.json({ ok: true }) }; }),) {}Keep credentials out of the sandbox
Section titled “Keep credentials out of the sandbox”Every provider can hand a sandbox a placeholder and swap the real key in as the request leaves, so the code you run never holds it. Daytona has secrets scoped to hosts, Archil and E2B have egress rules, and E2B can route through your own SOCKS5 proxy. A Modal sandbox can carry an identity Modal signs, so AWS or GCP give it short-lived credentials and no key is stored (#3238):
const sandbox = yield* sandboxes.create({ image: "amazon/aws-cli", includeOidcIdentityToken: true,});Boat keeps AI keys and API secrets in credential profiles, and its AI credentials proxy swaps them in for each request (#3231):
export const Keys = Boat.CredentialProfile("Keys", { anthropic: Config.Redacted("ANTHROPIC_API_KEY"),});export const Proxy = Boat.AiProxy("Proxy");export const Tenants = Boat.Environment("Tenants", { safeForThirdParties: true, credentialProfile: Keys, proxyAiCredentials: Proxy,});Docs: E2B · Daytona · Modal · Archil · Boat · AWS ECS.
Resource.lookup on every provider
Section titled “Resource.lookup on every provider”Resource.lookup binds a resource your stack doesn’t manage. It resolves at
plan time, binds and grants like a declared resource, and never writes state
or deletes anything. Every provider has it, on every resource another
resource can point at
(#2974,
#3002,
#3076,
#3094,
#3120,
#3124):
const uploads = AWS.S3.Bucket.lookup({ bucketName: "acme-uploads" });const vpc = AWS.EC2.Vpc.lookup({ isDefault: true });const zone = Cloudflare.Zone.lookup({ name: "example.com" });const orders = GCP.PubSub.Topic.lookup({ topicId: "orders" });const project = Neon.Project.lookup({ projectId: "plain-wave-12345678" });
// binds and grants like a declared bucketconst getObject = yield* AWS.S3.GetObject(uploads);A missing resource fails with LookupNotFoundError, and a lookup of one this
stack manages fails with LookupOfManagedResourceError.
RemovalPolicy.protect()
Section titled “RemovalPolicy.protect()”RemovalPolicy.protect() refuses any plan that would replace or delete a
resource, before anything is applied
(#3074):
const cluster = yield* AWS.EKS.Cluster("Cluster", props).pipe( RemovalPolicy.protect(stack.stage === "prod"),);ProtectedResource: Refusing to replace 'Cluster': protected by RemovalPolicy.protect().Deploy it without protect() first to replace or delete it deliberately.The policy is saved in state, so removing protect() in the same deploy as
the replacement is still refused. Thanks
Rahul Mishra!
Fly connects to AWS and GCP without keys
Section titled “Fly connects to AWS and GCP without keys”AWS and GCP bindings on a Fly Service exchange the Machine’s Fly OIDC token for credentials, instead of an access key or a service account key. The trust covers only that App (#3034, #3039). Your code doesn’t change:
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url, port: 3000 }, Effect.gen(function* () { const putObject = yield* AWS.S3.PutObject(yield* Uploads); // ... }).pipe(Effect.provide(AWS.S3.PutObjectHttp)),) {}AWS Batch jobs and EKS pods reach GCP through their AWS role
(#3040), and Fly Service
RPC authorizes callers by the App Fly signs into Fly-Src
(#3042).
Docs: Identity federation · Fly federated identity.
A layer cache for every image build
Section titled “A layer cache for every image build”Every resource that builds a container image takes the same opt-in layer cache, so a fresh CI runner resumes from the last unchanged step, earlier stages included (#3111):
export class Agent extends Cloudflare.Container<Agent>()("Agent", { context: `${import.meta.dirname}/context`, build: { cache: true }, // every stage's layers at <repository>:buildcache}) {}
yield* AWS.ECS.Service("Api", { context: "./api", build: { cache: true } });Docker.Image("App", { build: { context: "./app", cacheFrom: ["type=gha,scope=app"], cacheTo: ["type=gha,scope=app,mode=max"], cache: { from: ["type=gha,scope=app"], to: ["type=gha,scope=app,mode=max"], }, },});Image tags now follow what Docker receives, so a lockfile above the context
or a .dockerignored file no longer retags the image
(#3121). Thanks
Malcolm, who designed the cache
shape!
Real SQL clients at Construction phase
Section titled “Real SQL clients at Construction phase”SQL.* and Drizzle.* return the driver’s own client when the runtime is
constructed. Only the connection waits for a request, so every builder API
works, where the old proxyChain recording broke some of them
(#3209):
const db = yield* Drizzle.Postgres(hd.connectionString);// a real drizzle database: no connection yetconst children = db.$with("c").as(db.select().from(items));
return { fetch: Effect.gen(function* () { // running a query opens the pool, once per request return yield* HttpServerResponse.json(yield* db.select().from(items)); }),};The CLI’s output can be piped. --log-format json writes only JSON records
to stdout, and status lines go to stderr when output is piped
(#2874,
#3041):
alchemy dev --log-format json | jq 'select(.kind == "event") | .data.type'alchemy state read app/prod/output > out.json # progress on stderralchemy drift exits 2 when it finds drift it didn’t repair
(#2992):
alchemy drift --stage prod # 0 in sync · 1 couldn't check · 2 driftedAlso: the status line shows total deploy time
(#3057), terminals that
support program status show deploy progress
(#2887), nested stack
outputs print in full (#3037),
alchemy state takes --domain for a Cloudflare store on a custom domain
(#2966), and nuke scans
have their own 60 s timeout
(#2958). Thanks
Rahul Mishra and
gegi!
60 bug fixes
Section titled “60 bug fixes”Cloudflare Workers
Section titled “Cloudflare Workers”waitUntilwork keeps what the handler acquired and exports its telemetry, in every event, effect/rpc request and Durable Object (#2886, #2989). A streamed body keeps the event’s telemetry open until it ends (#1639).- Cron, email and queue handlers get the services init provides (#2972), and
alchemy devdelivers email to Effect Workers (#2989). AsyncLocalStoragestores stay visible underCloudflare.Telemetry()(#1627).- A storage call right after a Durable Object transaction waits for its commit (#3107).
- Workers that bind a Durable Object class added in the same deploy, or reference each other through a ref, deploy in order and plan again (#3113, #3115).
- A Container rebuilds when only its program changes, and image builds have no 3-minute deadline (#2971, #3151).
alchemy devno longer sendsCF-Worker, which Cloudflare-fronted sites answered with 403 (#2965).- A stopped local Worker proxy frees its port at once, so a stage restarted in the same process gets its port back instead of failing for 30 s (#3203).
- A second TXT, MX or A record at a name is created beside the first instead of adopting it (#3073). Hyperdrive and certificate packs stop reporting false drift (#2993, #3108).
DurableObjectState.rawno longer hangs the type checker with ambient workers types (#3123), andCloudflare.upgrade()types its requirements instead ofany(#3193).- A Worker with rolldown build plugins redeploys (#3176), a new Worker no longer fails with
WorkerNotFoundwhile it propagates (#3000), and aWebsite.Vitewhose old workspace is gone rebuilds instead of failing to plan (#3001). - A Container whose image was changed outside Alchemy is restored on the next deploy (#2982). Thanks Yannic Charlon!
- A Preview starts its Durable Objects’ containers (#3085) and applies
observability(#3244). - A Worker’s runtime bundle no longer pulls in the local runtime (#3240).
- A Durable Object workflow commits a run together with its first wake-up, so a Saga can’t stay
Runningforever (#3197). alchemy devskips restarts when a watch event leaves the build input unchanged (#3246), and re-applies D1 migrations when the simulator’s data is gone (#3211).- DNSSEC stops reporting drift when only
modifiedOnmoved (#3224).
Thanks Alex, Juliaan, Rahul Mishra, Matthew Aylward, Dallen Pyrah, Rook and apostoli!
- CloudFront website domains get an
AAAAalias next to theAalias (#3008). Thanks Altay! - EKS updates apply on every deploy, not just the first (#3077). Thanks Jacob Hands!
- A second private hosted zone with the same name no longer takes over the first (#3100). Thanks Krzysztof Lis!
- ECS blue/green services deploy with a new role and stabilize after a traffic shift (#3093). Thanks Dawson for the reports!
- Aurora migrations default to the Data API, and run on Floci in
alchemy dev(#3109). - A resource declared without props reaches its provider with
{}(#3084). - A destroy that meets an RDS, DocDB, Neptune, ElastiCache or Timestream instance already deleting waits for it instead of failing (#3216, #3234).
Everything else
Section titled “Everything else”- Migrations refuse a pending file that sorts before an applied one, and keep a renamed file applied (#2917).
- A failed
Drizzle.Schemadiff no longer hangs the plan (#2984), andDrizzle.Schematakes drizzle-kit’sbreakpoints(#2964). - A Hetzner replacement no longer re-adopts the resource it replaces (#3047).
- Fly multi-region scale-downs, App deletes and lease renewals no longer fail (#2957).
- Rivet Cloud sign-in works for an account in several organizations (#3258). Thanks Julian Archila!
Redactedoutputs stay opaque in their types (#2626), and Infisical applies personal overrides (#3066). Thanks Jason Kuhrt!- Dev servers require a per-process token, and Floci binds to loopback (#3027).
Telemetry.layerOtlpexports from every server and job runtime: Batch, App Runner, Lambda Web Functions, Fly, Hetzner, Railway, Docker, Prisma Compute and every sandbox (#3202, #3172).- A failed delete or drift repair prints its error on its own line (#3218), and a stack that fails to load auth providers says why (#3192). Thanks Dallen Pyrah!
Also in this release
Section titled “Also in this release”Cloudflare
-
Cloudflare.Telemetry()turns on Workers Logs (#2622). Thanks Jordan! -
A class-form Durable Object keeps a
classNamethat differs from its id (#1499). Thanks Alex! -
A Durable Object takes over another class’s namespace and objects with
renamedFrom(#3164):env: { Meter: Cloudflare.DurableObject("Meter", { renamedFrom: "Counter" }) }, -
Access takes passkeys as an identity provider (#3116).
-
Cloudflare.Workflows.withStepConfigsets an Activity’sstep.dotimeout and retries on the native engine (#3186). Thanks Alessandro Cheli!Activity.make({ name: "encode", success: Schema.String, execute }).pipe(Cloudflare.Workflows.withStepConfig({ timeout: "16 minutes" }),); -
Logpush.Jobtakes aRedacteddestinationConf(#2840). Thanks Henry Marks! -
API tokens can name the permission groups Cloudflare added since the last sync (#2927).
-
A host that embeds the local Workers runtime sees each workerd process’s lifecycle: its start with the PID, an unexpected exit, a scheduled restart, and why recovery stopped (#3203). The events are a Schema union, so they log as JSON, and
onEventreplacesonRestart. Thanks Umut Sirin for this and the port fix above!runtime.start({...worker,onEvent: (event) => {if (event.type === "workerd.process.started") watchMemory(event.pid);if (event.type === "workerd.recovery.stopped") saveState(event.reason);},});
Alchemy Domain
DomainCloudflare.layerDurableObjects({ locationHint: "wnam" })creates a model’s Durable Objects near a region (#3248).- A schedule’s
commandIdnames it, so a deadline can be armed again (#2806). Thanks Taras Kornichuk!
AWS
- Lambda Web Functions run in Floci under
alchemy dev, on Floci 2.4, which also emulates RDS Data, Redshift and CloudWatch Logs live tail (#3163). Every AWS test that Floci can run now passes on it (#3198).
Other providers
- A
Fly.Servicestores its boundConfigvalues as App secrets, so they no longer show in plain text in the Machine config (#2148). Thanks Jarred Norris! Planetscale.ServiceTokenmanages database-scoped tokens (#2771). Thanks Jason Kuhrt!GitHub.fromAppacts as a GitHub App installation (#3132).- A Prisma host can supply a token that refreshes during a deploy (#3101, #3122). Thanks Will Madden!
Docker.Networktakes the rest ofdocker network create:internal,attachable, IPv4/IPv6 toggles, driver options and IPAM (#3190). Thanks Yuzu!GCP.StorageInsights.ReportConfigschedules Cloud Storage inventory reports (#2949). Thanks Bartek!- Every distilled SDK is regenerated from the latest specs (#3201). Thanks Michael K!
Fly.Service,AWS.EC2.Instance,Hetzner.ServiceandGCP.Run.WorkerPoolbuild from a Dockerfilecontext(#3118).
Core
- The Workers runtime (Worker and Durable Object bridges, RPC, the workflow engine) lives in
alchemy/Workers, so any engine that runs Workers bundles reuses it (#2819). - Every provider’s docs were rewritten platform-first, with a What is page and about 150 diagrams that read on a phone and draw how their pieces connect (#2948, #3044, #3168).
See the CHANGELOG for every other change, and thanks to everyone who contributed.