The HTTP API guide showed how to build
REST-style endpoints with schema validation. Effect RPC takes a
different angle — you define procedures instead of HTTP
endpoints, and you get a fully typed client for free with no URL
construction or manual serialization.
The transport is still HTTP under the hood, and both patterns
produce the same HttpEffect type, so the wiring story is
identical to the HTTP API guide:
Define schemas outside. Domain types and tagged errors,
importable by both server and client.
Construct the service inside the Worker’s Init phase.RpcGroup.toLayer is pure construction — safe to call at plan
time. Don’t yield* the running server; it can’t run without a
request.
Return { fetch } where fetch is the HttpEffect
produced by RpcServer.toHttpEffect. Or use
Cloudflare.RpcWorker and skip the { fetch } wrapper.
Bonus: deploy and call the procedures from a typed client
that shares the exact same RpcGroup value.
For Durable Objects, Cloudflare.RpcDurableObjectNamespace
serves the rpc group on the DO’s fetch and exposes
namespace.getByName(id) as a typed RpcClient — see
Durable Object RPC with RpcDurableObjectNamespace.
Each Rpc.make declares one procedure: a name, a payload schema,
a success schema, and an error schema. RpcGroup.make collects them
into a single value that both the server and the client will share.
The generator inside Cloudflare.Worker is the Init phase — it
runs both at plan time and at runtime. Only do pure construction
or resource-binding factories here; never yield* work that needs
an incoming request.
Tasks need durable storage. Declare an R2Bucket resource and bind
it inside Init — bind() returns a typed handle whose get /
put / delete / list methods we’ll call from the handlers
below.
src/bucket.ts
import*asCloudflarefrom"alchemy/Cloudflare";
exportconstTasks=Cloudflare.R2Bucket("Tasks");
import {
import Tasks
Tasks } from"./bucket.ts";
exportdefault
any
Cloudflare.
any
Worker(
"Worker",
{
main: string
main:import.
The type of import.meta.
If you need to declare that a given property exists on import.meta,
this type may be augmented via interface merging.
meta.
ImportMeta.path: string
Absolute path to the source file
path },
any
Effect.
any
gen(function* () {
const
consttasks: any
tasks=yield*
any
Cloudflare.
any
R2Bucket.
any
bind(
import Tasks
Tasks);
return {};
}),
);
We’ll provide the runtime side of this binding
(Cloudflare.R2BucketBindingLive) in step 3c when we wire up the
fetch handler.
TaskRpcs.toLayer takes an Effect that returns one handler per
procedure and produces a Layer. Like HttpApiBuilder.group, this
is pure construction — it builds a value, it doesn’t run the server.
Don’t yield* TaskRpcs.toLayer(...) here. Building a layer is
fine, but actually executing the procedures requires an incoming
request. Init only constructs; the work happens later, on each
fetch call.
any
Effect.
any
gen(function* () {
const
consttasks: any
tasks=yield*
any
Cloudflare.
any
R2Bucket.
any
bind(
any
Tasks);
const
consthandlersLayer: any
handlersLayer=
any
TaskRpcs.
any
toLayer({
getTask: ({ id }: {
id: any;
}) => any
getTask: ({
id: any
id }) =>
any
Effect.
any
gen(function* () {
const
constobject: any
object=yield*
consttasks: any
tasks.
any
get(
id: any
id);
if (!
constobject: any
object) {
returnyield*
any
Effect.
any
fail(new
any
TaskNotFound({
id: any
id }));
}
return
any
Schema.
any
decodeUnknownSync(
any
Task)(
var JSON: JSON
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
Converts a JavaScript Object Notation (JSON) string into an object.
@param ― text A valid JSON string.
@param ― reviver A function that transforms the results. This function is called for each member of the object.
If a member contains nested objects, the nested objects are transformed before the parent object is.
@throws ― {SyntaxError} If text is not valid JSON.
Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
@param ― value A JavaScript value, usually an object or array, to be converted.
@param ― replacer A function that transforms the results.
@param ― space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
@throws ― {TypeError} If a circular reference or a BigInt value is found.
stringify(
consttask: any
task));
return
consttask: any
task;
}).
any
pipe(
any
Effect.
any
catchTag("R2Error", (
error: any
error) =>
any
Effect.
any
fail(new
any
CreateTaskFailed({
message: any
message:
error: any
error.
any
message })),
),
),
});
return {};
}),
Each handler receives the typed payload and returns an Effect
that either succeeds with the declared success schema or fails with
the declared error schema. getTask uses Effect.orDie to turn
unexpected R2 failures into 500s — TaskNotFound is the only
client-visible error. createTask maps R2 failures into the
declared CreateTaskFailed error so the client can match on it.
The Cloudflare.Worker + { fetch: RpcServer.toHttpEffect(...) }
pattern shows up every time you build a worker whose entire surface
is an RpcGroup. Cloudflare.RpcWorker is the same construct with
two small affordances:
The schema lives in props next to main, declaring the rpc
group on the worker resource itself.
The init Effect returns the RpcServer.toHttpEffect(...)-piped
Effect directly — no { fetch } wrapper. The wrapper plugs it
into the worker’s fetch for you.
Two changes from the plain Cloudflare.Worker form:
Cloudflare.RpcWorker<Worker>()(...) instead of
Cloudflare.Worker<Worker>()(...) — same class X extends ...
ergonomics; consumers yield* Worker to get the deploy handle.
The init Effect returns the rpc server’s Effect directly. Compare:
Because TaskRpcs is just a value, the same group drives a fully
typed client — no codegen. client.createTask accepts
{ title: string } and returns Effect<Task, CreateTaskFailed>.
The errors are typed values: client.getTask returns
Effect<Task, TaskNotFound>, and you can Effect.catchTag( "TaskNotFound", ...) to handle the missing case explicitly.
Cloudflare.RpcDurableObjectNamespace is the same pattern as
RpcWorker, applied to a Durable Object. The DO serves the rpc
server on its own fetch handler, and consumers see
namespace.getByName(id) as a typed RpcClient directly — no
manual client wiring.
This is preferable to alchemy’s built-in DO method bridge whenever
your DO’s surface is naturally an rpc group, because both sides go
through the same Schema codec end-to-end. The built-in bridge
JSON.stringifys every method return value, which strips
Schema.Class identity (e.g. an effect/aiResponse.Usage
instance becomes a plain struct). With RpcDurableObjectNamespace,
the wire format is the rpc serialization (NDJSON by default), and
Schema.decode reconstructs class instances on the consumer side.
The DO needs its own rpc group — the same shape as the outer one
minus per-session identifiers (the DO instance is the session,
so an id field would be redundant).
The outer Effect runs once per DO class definition. The inner
Effect runs once per DO instance — that’s where you build the
handlers layer and return the rpc server’s HttpEffect-producing
Effect.
The RpcSerialization layer must be layerNdjson if any rpc in the
group is a streaming rpc (Rpc.make("...", { stream: true, ... }))
— streaming rpcs need newline framing on the wire. For non-streaming
groups, layerJson works too.
yield* Counter resolves to a value with getByName(id) typed as
RpcClient<CounterRpcs>. Each rpc method is a typed Effect/Stream
factory — exactly the same client you’d get from RpcClient.make,
but built for you and routed through the DO stub’s fetch.
Note counters.getByName("global").setTitle({ title }) — no
yield*, no client construction. The namespace already exposes the
rpc client. The same rule applies for streaming rpcs: the result
of getByName(id).someStreamRpc(...) is a Stream directly.
If you’re migrating from alchemy’s classic DO methods (where the DO
returns a handler object and consumers call do.getByName(id).foo(...)
to get Effect-wrapped method invocations), the call site doesn’t
change shape — only the contract of what crosses the wire:
Built-in DO bridge
RpcDurableObjectNamespace
Wire format
JSON.stringify per method
RpcSerialization codec
Class identity
Lost — Response.Usage → plain struct
Preserved via Schema.decode
Streaming returns
Encoded as RpcStreamEnvelope (JSONL bytes)
Native RpcSchema.Stream
Typed errors
Encoded as RpcErrorEnvelope
Native rpc error schema
Use RpcDurableObjectNamespace whenever any of the values flowing
across the DO boundary contain Schema.Class instances (most
effect/ai, effect/unstable/ai, or custom schemas with
Schema.Class<X>(...)).
Schemas (Task, TaskNotFound, CreateTaskFailed) and the
RPC group (TaskRpcs) live outside the Worker — pure
descriptions, importable by clients.
The handlers are constructed inside the Worker’s Init phase
closure via TaskRpcs.toLayer. We build a Layer but never
yield* the running server.
The Worker’s surface is { fetch }, where fetch is the
HttpEffect produced by RpcServer.toHttpEffect. The
Cloudflare.RpcWorker wrapper drops the { fetch } boilerplate
by accepting that Effect as the init return value directly.
For a Durable Object surface, Cloudflare.RpcDurableObjectNamespace
serves the rpc group over the DO’s fetch and exposes
namespace.getByName(id) as a typed RpcClient — preserving
Schema.Class identity across the DO hop, unlike the built-in
DO bridge.
The same TaskRpcs value drives a fully typed client via
RpcClient.make, with errors as typed values rather than HTTP
status codes.