ch18-rpc
對應 18. Workers RPC 與 Service Bindings:把單體拆開
在 GitHub 上檢視·8 個檔案·10.4 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch18-rpc
cd ch18-rpc
npm install可用指令
npm run dev # wrangler dev -c api/wrangler.jsonc -c auth/wrangler.jsonc
npm run typecheck # tsc --noEmit -p api && tsc --noEmit -p auth說明
ch18 — Workers RPC and service bindings
Section titled “ch18 — Workers RPC and service bindings”Companion example for docs/18-rpc-service-bindings.md.
Two Workers: api (caller) and auth (callee).
npm installnpx wrangler dev -c api/wrangler.jsonc -c auth/wrangler.jsoncB=localhost:8787curl -s "$B/basic"curl -s "$B/instances"curl -s "$B/rpctarget"curl -s "$B/plainclass"curl -s "$B/callback"curl -s "$B/props"curl -s "$B/roundtrips?n=500"curl -s "$B/pipelining?ms=300"curl -s "$B/reserved"curl -s "$B/viafetch"Verified findings
Section titled “Verified findings”2026-08-01 · wrangler@4.114.0 · workerd@1.20260722.1
RPC types do not cross Workers automatically
Section titled “RPC types do not cross Workers automatically”wrangler types emits the entrypoint name only as a comment:
AUTH: Service /* entrypoint AuthService from ch18-auth */;so env.AUTH.verify(...) fails to compile. api/src/env.ts wires it up:
import type { AuthService } from "../../auth/src/index";export interface AppEnv extends Omit<CloudflareBindings, "AUTH"> { AUTH: Service<AuthService>;}Exercise: delete that file’s contents and watch the compile errors — that is what “types do not flow” looks like.
A fresh instance per invocation
Section titled “A fresh instance per invocation”{"ids":["o6f3jt","avyqrv","csxxbw"],"allDifferent":true,"callCounts":[1,1,1]}Three calls, three instances, each counter at 1. Instance fields on a
WorkerEntrypoint are single-call scratch space — the opposite of a Durable
Object, which persists until evicted.
RpcTarget: methods yes, getters yes, properties no
Section titled “RpcTarget: methods yes, getters yes, properties no”{"method_can":{"ok":true}, "method_profile":{"ok":{"userId":"u1","tenantId":"t1","label":"u1@t1"}}, "getter_label":{"ok":"u1@t1"}, "plainProp":{"threw":"TypeError: The RPC receiver does not implement the method \"plainProp\"."}, "paramProp_userId":{"threw":"TypeError: The RPC receiver does not implement the method \"userId\"."}}Both a plain instance property and a TypeScript parameter property are unreachable. Expose values through getters or methods. The error message does not hint at the cause.
A class that does not extend RpcTarget cannot cross at all:
{"result":{"threw":"DataCloneError: Could not serialize object of type \"PlainToken\". This type does not support serialization."}}Callback stubs
Section titled “Callback stubs”{"result":"succeeded on attempt 2","attemptsSeen":3}The callee invoked a function belonging to the caller three times.
ctx.props comes from the caller’s binding config
Section titled “ctx.props comes from the caller’s binding config”{"props":{"callerName":"ch18-api","tier":"internal"}}The caller cannot forge it from a request body — the platform injects it.
Pipelining saves round trips, not callee time
Section titled “Pipelining saves round trips, not callee time”n=200 -> {"twoHopsMs":1358,"pipelinedMs":980, "savedMs":378, "savedPercent":28}n=500 -> {"twoHopsMs":2136,"pipelinedMs":1958,"savedMs":178, "savedPercent":8}n=1000 -> {"twoHopsMs":7645,"pipelinedMs":4418,"savedMs":3227,"savedPercent":42}Pipelined wins every run, but the ratio is noisy because both Workers share one local workerd process — a “round trip” is in-process here. With a callee that sleeps 300ms:
{"delayMs":300,"sequentialMs":302,"pipelinedMs":302,"savedMs":0}No difference at all: the 300ms is paid either way. The saving is one network hop, which only becomes real in production.
Reserved names
Section titled “Reserved names”{"dup":{"threw":"TypeError: 'dup' is a reserved method and cannot be called over RPC."}, "constructorCall":{"threw":"TypeError: Illegal constructor"}, "nonExistent":{"threw":"TypeError: The RPC receiver does not implement the method \"noSuchMethod\"."}}原始碼
package.jsonapi/src/env.tsapi/src/index.tsapi/tsconfig.jsonapi/wrangler.jsoncauth/src/index.tsauth/tsconfig.jsonauth/wrangler.jsonc
package.json
{ "name": "ch18-rpc", "private": true, "type": "module",
"scripts": {
"dev": "wrangler dev -c api/wrangler.jsonc -c auth/wrangler.jsonc",
"typecheck": "tsc --noEmit -p api && tsc --noEmit -p auth" },
"devDependencies": { "typescript": "^5.9.0", "wrangler": "^4.114.0" } }api/src/env.ts
// ---------------------------------------------------------------------------
// `wrangler types` knows the entrypoint name but does not parameterise it:
//
// AUTH: Service /* entrypoint AuthService from ch18-auth */;
//
// So RPC method types do NOT flow across Workers automatically. Wire them up
// here with a type-only import and the Service<T> helper.
// ---------------------------------------------------------------------------
import type { AuthService } from "../../auth/src/index";
export interface AppEnv extends Omit<CloudflareBindings, "AUTH"> {
AUTH: Service<AuthService>;
}api/src/index.ts
import type { AppEnv } from "./env";
const t = async (fn: () => Promise<unknown>): Promise<unknown> => {
try { return { ok: await fn() }; }
catch (e) { return { threw: String(e).slice(0, 200) }; }
};
export default {
async fetch(request: Request, env: AppEnv): Promise<Response> {
const url = new URL(request.url);
const auth = env.AUTH;
switch (url.pathname) {
case "/basic":
return Response.json({
good: await auth.verify("good"),
bad: await auth.verify("bad"),
});
// Each invocation gets a FRESH instance — no cross-request state.
case "/instances": {
const a = await auth.verify("good");
const b = await auth.verify("good");
const c = await auth.verify("good");
return Response.json({
ids: [a.instanceId, b.instanceId, c.instanceId],
allDifferent: new Set([a.instanceId, b.instanceId, c.instanceId]).size === 3,
callCounts: [a.instanceCalls, b.instanceCalls, c.instanceCalls],
});
}
// RpcTarget crosses as a STUB — methods are callable remotely.
// What is reachable on an RpcTarget stub: methods, getters, plain props?
case "/rpctarget": {
const session = await auth.openSession("good");
const s = session as unknown as Record<string, unknown>;
return Response.json({
method_can: await t(() => session.can("edit")),
method_profile: await t(() => session.profile()),
getter_label: await t(async () => await (s.label as Promise<unknown>)),
plainProp: await t(async () => await (s.plainProp as Promise<unknown>)),
paramProp_userId: await t(async () => await (s.userId as Promise<unknown>)),
});
}
// A plain class cannot cross.
case "/plainclass":
return Response.json({ result: await t(() => auth.plainClass()) });
// 🎯 Promise pipelining: no intermediate await -> one round trip.
case "/pipelining": {
const delay = Number(url.searchParams.get("ms") ?? 300);
// A. Awaiting each step: two sequential round trips.
const tA = Date.now();
const session = await auth.slowSession("good", delay);
const seqProfile = await session.profile();
const seqMs = Date.now() - tA;
// B. Pipelined: the .profile() call is sent along with the first one.
const tB = Date.now();
const pipedProfile = await auth.slowSession("good", delay).profile();
const pipeMs = Date.now() - tB;
return Response.json({
delayMs: delay,
sequentialMs: seqMs,
pipelinedMs: pipeMs,
savedMs: seqMs - pipeMs,
sameResult: JSON.stringify(seqProfile) === JSON.stringify(pipedProfile),
});
}
// Pipelining saves ROUND TRIPS, not wall time in the callee. Locally
// a "round trip" is in-process, so the saving is only visible as
// per-call overhead across many iterations.
case "/roundtrips": {
const n = Number(url.searchParams.get("n") ?? 200);
const t1 = Date.now();
for (let i = 0; i < n; i++) {
const s = await auth.openSession("good"); // hop 1
await s.profile(); // hop 2
}
const twoHops = Date.now() - t1;
const t2 = Date.now();
for (let i = 0; i < n; i++) {
await auth.openSession("good").profile(); // pipelined: 1 hop
}
const oneHop = Date.now() - t2;
return Response.json({
iterations: n,
twoHopsMs: twoHops,
pipelinedMs: oneHop,
savedMs: twoHops - oneHop,
savedPercent: Math.round(((twoHops - oneHop) / twoHops) * 100),
perCallOverheadUs: Math.round(((twoHops - oneHop) / n) * 1000),
});
}
// Functions cross as callback stubs — the callee calls back into us.
case "/callback": {
let attemptsSeen = 0;
const result = await auth.withRetry(3, async (attempt: number) => {
attemptsSeen++;
if (attempt < 2) throw new Error(`fail ${attempt}`);
return `succeeded on attempt ${attempt}`;
});
return Response.json({ result, attemptsSeen });
}
// ctx.props comes from the CALLER's binding config.
case "/props":
return Response.json(await auth.whoIsCalling());
// fetch() is still available, with fetch semantics.
case "/viafetch": {
const res = await auth.fetch(new Request("https://auth.internal/check"));
return Response.json({ status: res.status, body: await res.json() });
}
// Reserved / disallowed names.
case "/reserved": {
const a = auth as unknown as Record<string, () => Promise<unknown>>;
return Response.json({
dup: await t(() => a.dup()),
constructorCall: await t(() => a.constructor()),
nonExistent: await t(() => a.noSuchMethod()),
});
}
default:
return new Response(
"try /basic /instances /rpctarget /plainclass /pipelining?ms=300 /callback /props /viafetch /reserved\n",
{ status: 404 },
);
}
},
} satisfies ExportedHandler<AppEnv>;api/tsconfig.json
{ "compilerOptions": { "target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true },
"include": ["src/**/*.ts", "worker-configuration.d.ts"] }api/wrangler.jsonc
{
"$schema": "../node_modules/wrangler/config-schema.json",
"name": "ch18-api",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true },
"services": [
{
"binding": "AUTH",
"service": "ch18-auth",
"entrypoint": "AuthService",
// props are set HERE, by the caller's config. The callee reads them from
// ctx.props and can trust them — the platform guarantees only authorized
// deployments can set them.
"props": { "callerName": "ch18-api", "tier": "internal" }
}
]
}auth/src/index.ts
import { WorkerEntrypoint, RpcTarget } from "cloudflare:workers";
/** Only subclasses of RpcTarget can be returned across an RPC boundary. */
export class Session extends RpcTarget {
/** A plain instance property, assigned in the constructor body. */
public plainProp: string;
constructor(
// Parameter properties are also plain instance properties.
public readonly userId: string,
public readonly tenantId: string,
) {
super();
this.plainProp = `plain:${userId}`;
}
/** A GETTER on the prototype — the documented way to expose a value. */
get label(): string {
return `${this.userId}@${this.tenantId}`;
}
/** Methods on an RpcTarget are callable from the caller — one more hop. */
can(action: string): boolean {
return action !== "delete-tenant";
}
profile(): { userId: string; tenantId: string; label: string } {
return { userId: this.userId, tenantId: this.tenantId, label: `${this.userId}@${this.tenantId}` };
}
}
/** A plain class — NOT an RpcTarget. Cannot cross the boundary. */
export class PlainToken {
constructor(public readonly raw: string) {}
}
export class AuthService extends WorkerEntrypoint<CloudflareBindings> {
// A per-instance counter, to prove a fresh instance is created per invocation.
private instanceCalls = 0;
private readonly instanceId = Math.random().toString(36).slice(2, 8);
async verify(token: string): Promise<{ ok: boolean; instanceId: string; instanceCalls: number }> {
this.instanceCalls++;
return { ok: token === "good", instanceId: this.instanceId, instanceCalls: this.instanceCalls };
}
/** Returns an RpcTarget — the caller gets a stub, not a copy. */
async openSession(token: string): Promise<Session> {
if (token !== "good") throw new Error("invalid token");
return new Session("u1", "t1");
}
/** Deliberately slow, to make pipelining measurable. */
async slowSession(token: string, delayMs: number): Promise<Session> {
await scheduler.wait(delayMs);
if (token !== "good") throw new Error("invalid token");
return new Session("u1", "t1");
}
/** Accepts a callback — functions become callback stubs across RPC. */
async withRetry<T>(attempts: number, fn: (attempt: number) => Promise<T>): Promise<T> {
let last: unknown;
for (let i = 0; i < attempts; i++) {
try { return await fn(i); } catch (e) { last = e; }
}
throw new Error(`all ${attempts} attempts failed: ${String(last)}`);
}
/** Tries to return a non-RpcTarget class. */
async plainClass(): Promise<PlainToken> {
return new PlainToken("abc");
}
/** ctx.props is set by the CALLER's binding config — trustworthy by construction. */
async whoIsCalling(): Promise<unknown> {
return { props: this.ctx.props ?? null };
}
/** Reserved-name probe: `fetch` must be Request -> Response. */
override async fetch(request: Request): Promise<Response> {
return Response.json({ via: "fetch", url: request.url });
}
}
export default {
async fetch(): Promise<Response> {
return new Response("auth service: bound via service binding, not routed directly\n");
},
} satisfies ExportedHandler<CloudflareBindings>;auth/tsconfig.json
{ "compilerOptions": { "target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true },
"include": ["src/**/*.ts", "worker-configuration.d.ts"] }auth/wrangler.jsonc
{
"$schema": "../node_modules/wrangler/config-schema.json",
"name": "ch18-auth",
"main": "src/index.ts",
"compatibility_date": "2026-07-24"
}