Skip to content

2.0.0-beta.45 - Config & RPC Workers

v2.0.0-beta.45 has three things worth calling out: config binding now rides on effect/Config, and two new Worker abstractions — Cloudflare.RpcWorker and Cloudflare.RpcDurableObjectNamespace — collapse the Effect RPC boilerplate down to “hand me an Effect RPC schema.”

effect/Config replaces Alchemy.Secret / Alchemy.Variable

Section titled “effect/Config replaces Alchemy.Secret / Alchemy.Variable”

Alchemy.Secret and Alchemy.Variable forced an accessor pattern: you yielded the secret in Init to get a handle, then yielded the handle again at runtime to read it. You couldn’t use the value during Init, so initializing a client meant threading Outputs and accessors around for no reason.

beta.45 drops both helpers and leans on effect/Config. A Config value yield*ed in a Worker’s Init phase is captured and bound to the deploy target automatically — and the same value is usable immediately, right there in Init.

export default Cloudflare.Worker(
"Worker",
{ main: import.meta.filename },
Effect.gen(function* () {
const API_KEY = yield* Alchemy.Secret("API_KEY");
const apiKey = yield* Config.redacted("API_KEY");
return {
fetch: Effect.gen(function* () {
const apiKey = yield* API_KEY;
// ...
}),
};
}),
);

Under the hood a custom ConfigProvider intercepts Config evaluation inside a Platform context and records the binding. Combinators (withDefault, orElse, mapAttempt, …) work as usual — what gets bound is the source env var, not the transformed result, so the combinators re-run at runtime against the bound source.

One footgun to internalize: a Config only yield*ed inside fetch is never bound, because fetch doesn’t run during deploy. Always resolve it in the outer Init Effect.gen and capture it in a const. (#445)

Relatedly, WorkerProps.bindings collapses into WorkerProps.env — there’s now one place to declare a Worker’s environment, whether the entries are resources, plain values, or Config references.

export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
bindings: { Bucket, KV },
env: { Bucket, KV },
});

For async (non-Effect) Workers that can’t yield* Config, this is also where you bind secrets:

export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: {
API_KEY: Config.redacted("API_KEY"),
HOST: Config.string("HOST"),
Bucket,
},
});

(#446)

Cloudflare.RpcWorker is a Worker whose entire fetch surface is a typed Effect RPC schema. You pass the schema directly in props.schema, and the init returns the piped RpcServer.toHttpEffect(schema) Effect — it wraps the { fetch } shape and transport plumbing for you.

Until now you wrote that plumbing by hand: build the handler Layer, pipe it into RpcServer.toHttpEffect, return { fetch } — the long-form recipe in the Effect RPC guide, nearly identical for every RPC Worker. RpcWorker collapses it.

export default class Worker extends Cloudflare.RpcWorker<Worker>()(
"Worker",
{
main: import.meta.filename,
// the served Effect RPC schema — this is what callers bind
// against and get a typed client for
schema: TaskRpcs,
},
Effect.gen(function* () {
const handlers = TaskRpcs.toLayer({
getTask: ({ id }) => Effect.succeed(`task-${id}`),
});
return RpcServer.toHttpEffect(TaskRpcs).pipe(
Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerJson)),
);
}),
) {}

Here’s the payoff. Any other Worker in the same account binds it with Cloudflare.RpcWorker.bind(TaskWorker) and gets back a fully typed RPC client — every procedure in the schema is a method you can just call, request and response typed end to end:

export default class Caller extends Cloudflare.Worker<Caller>()(
"Caller",
{ main: import.meta.filename },
Effect.gen(function* () {
// tasks is an RpcClient<TaskRpcs> — every Rpc is a method
const tasks = yield* Cloudflare.RpcWorker.bind(TaskWorker);
return {
fetch: Effect.gen(function* () {
// just call it — typed payload, typed result, typed errors
const task = yield* tasks.getTask({ id: "abc" });
return HttpServerResponse.text(task);
}),
};
}),
) {}

The schema is recovered from the TaskWorker class itself (via an internal stash), so binding is a single line — it mirrors R2Bucket.bind, and the call goes over the in-account service binding rather than the public network.

The full walkthrough — schema, handlers, deploy, integration test, cross-Worker binding, and the modular Class.make(impl) form — lives in the new RpcWorker tutorial. (#387)

Alchemy’s built-in DO method bridge JSON.stringifys every return value, which strips class identity — fine for primitives, a problem when a value contains Schema.Class instances. Cloudflare.RpcDurableObjectNamespace runs an Effect RPC server on the DO’s fetch so both ends share one RpcSerialization codec, preserving class identity (and supporting streaming).

export default class Counter extends Cloudflare.RpcDurableObjectNamespace<Counter>()(
"Counter",
{ schema: CounterRpcs },
Effect.gen(function* () {
return Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
const handlers = CounterRpcs.toLayer({
setTitle: ({ title }) => state.storage.put("title", title),
getTitle: () =>
Effect.map(state.storage.get<string>("title"), (t) => t ?? ""),
});
return RpcServer.toHttpEffect(CounterRpcs).pipe(
Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)),
);
});
}),
) {}

A Worker reaches the DO by yielding the class to register the binding, then getByName(id) hands back a typed client for that instance — same RpcClient shape as RpcWorker.bind, every procedure a callable method:

export default class Worker extends Cloudflare.Worker<Worker>()(
"Worker",
{ main: import.meta.filename },
Effect.gen(function* () {
const counters = yield* Counter;
return {
fetch: Effect.gen(function* () {
// typed client for this DO instance
const counter = yield* counters.getByName("default");
yield* counter.setTitle({ title: "Hello" });
const title = yield* counter.getTitle({});
return HttpServerResponse.text(title);
}).pipe(Effect.scoped),
};
}),
) {}

getByName(id) returns an Effect<RpcClient> — yield it inside a per-request Effect.scoped handler and the client is freed with the request.

Both RpcWorker and RpcDurableObjectNamespace ship a matching modular form(name, { schema }) with no impl declares a pure tagged identifier, Class.make(impl) returns the runtime Layer, and Class.from(Worker) binds cross-script. That makes a fully typed RpcWorker → RpcDurableObjectNamespace binding work across scripts without dragging the DO runtime into the caller’s bundle.

The RpcDurableObjectNamespace tutorial covers the single-DO flow, the modular split, and cross-script binding. (#388)

  • Cloudflare Browser Rendering binding — bind headless Chrome to a Worker. Thanks Alex (#372).
  • Cross-script Durable Object binding — the primitive under RpcDurableObjectNamespace’s Class.from(Worker) (#435).
  • Random.KeyPair resource — a managed public/private key pair (#441).
  • timeout on FunctionProps — set AWS Lambda timeout declaratively (#440).
  • Cloudflare.Queue accepts Duration.Input for time props on messages()"30 seconds" instead of raw seconds (#443).
  • Workflows, AnalyticsEngine + SendEmail, and custom ports in alchemy dev — more bindings light up locally. Thanks John Royal (#449, #460, #469).
  • Worker.url infers the canonical URL and preserves domain order. Thanks Michael K (#432).

A batch of dev-server and bundler fixes also landed — reconciling the Worker preview subdomain, websocket upgrades in vite dev, external require calls, and tighter Cloudflare.providers() types. See the changelog for the full list.