alchemy@2.0.0-beta.41
v2.0.0-beta.41 is mostly the bridge rewrite. The Worker
entrypoint is now a thin generated shell that calls into a
type-checked WorkerBridge.ts; scope construction is linear,
per-request, and no longer mixes promises across the workerd I/O
boundary. The same fix lands across Cloudflare.Worker,
Cloudflare.DurableObjectNamespace, and Cloudflare.Workflow,
so the schema-driven pattern in the HTTP API
and RPC guides now holds together under
real traffic.
RPC and HTTP API — per-request scopes, no more cross-context promises
Section titled “RPC and HTTP API — per-request scopes, no more cross-context promises”The Worker runtime had been caching the user’s Worker Effect once
as a module-scope Promise, then mixing and matching scopes
between WorkerBundle, WorkerRuntime, and HttpServer on each
request. Three things went wrong in production:
- Too many scopes. Every layer in the stack created its own
Scope, and the close order between them wasn’t well-defined. Finalizers ran against scopes that had already been torn down, or never ran at all. - Promises crossed the workerd I/O boundary. A single cached
promise was awaited from multiple requests. workerd treats
that as “promise resolved on the wrong I/O context” and aborts
the request — manifesting as hung
fetches and dropped responses. - Global vs request-level scope confusion. Construction-time
layers (provided once) and per-request layers (provided per
fetch) were being merged in the wrong order, so request-level services occasionally got resolved from the global scope and then captured stale state from a previous request.
beta.41 refactors the entrypoint into
WorkerBridge.ts.
The generated entrypoint is now thin — it just instantiates the
bridge class. Everything else is type-checked TypeScript in the
package. Scoping is linear: one Scope.makeUnsafe() per request,
provided into the user’s Effect, and closed via ctx.waitUntil
when the response finishes streaming. Nothing is cached as a
promise; the user’s Worker Effect is re-evaluated per request
against the request’s own scope.
Performance note: the rewrite trades a small amount of per-request setup for correctness. The previous “cache the Worker once” path was the source of the bugs, so we’ve left it off by default. There’s a follow-up to identify what’s actually safe to cache (anything that doesn’t capture a promise) — for now, correctness first.
The schema-driven pattern, end to end
Section titled “The schema-driven pattern, end to end”The pattern the HTTP API guide and
RPC guide describe — define a schema, build
a server, wrap it as { fetch }, and optionally proxy through a
Durable Object — is the same shape regardless of which transport
you pick. beta.41 is the release where it actually works for all
three combinations.
1. Define the schema once. Same Task schema feeds the HTTP
API endpoint declarations and the RPC procedure declarations:
import * as Schema from "effect/Schema";
export class Task extends Schema.Class<Task>("Task")({ id: Schema.String, title: Schema.String, completed: Schema.Boolean,}) {}
export class TaskNotFound extends Schema.TaggedClass<TaskNotFound>()( "TaskNotFound", { id: Schema.String },) {}2a. HTTP API — declare endpoints, return { fetch }.
HttpApiBuilder.layer(TaskApi).pipe(..., HttpRouter.toHttpEffect)
is exactly the HttpEffect Workers expect:
export default Cloudflare.Worker( "Worker", { main: import.meta.path }, Effect.gen(function* () { const tasks = yield* Cloudflare.R2Bucket.bind(Bucket);
const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (h) => h.handle("getTask", ({ params }) => tasks.get(params.id).pipe( Effect.flatMap((o) => o ? decodeTask(o) : Effect.fail(new TaskNotFound({ id: params.id })), ), ), ), );
return { fetch: HttpApiBuilder.layer(TaskApi).pipe( Layer.provide(tasksGroup), HttpRouter.toHttpEffect, ), }; }).pipe(Effect.provide(Cloudflare.R2BucketBindingLive)),);2b. RPC — declare procedures, return { fetch }. Same
schemas, same Worker shape, different transport. The RPC server
also produces an HttpEffect:
export class TaskRpcs extends RpcGroup.make( Rpc.make("getTask", { payload: { id: Schema.String }, success: Task, error: TaskNotFound, }), Rpc.make("createTask", { payload: { title: Schema.String }, success: Task, }),) {}export default Cloudflare.Worker( "Worker", { main: import.meta.path }, Effect.gen(function* () { const tasks = yield* Cloudflare.R2Bucket.bind(Bucket);
const handlersLayer = TaskRpcs.toLayer({ getTask: ({ id }) => /* ... */, createTask: ({ title }) => /* ... */, });
return { fetch: RpcServer.toHttpEffect(TaskRpcs).pipe( Effect.provide(handlersLayer), Effect.provide(RpcSerialization.layerJson), ), }; }).pipe(Effect.provide(Cloudflare.R2BucketBindingLive)),);3. Bind a Durable Object, get a typed fetcher per instance.
The DO’s Init returns the same { fetch } shape — an
HttpEffect from either HttpApiBuilder or
RpcServer.toHttpEffect. Inside the Worker, bind the DO and use
Cloudflare.toHttpClient(stub) to turn each instance into a
typed client:
const tasksDO = yield* TasksObject;
const getDOClient = (id: string = "default") => HttpApiClient.makeWith(TaskDOApi, { baseUrl: "http://localhost", httpClient: Cloudflare.toHttpClient(tasksDO.getByName(id)), });The baseUrl is a placeholder — toHttpClient short-circuits
straight to tasksDO.getByName(id).fetch, so the call never
leaves the isolate. The same wrapper plugs into
RpcClient.layerProtocolHttp if the DO speaks RPC instead:
const makeDOClient = (id: string = "default") => RpcClient.make(DoRpcs).pipe( Effect.provide( RpcClient.layerProtocolHttp({ url: "http://localhost" }).pipe( Layer.provide( Layer.succeed( HttpClient.HttpClient, Cloudflare.toHttpClient(tasksDO.getByName(id)), ), ), Layer.provide(RpcSerialization.layerNdjson), ), ), );One Task schema. Worker exposes HTTP API or RPC. DO speaks the
same protocol back to the Worker. Per-DO-instance fetchers via
getByName(id). With beta.41’s per-request scoping, the whole
chain runs without scope leaks or cross-context promise errors.
The guides walk through the full thing:
- Effect HTTP API — schema → endpoints → Worker → typed client, plus the DO-backed variant.
- Effect RPC — schema → procedures → Worker → typed client, plus streaming and DO-backed RPCs.
Other fixes
Section titled “Other fixes”WorkerProps.envflows intoInferEnv— typingworker.env.MY_VARnow picks up keys you declared inenv: { ... }on the Worker, not just bindings (#351).WorkerExecutionContext+ExecutionContextin fetch types — the Worker fetch handler now exposes both the Effect-sideExecutionContext(with the request scope) and the raw workerdWorkerExecutionContext, soctx.waitUntil(...)and friends are reachable from inside the handler without an extra cast. Thanks Michael K (#358).- DO methods receive the Worker env — previously, RPC methods
on a
DurableObjectNamespacewere running with an emptyenvcontext, breaking any binding the DO needed to call into. Thanks Dillon Mulroy (#369). Redactedsurvives sidecar RPC serialization — sidecar was unwrappingRedactedvalues during JSON round-trips, leaking secrets in dev logs. They’re now preserved end to end. Thanks Juliaan (#356).lightningcss+fseventsexternalized in the Worker bundler — both are platform-specific native deps that don’t belong in the Worker bundle. Thanks Michael K (#363).- Worker teardown force-deletes scripts — if a Worker had
stuck Durable Object migrations from a previous failed deploy,
destroywould hang. It now force-deletes the script and proceeds (#348). - Eager terminal status events while waiting on deps — the
CLI now shows
pendingfor resources blocked on dependencies and emits terminal status (created/updated/failed) as soon as the resource’s own reconcile finishes, rather than batching at the end. Thanks Michael K (#376).
Contributors
Section titled “Contributors”Big thank-you to everyone who shipped code in this beta:
- Michael K — Worker fetch type fix (#358),
lightningcss/fseventsexternalization (#363), eager terminal status events (#376) - Dillon Mulroy — DO methods receive Worker env (#369)
- Juliaan —
Redactedacross sidecar RPC (#356)