Effect RPC exposes a typed, schema-validated surface across a
trust boundary — a web app or an external service calling into
your Worker. For internal Worker-to-Worker or Worker-to-DO calls,
use Schemaless RPC instead; for choosing between
the modalities, see RPC.
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.
Bonus: deploy and call the procedures from a typed client
that shares the exact same RpcGroup value.
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 Bucket 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.R2.Bucket("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.url: string
file:// url string for the current module.
@example
console.log(import.meta.url);
"file:///Users/me/projects/my-app/src/my-app.ts"
url },
any
Effect.
any
gen(function* () {
const
consttasks: any
tasks=yield*
any
Cloudflare.
any
R2.
any
ReadWriteBucket(
import Tasks
Tasks);
return {};
}),
);
We’ll provide the runtime side of this binding
(Cloudflare.R2.ReadWriteBucketBinding) 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
R2.
any
ReadWriteBucket(
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.
RpcServer.toHttpEffect converts the RpcGroup into an
HttpEffect — exactly the type Workers expect for fetch. We
provide two layers to it via Layer.mergeAll:
The handlersLayer we just built.
RpcSerialization.layerNdjson — one frame per line, so streaming
procedures work out of the box and the wire format matches what
Cloudflare.RpcWorker.bind clients speak. When nothing streams
and no bound consumer is involved, the buffered
RpcSerialization.layerJson is also an option.
Unlike the HTTP API approach, RPC doesn’t need HttpPlatform.layer
or Etag.layer — the RPC server handles message framing internally.
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.
An RPC’s success doesn’t have to be a single value. Wrapping it in
RpcSchema.Stream(item, error) produces a procedure whose handler
returns a Stream and whose client method also returns a Stream.
The wire format is one frame per item — exactly what the
RpcSerialization.layerNdjson layer we’re already on emits, so the
fetch wiring doesn’t change.
Stream.fromIterable(Array.from({ length: upto }, (_, i) => i +1)),
});
On the client, client.countTasks({ upto: 5 }) is a Stream<number>
you consume with Stream.runCollect, Stream.runForEach, etc. Each
emitted item arrives as soon as the server flushes its frame.
The Cloudflare.Worker(...) + RpcServer.toHttpEffect(...) recipe is
identical for every RPC Worker, so Alchemy ships a thin wrapper that
takes the RpcGroup directly in props and removes the { fetch }
wrapper:
Functionally identical to the long form above — yielding the class
returns the same Worker resource — but props.schema lets a second
Worker bind a typed client without re-importing RpcClient:
// INIT: register the binding, get the typed client
The bind goes over the in-account service binding (not the public
internet) and the client is typed by TaskWorker’s declared schema.
A Proxy defers each call’s underlying RpcClient construction so
Cloudflare’s “no cross-request I/O” rule is satisfied transparently.
For internal Worker-to-Worker calls that don’t need a schema at all,
see Schemaless RPC on Workers.
RpcWorker also supports a modular form that separates the
class declaration from its runtime — useful when a consumer Worker
should be able to import the class for binding without pulling in
the host’s runtime:
// Modular: class declaration carries no impl — just the schema.
The class can also declare DOs it publishes via the second type
argument — RpcWorker<Self, Deps>() mirrors
Cloudflare.Worker<Self, Bindings, Deps> so cross-script
Counter.from(TaskWorker) type-checks.
The same shape applies to Durable Objects.
Cloudflare.RpcDurableObject<Self>()(...) mirrors the
regular DO class but the inner Effect returns the piped
RpcServer.toHttpEffect(schema) Effect directly:
counters.getByName(id) returns an Effect<RpcClient<CounterRpcs>>
(yield it inside a per-request scope) rather than alchemy’s built-in
DO method bridge. Reach for this when an external client connects to
the DO and speaks RPC over a WebSocket connection, or whenever DO
method return values cross a Schema.Class boundary — the built-in bridge
JSON.stringifys each value and loses class identity, while the RPC
namespace round-trips through the shared RpcSerialization codec.
The concept home for this trade-off is
Effect RPC.
Like RpcWorker, the RPC DO supports a modular form that
separates the class from its runtime so consumer Workers can bind
to it cross-script:
// counter.ts — class declaration carries no impl.
RpcDurableObject.getByName(id) above returns a wired RpcClient
directly — you rarely need more. This section shows what it does
internally, and doubles as the escape hatch when the RpcServer
runs inside a plain Cloudflare.DurableObject and you build the
bridge yourself: Cloudflare.toHttpClient(stub) turns the DO stub
into an HttpClient that plugs into RpcClient.layerProtocolHttp.
Cloudflare.toHttpClient(stub) produces an HttpClient whose
execute calls into the DO’s fetch. Plug it into
RpcClient.layerProtocolHttp to get a typed RpcClient<InnerRpcs>
— the group the DO actually serves — then the *DO handlers just
forward the typed call. Here’s the
whole Worker — the R2-backed handlers are unchanged, the new
makeDOClient and getTaskDO / createTaskDO proxies are added:
For streaming RPCs the same proxy pattern works with Stream.unwrap —
declare a streaming countTasksDO in DoRpcs (same shape as
countTasks, with stream: true) and forward: