| 1 | --- |
| 2 | title: Schemaless RPC |
| 3 | description: Typed RPC between Workers, Durable Objects, and Containers with no schema — bind the class, get the client. |
| 4 | sidebar: |
| 5 | order: 2 |
| 6 | --- |
| 7 | |
| 8 | The 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 |
| 13 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 16 | |
| 17 | export 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 | |
| 33 | Any 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 |
| 38 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 39 | import * as Effect from "effect/Effect"; |
| 40 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 41 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 42 | import BindingTargetWorker from "./binding-target-worker.ts"; |
| 43 | |
| 44 | export 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 |
| 72 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 73 | import * as Effect from "effect/Effect"; |
| 74 | import * as Schedule from "effect/Schedule"; |
| 75 | import * as Stream from "effect/Stream"; |
| 76 | |
| 77 | const KV = Cloudflare.KV.Namespace("DurableObjectWorkerEnvironmentKV", { |
| 78 | title: "durable-object-worker-environment-kv", |
| 79 | }); |
| 80 | |
| 81 | export 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 | |
| 101 | A 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 |
| 106 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 107 | import * as Effect from "effect/Effect"; |
| 108 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 109 | import { WorkerEnvironmentKVObject } from "./object.ts"; |
| 110 | |
| 111 | export 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 | |
| 129 | Yielding 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 |
| 135 | const stream = objects |
| 136 | .getByName("tick") |
| 137 | .tick(n) |
| 138 | .pipe( |
| 139 | Stream.map((i) => `${i}\n`), |
| 140 | Stream.encodeText, |
| 141 | ); |
| 142 | return HttpServerResponse.stream(stream, { |
| 143 | headers: { "content-type": "text/plain" }, |
| 144 | }); |
| 145 | ``` |
| 146 | |
| 147 | The 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 |
| 153 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 154 | import * as Effect from "effect/Effect"; |
| 155 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 156 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 157 | import { Counter } from "./object.ts"; |
| 158 | import { WorkerA } from "./workerA.ts"; |
| 159 | |
| 160 | export 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 |
| 184 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 185 | import type { RuntimeContext } from "alchemy/RuntimeContext"; |
| 186 | import * as Effect from "effect/Effect"; |
| 187 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 188 | |
| 189 | const Storage = Cloudflare.R2.Bucket("Storage"); |
| 190 | |
| 191 | export 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 | |
| 201 | export 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 | |
| 226 | Containers 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 |
| 231 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 232 | import * as Effect from "effect/Effect"; |
| 233 | import { MyContainer } from "./container.ts"; |
| 234 | |
| 235 | export 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 | |
| 255 | Containers 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: |
| 261 | const loader = yield* Cloudflare.WorkerLoader("LOADER"); |
| 262 | |
| 263 | // inside the fetch handler — load a sandboxed Worker from inline source: |
| 264 | const worker = yield* loader.load({ |
| 265 | compatibilityDate: "2026-01-28", |
| 266 | mainModule: "worker.js", |
| 267 | modules: { "worker.js": source }, |
| 268 | }); |
| 269 | const api = worker.getEntrypoint<{ |
| 270 | greet: (name: string) => Effect.Effect<string>; |
| 271 | }>("api"); |
| 272 | const 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 | |
| 279 | A plain async Worker calls a service binding directly: |
| 280 | |
| 281 | ```typescript |
| 282 | // inside a plain async fetch(request, env): |
| 283 | const greeting = await env.TARGET.greet(name); |
| 284 | ``` |
| 285 | |
| 286 | Or wrap the binding with `toRpcAsync` to auto-decode the wire |
| 287 | envelopes: |
| 288 | |
| 289 | ```typescript |
| 290 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 291 | import type Backend from "../backend.ts"; |
| 292 | |
| 293 | const backend = Cloudflare.toRpcAsync<Backend>(env.BACKEND); |
| 294 | const value = await backend.hello(key); |
| 295 | ``` |
| 296 | |
| 297 | Error 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 | |
| 301 | Schemaless 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 |