| 1 | --- |
| 2 | title: Schemaless RPC |
| 3 | description: The pattern behind typed, schema-free RPC — what an RPC member may be, how the typed client arises, how calls travel over the wire, and where the limits are. |
| 4 | sidebar: |
| 5 | order: 1 |
| 6 | --- |
| 7 | |
| 8 | Schemaless RPC is the default for internal communication between the |
| 9 | services you deploy together: expose methods on a runtime's |
| 10 | interface, bind it from another resource, and call them through a |
| 11 | fully typed client — no schema, no runtime validation, no |
| 12 | per-request decode cost. |
| 13 | |
| 14 | ## The shape |
| 15 | |
| 16 | RPC is a feature of every |
| 17 | [Runtime](/infrastructure-as-effects/runtime) |
| 18 | runtime — the interface its Effectful Constructor returns may |
| 19 | include methods alongside `fetch`: |
| 20 | |
| 21 | ```typescript |
| 22 | export default class Greeter extends Cloudflare.Worker<Greeter>()( |
| 23 | "Greeter", |
| 24 | { main: import.meta.url }, |
| 25 | Effect.gen(function* () { |
| 26 | return { |
| 27 | greet: (name: string) => Effect.succeed(`hello ${name}`), |
| 28 | fetch: Effect.gen(function* () { |
| 29 | return HttpServerResponse.text("ok"); |
| 30 | }), |
| 31 | }; |
| 32 | }), |
| 33 | ) {} |
| 34 | ``` |
| 35 | |
| 36 | The host class here happens to be `Cloudflare.Worker`, but the host |
| 37 | is incidental — the pattern is identical on every runtime. This page |
| 38 | covers what members are allowed, how the typed client arises, what |
| 39 | crosses the wire, and where the limits are. |
| 40 | |
| 41 | ## What a member may be |
| 42 | |
| 43 | An RPC-callable member is a **function returning an Effect**: |
| 44 | |
| 45 | ```typescript |
| 46 | return { |
| 47 | greet: (name: string) => Effect.succeed(`hello ${name}`), |
| 48 | }; |
| 49 | ``` |
| 50 | |
| 51 | …or a **function returning a Stream**, which the caller consumes |
| 52 | with the usual `Stream.*` combinators: |
| 53 | |
| 54 | ```diff lang="typescript" |
| 55 | return { |
| 56 | greet: (name: string) => Effect.succeed(`hello ${name}`), |
| 57 | + tick: (n: number) => |
| 58 | + Stream.iterate(0, (i) => i + 1).pipe( |
| 59 | + Stream.take(n), |
| 60 | + Stream.schedule(Schedule.spaced("100 millis")), |
| 61 | + ), |
| 62 | }; |
| 63 | ``` |
| 64 | |
| 65 | That is the whole rule: a function, with any arguments, returning an |
| 66 | Effect or a Stream. Bare Effect and Stream members also satisfy the |
| 67 | type — `fetch` itself is one — but they serve the host rather than |
| 68 | remote callers, so declare your RPC surface as functions. Typed |
| 69 | failures flow to the caller |
| 70 | ([errors over the wire](#errors-over-the-wire) below), and |
| 71 | requirements like `RuntimeContext` are satisfied by the deployed |
| 72 | runtime. |
| 73 | |
| 74 | ### What the types reject |
| 75 | |
| 76 | A member that is a plain value, or a function returning a plain value, fails the Shape constraint: |
| 77 | |
| 78 | ```typescript |
| 79 | // ❌ rejected — neither member is an Effect, a Stream, or returns one: |
| 80 | return { |
| 81 | version: "1.0", |
| 82 | greet: (name: string) => `hello ${name}`, |
| 83 | }; |
| 84 | |
| 85 | // ✅ accepted: |
| 86 | return { |
| 87 | version: () => Effect.succeed("1.0"), |
| 88 | greet: (name: string) => Effect.succeed(`hello ${name}`), |
| 89 | }; |
| 90 | ``` |
| 91 | |
| 92 | The rejection happens at declaration, not at call time — it surfaces as an assignability error on the constructor Effect you pass to the host class, so a shape that compiles is a shape every caller can rely on. |
| 93 | |
| 94 | ## The typed client |
| 95 | |
| 96 | There is no schema because the class itself carries the type. `class Greeter extends Cloudflare.Worker<Greeter>()(...)` gives the class a construct signature — `new (_: never): Shape & ...` — that makes its instance type exactly the Shape the constructor Effect returns, and the value you get by yielding the class carries that Shape as a phantom type (`Rpc<Shape> = { "~alchemy/rpc": Shape }`) that binders read back. Binding the class produces the client: |
| 97 | |
| 98 | ```typescript |
| 99 | // in another resource's Effectful Constructor: |
| 100 | const target = yield* Cloudflare.Workers.bindWorker(Greeter); |
| 101 | |
| 102 | // in the interface it returns: |
| 103 | const greeting = yield* target.greet(name); // typed from Greeter's Shape |
| 104 | ``` |
| 105 | |
| 106 | `bindWorker` is the Cloudflare Worker-to-Worker binder; each runtime pair has its own bind callable — the hub pages list them, with full callers per pairing. The mental model is the same everywhere: the stub is a Proxy whose get-trap dispatches over the wire. Every property access is a remote call — the only exceptions are the host's own `fetch`/`connect` surface, which pass through to the underlying binding. |
| 107 | |
| 108 | ## How a call travels |
| 109 | |
| 110 | One wire protocol, two carriers. Where the platform provides a native structured-clone channel (Cloudflare service bindings and Durable Object stubs, for example), calls ride it directly and values move by structured clone. Everywhere else — Containers and MicroVMs among them — a generic fetch transport carries the call: each method invocation is a `POST {base}/__rpc__/{name}` whose body is the JSON-encoded argument array, and the response is a JSON value, a JSON error envelope, or an NDJSON stream flagged by a response header: |
| 111 | |
| 112 | ```typescript |
| 113 | // packages/alchemy/src/Rpc.ts |
| 114 | /** Path prefix under which RPC methods are dispatched. */ |
| 115 | export const RPC_PATH_PREFIX = "/__rpc__/"; |
| 116 | /** Response header flag marking an NDJSON streamed body (vs a JSON value). */ |
| 117 | export const RPC_STREAM_HEADER = "x-alchemy-rpc-stream"; |
| 118 | ``` |
| 119 | |
| 120 | Per-call protocol overhead is sub-millisecond — benchmarked in the wire-protocol test suite. |
| 121 | |
| 122 | ## Streams |
| 123 | |
| 124 | A stub call's return value is *both* an Effect and a Stream. The stub can't know synchronously which one the remote method returns — the call is asynchronous — yet its declared type mirrors the remote Shape verbatim, so the single value it hands back satisfies both interfaces: |
| 125 | |
| 126 | ```typescript |
| 127 | // one stub, both call shapes: |
| 128 | const greeting = stub.greet(name); // value method — yield* it as an Effect |
| 129 | const firstFive = stub.tick(n).pipe(Stream.take(5)); // streaming method — pipe it as a Stream |
| 130 | ``` |
| 131 | |
| 132 | Streams are pulled lazily under backpressure: an infinite remote Stream can be `Stream.take(5)`-ed, and the server stops producing when the client stops consuming. A returned Stream can be piped through any `Stream.*` combinator, including straight onto an HTTP response. Byte streams (`Uint8Array`) round-trip alongside JSON object streams. A failure mid-stream travels as a trailing error marker and surfaces on the consumer as `RpcRemoteStreamError` with the encoded inner error on `.error`; values emitted before the failure are delivered. |
| 133 | |
| 134 | ## Errors over the wire |
| 135 | |
| 136 | Typed failures cross the wire intact — `Effect.fail(new MyTaggedError(...))` on the callee is recoverable on the caller: |
| 137 | |
| 138 | ```typescript |
| 139 | class KeyMissing extends Data.TaggedError("KeyMissing")<{ |
| 140 | readonly key: string; |
| 141 | }> {} |
| 142 | |
| 143 | // callee — a method that fails with a tagged error: |
| 144 | get: (key: string) => |
| 145 | kv.get(key).pipe( |
| 146 | Effect.flatMap((value) => |
| 147 | value === null |
| 148 | ? Effect.fail(new KeyMissing({ key })) |
| 149 | : Effect.succeed(value), |
| 150 | ), |
| 151 | ), |
| 152 | |
| 153 | // caller — `_tag` and `key` survive the wire; class identity does not: |
| 154 | const value = yield* stub.get(key).pipe( |
| 155 | Effect.catchTag("KeyMissing", (e) => |
| 156 | Effect.succeed(`missing: ${e.key}`), |
| 157 | ), |
| 158 | ); |
| 159 | ``` |
| 160 | |
| 161 | The codec rules: tagged errors keep `_tag` and all own enumerable fields, and arrive as plain structs — `Effect.catchTag` matches because it keys on `_tag`, but `e instanceof KeyMissing` is `false`; class identity does not survive. Plain `Error`s keep `name`, `message`, and `stack`. Calling an unknown method fails with `RpcCallError`. Defects are not enveloped — the call rejects and surfaces as `RpcCallError`. |
| 162 | |
| 163 | ## Limits, and why there is no validation |
| 164 | |
| 165 | Arguments and results must survive the wire codec: structured clone on the native channel, JSON on the fetch transport. Stream elements must be JSON values or `Uint8Array` (binary is base64-tagged on the fetch transport so it round-trips). Functions and callbacks don't serialize. Class identity is always stripped. And because every property access on a stub dispatches remotely (bar the host's own `fetch`/`connect` pass-through), there are no local fields — the stub is only its methods. |
| 166 | |
| 167 | There is zero runtime validation anywhere in the stub or the server. That is the point — no schema to maintain, no per-request decode cost — and the trade-off: nothing sanitizes what arrives. Both sides are your code, deployed together and typed from the same Shape, which is why that trade is safe internally. At a trust boundary — external callers, versioned APIs, untrusted input — reach for schema-validated [Effect RPC](/apis/effect-rpc) instead. |
| 168 | |
| 169 | ## Where next |
| 170 | |
| 171 | - [Schemaless RPC on Cloudflare](/cloudflare/apis/schemaless-rpc) — every Cloudflare pairing: Worker → Worker, Worker → Durable Object, another script's Durable Object, Containers, and async consumers. |
| 172 | - [Schemaless RPC on AWS](/aws/apis/schemaless-rpc) — Lambda → MicroVM, plus what AWS does not yet have a schemaless stub for. |
| 173 | - [Effect RPC](/apis/effect-rpc) — schema-validated RPC for trust boundaries. |