Skip to content

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:

  1. 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.
  2. 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.
  3. 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.

#374

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:

src/task.ts
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:

src/worker.ts
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:

src/rpcs.ts
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,
}),
) {}
src/worker.ts
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.
  • WorkerProps.env flows into InferEnv — typing worker.env.MY_VAR now picks up keys you declared in env: { ... } on the Worker, not just bindings (#351).
  • WorkerExecutionContext + ExecutionContext in fetch types — the Worker fetch handler now exposes both the Effect-side ExecutionContext (with the request scope) and the raw workerd WorkerExecutionContext, so ctx.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 DurableObjectNamespace were running with an empty env context, breaking any binding the DO needed to call into. Thanks Dillon Mulroy (#369).
  • Redacted survives sidecar RPC serialization — sidecar was unwrapping Redacted values during JSON round-trips, leaking secrets in dev logs. They’re now preserved end to end. Thanks Juliaan (#356).
  • lightningcss + fsevents externalized 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, destroy would hang. It now force-deletes the script and proceeds (#348).
  • Eager terminal status events while waiting on deps — the CLI now shows pending for 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).

Big thank-you to everyone who shipped code in this beta: