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
Section titled “Cloudflare.RpcWorker”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)
Cloudflare.RpcDurableObjectNamespace
Section titled “Cloudflare.RpcDurableObjectNamespace”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)
Also in this release
Section titled “Also in this release”CloudflareBrowser Rendering binding — bind headless Chrome to a Worker. Thanks Alex (#372).- Cross-script Durable Object binding — the primitive under
RpcDurableObjectNamespace’sClass.from(Worker)(#435). Random.KeyPairresource — a managed public/private key pair (#441).timeoutonFunctionProps— set AWS Lambda timeout declaratively (#440).Cloudflare.QueueacceptsDuration.Inputfor time props onmessages()—"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.urlinfers 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.