1---
2title: Schemaless RPC
3description: Typed RPC between Workers, Durable Objects, and Containers with no schema — bind the class, get the client.
4sidebar:
5 order: 2
6---
7
8The schemaless RPC pattern — what makes a member callable, how the typed client arises, what crosses the wire — is documented at [Schemaless RPC](/apis/schemaless). This page walks Cloudflare's pairings. Worker → Worker and Worker → Durable Object ride the platform's native JSRPC channel (values move by structured clone, and service bindings never traverse the public internet); Containers ride the generic fetch transport.
9
10## Expose an interface
11
12```typescript
13import * as Cloudflare from "alchemy/Cloudflare";
14import * as Effect from "effect/Effect";
15import * as HttpServerResponse from "effect/http/HttpServerResponse";
16
17export default class BindingTargetWorker extends Cloudflare.Worker<BindingTargetWorker>()(
18 "BindingTargetWorker",
19 {
20 main: import.meta.url,
21 },
22 Effect.gen(function* () {
23 return {
24 greet: (name: string) => Effect.succeed(`hello ${name}`),
25 fetch: Effect.gen(function* () {
26 return HttpServerResponse.text("hello from BindingTargetWorker");
27 }),
28 };
29 }),
30) {}
31```
32
33Any non-`fetch` function member returning an Effect or a Stream becomes callable remotely, and the class instance type *is* the interface: there is no schema to declare. The full member rules live at [Schemaless RPC](/apis/schemaless).
34
35## Worker → Worker
36
37```typescript
38import * as Cloudflare from "alchemy/Cloudflare";
39import * as Effect from "effect/Effect";
40import { HttpServerRequest } from "effect/http/HttpServerRequest";
41import * as HttpServerResponse from "effect/http/HttpServerResponse";
42import BindingTargetWorker from "./binding-target-worker.ts";
43
44export default class BindingEffectCaller extends Cloudflare.Worker<BindingEffectCaller>()(
45 "BindingEffectCaller",
46 {
47 main: import.meta.url,
48 },
49 Effect.gen(function* () {
50 const target = yield* Cloudflare.Workers.bindWorker(BindingTargetWorker);
51
52 return {
53 fetch: Effect.gen(function* () {
54 const request = yield* HttpServerRequest;
55 const name =
56 new URL(request.url, "http://x").searchParams.get("name") ?? "world";
57 const greeting = yield* target.greet(name);
58 return HttpServerResponse.text(String(greeting));
59 }),
60 };
61 }),
62) {}
63```
64
65`bindWorker(BindingTargetWorker)` registers a service Binding at deploy time and returns a stub typed as the target's Shape — `target.greet(name)` is fully inferred from the target class, and every call rides native JSRPC. Service bindings in depth: [Workers](/cloudflare/compute/workers#call-another-worker).
66
67## Worker → Durable Object
68
69### The Durable Object's shape
70
71```typescript
72import * as Cloudflare from "alchemy/Cloudflare";
73import * as Effect from "effect/Effect";
74import * as Schedule from "effect/Schedule";
75import * as Stream from "effect/Stream";
76
77const KV = Cloudflare.KV.Namespace("DurableObjectWorkerEnvironmentKV", {
78 title: "durable-object-worker-environment-kv",
79});
80
81export class WorkerEnvironmentKVObject extends Cloudflare.DurableObject<WorkerEnvironmentKVObject>()(
82 "WorkerEnvironmentKVObject",
83 Effect.gen(function* () {
84 const kv = yield* Cloudflare.KV.ReadWriteNamespace(KV);
85
86 return Effect.gen(function* () {
87 return {
88 put: (key: string, value: string) => kv.put(key, value),
89 get: (key: string) => kv.get(key),
90 tick: (n: number) =>
91 Stream.iterate(0, (i) => i + 1).pipe(
92 Stream.take(n),
93 Stream.schedule(Schedule.spaced("100 millis")),
94 ),
95 };
96 });
97 }).pipe(Effect.provide(Cloudflare.KV.ReadWriteNamespaceBinding)),
98) {}
99```
100
101A DO shape can mix value methods (`put`/`get` return Effects) with streaming methods (`tick` returns a Stream), and its constructor Effect may close over other bindings — here the KV Namespace it reads and writes.
102
103### Value round-trip
104
105```typescript
106import * as Cloudflare from "alchemy/Cloudflare";
107import * as Effect from "effect/Effect";
108import * as HttpServerResponse from "effect/http/HttpServerResponse";
109import { WorkerEnvironmentKVObject } from "./object.ts";
110
111export default class KVWorker extends Cloudflare.Worker<KVWorker>()(
112 "KVWorker",
113 { main: import.meta.url },
114 Effect.gen(function* () {
115 const objects = yield* WorkerEnvironmentKVObject;
116
117 return {
118 fetch: Effect.gen(function* () {
119 const object = objects.getByName("default");
120 yield* object.put("greeting", "ok").pipe(Effect.orDie);
121 const value = yield* object.get("greeting").pipe(Effect.orDie);
122 return yield* HttpServerResponse.json({ value });
123 }),
124 };
125 }),
126) {}
127```
128
129Yielding the DO class binds the namespace to the Worker, `getByName("default")` returns a stub typed as the DO's Shape, and every property access on that stub is a remote call.
130
131### Stream straight onto the response
132
133```typescript
134// GET /tick/:n — inside the same fetch handler
135const stream = objects
136 .getByName("tick")
137 .tick(n)
138 .pipe(
139 Stream.map((i) => `${i}\n`),
140 Stream.encodeText,
141 );
142return HttpServerResponse.stream(stream, {
143 headers: { "content-type": "text/plain" },
144});
145```
146
147The stub call is both an Effect and a Stream — value methods `yield*`, streaming methods pipe through `Stream.*` combinators, and elements are pulled lazily under backpressure all the way to the HTTP response. Durable Object RPC in depth: [Durable Objects](/cloudflare/compute/durable-objects#schemaless-rpc).
148
149## Another script's Durable Object
150
151```typescript
152// src/workerB.ts
153import * as Cloudflare from "alchemy/Cloudflare";
154import * as Effect from "effect/Effect";
155import { HttpServerRequest } from "effect/http/HttpServerRequest";
156import * as HttpServerResponse from "effect/http/HttpServerResponse";
157import { Counter } from "./object.ts";
158import { WorkerA } from "./workerA.ts";
159
160export default class WorkerB extends Cloudflare.Worker<WorkerB>()(
161 "WorkerB",
162 { main: import.meta.url },
163 Effect.gen(function* () {
164 const counters = yield* Counter.from(WorkerA);
165 return {
166 fetch: Effect.gen(function* () {
167 const request = yield* HttpServerRequest;
168 const name = new URL(request.url, "http://x").pathname.slice(1);
169 const value = yield* counters.getByName(name).get();
170 return HttpServerResponse.json({ value });
171 }),
172 };
173 }),
174) {}
175```
176
177`Counter.from(WorkerA)` reads WorkerA's Deps to confirm `Counter` is part of its public contract, then emits a binding with `scriptName: "WorkerA"` — WorkerB never imports the DO implementation; only the host ships the class. Full walkthrough: [Cross-Worker Durable Objects](/cloudflare/compute/cross-worker-durable-object).
178
179## Containers
180
181### Declare the Shape
182
183```typescript
184import * as Cloudflare from "alchemy/Cloudflare";
185import type { RuntimeContext } from "alchemy/RuntimeContext";
186import * as Effect from "effect/Effect";
187import * as HttpServerResponse from "effect/http/HttpServerResponse";
188
189const Storage = Cloudflare.R2.Bucket("Storage");
190
191export class MyContainer extends Cloudflare.Container<
192 MyContainer,
193 {
194 ping: () => Effect.Effect<string>;
195 readObject: (
196 key: string,
197 ) => Effect.Effect<string | null, never, RuntimeContext>;
198 }
199>()("EffectfulContainer") {}
200
201export default MyContainer.make(
202 {
203 main: import.meta.url,
204 image: "oven/bun:latest",
205 },
206 Effect.gen(function* () {
207 const bucket = yield* Cloudflare.R2.ReadWriteBucket(Storage);
208
209 return {
210 ping: () => Effect.succeed("pong"),
211 readObject: (key: string) =>
212 bucket.get(key).pipe(
213 Effect.flatMap((object) =>
214 object ? object.text() : Effect.succeed(null),
215 ),
216 Effect.orDie,
217 ),
218 fetch: Effect.gen(function* () {
219 return HttpServerResponse.text("hello from effectful container");
220 }),
221 };
222 }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketHttp)),
223);
224```
225
226Containers are the one pairing where the Shape is written explicitly as a type parameter — there is no constructor Effect on the class itself to infer from; `MyContainer.make` supplies the implementation (which, like a Worker's, may close over other bindings such as the R2 Bucket here).
227
228### Host it in a Durable Object
229
230```typescript
231import * as Cloudflare from "alchemy/Cloudflare";
232import * as Effect from "effect/Effect";
233import { MyContainer } from "./container.ts";
234
235export class ContainerHost extends Cloudflare.DurableObject<ContainerHost>()(
236 "ContainerHost",
237 Effect.gen(function* () {
238 const container = yield* MyContainer;
239
240 return Effect.gen(function* () {
241 return {
242 ping: () => container.ping(),
243 // Read the object from inside the container over RPC.
244 readObjectRpc: (key: string) => container.readObject(key),
245 };
246 });
247 }).pipe(
248 Effect.provide(
249 Cloudflare.Containers.layer(MyContainer, { enableInternet: true }),
250 ),
251 ),
252) {}
253```
254
255Containers run inside Durable Objects, so the chain is Worker → DO → Container; the container leg rides the fetch transport (`POST /__rpc__/{name}`), and a forwarded value like `readObjectRpc` is itself an RPC call — nested forwarding just works. Running and connecting containers in depth: [Containers](/cloudflare/compute/containers).
256
257## Dynamically-loaded Workers
258
259```typescript
260// Construction phase — register the loader binding:
261const loader = yield* Cloudflare.WorkerLoader("LOADER");
262
263// inside the fetch handler — load a sandboxed Worker from inline source:
264const worker = yield* loader.load({
265 compatibilityDate: "2026-01-28",
266 mainModule: "worker.js",
267 modules: { "worker.js": source },
268});
269const api = worker.getEntrypoint<{
270 greet: (name: string) => Effect.Effect<string>;
271}>("api");
272const greeting = yield* api.greet("world");
273```
274
275`getEntrypoint` wraps the loader entrypoint in the same stub proxy — a Worker loaded at runtime speaks schemaless RPC exactly like a statically bound one; here the caller supplies the Shape as a type argument, since there is no class to infer it from. See the [Worker Loader guide](/cloudflare/compute/worker-loader) and the [WorkerLoader API reference](/providers/cloudflare/workers/workerloader).
276
277## Async (non-Effect) consumers
278
279A plain async Worker calls a service binding directly:
280
281```typescript
282// inside a plain async fetch(request, env):
283const greeting = await env.TARGET.greet(name);
284```
285
286Or wrap the binding with `toRpcAsync` to auto-decode the wire
287envelopes:
288
289```typescript
290import * as Cloudflare from "alchemy/Cloudflare";
291import type Backend from "../backend.ts";
292
293const backend = Cloudflare.toRpcAsync<Backend>(env.BACKEND);
294const value = await backend.hello(key);
295```
296
297Error envelopes are thrown so `await` rejects, stream envelopes unwrap to their raw `ReadableStream<Uint8Array>` body, and `Service.fetch`/`connect` pass through unchanged. See [Vite SPA](/cloudflare/frontend/vite-spa#wrap-the-binding-for-typed-rpc).
298
299## When you need schemas
300
301Schemaless RPC has zero runtime validation by design — nothing decodes or sanitizes what arrives. At a trust boundary (external clients, versioned APIs), use schema-validated [Effect RPC](/apis/effect-rpc) instead.
302
303## Where next
304
305- [Schemaless RPC](/apis/schemaless) — the pattern: interface rules, transports, streams, errors, limits
306- [Durable Objects](/cloudflare/compute/durable-objects#schemaless-rpc) — DO stubs, streaming methods, and state
307- [Cross-Worker Durable Objects](/cloudflare/compute/cross-worker-durable-object) — bind another script's DO with `Counter.from`
308- [Containers](/cloudflare/compute/containers) — build, host, and call Containers from Durable Objects
309- [Vite SPA](/cloudflare/frontend/vite-spa#wrap-the-binding-for-typed-rpc) — consume an Effect Worker from async server routes
310- [Effect RPC](/apis/effect-rpc) — schema-first RPC for trust boundaries