跳到內容

ch18-rpc

取得並執行

這個範例可以獨立 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

說明

Companion example for docs/18-rpc-service-bindings.md.

Two Workers: api (caller) and auth (callee).

Terminal window
npm install
npx wrangler dev -c api/wrangler.jsonc -c auth/wrangler.jsonc
Terminal window
B=localhost:8787
curl -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"

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.

{"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."}}
{"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.

{"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.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"
}