跳到內容

ch33-multitenant

取得並執行

這個範例可以獨立 clone 執行,不依賴其他章節。

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch33-multitenant
cd ch33-multitenant
npm install

可用指令

npm run dev	# wrangler dev
npm run types	# wrangler types --env-interface CloudflareBindings

說明

ch33 — Workers for Platforms vs Dynamic Workers

Section titled “ch33 — Workers for Platforms vs Dynamic Workers”

Probe project for chapter 33. Both bindings are declared side by side so the difference is visible in one place.

Verified with wrangler 4.118.0.

Terminal window
npm install
npx wrangler types --env-interface CloudflareBindings
npx wrangler dev --port 9042

Dynamic Workers routes work locally. The dispatch namespace routes do not — see below.

RouteWhat it shows
/shapeBoth bindings’ real shape, and that LOADER.get() returns synchronously.
/dynamic?tenant=A tenant code string loaded and run. Look at envKeys.
/callback-countHow many times the get() callback actually fires.
/sandboxEscape attempts from inside globalOutbound: null.
/limitsA 5-second CPU spin under limits: { cpuMs: 50 }.
/module-typesjson and text modules imported by the guest.
/dispatch?name=Workers for Platforms dispatch.
/dispatch-missingThe documented Worker not found 404 idiom.
Terminal window
curl -s localhost:9042/shape | jq .loader
{
"ctorName": "WorkerLoader",
"protoKeys": ["get", "load", "constructor"],
"getIsSync": { "isPromise": false, "keys": ["getEntrypoint", "getDurableObjectClass", "constructor"] }
}

A real class, not a Fetcher/JsRpcProperty proxy like the Pipelines (ch23), Browser Run (ch30) and Email (ch32) bindings. get() hands back a stub immediately; the callback has not run yet.

Terminal window
curl -s "localhost:9042/dynamic?tenant=acme" | jq .ok
{ "transformedBy": "acme", "upper": "HELLO", "envKeys": ["TENANT_ID"], "canFetch": "function" }

The host Worker has CODE (KV), DISPATCHER and LOADER bindings. The guest sees exactly one key — the one that was passed in. Guest code isn’t forbidden from touching KV; it has no idea KV exists. There is no blacklist to bypass.

Terminal window
curl -s localhost:9042/sandbox | jq .ok
{
"fetch": "This worker is not permitted to access the internet via global functions like fetch()...",
"eval": "Code generation from strings disallowed for this context",
"wasm": "WebAssembly.compile(): Wasm code generation disallowed by embedder"
}

The last two aren’t Dynamic-Workers-specific — they’re platform-wide (ch27). Together: sealed guest code can compute, and use whatever you put in env, and nothing else.

Terminal window
curl -s localhost:9042/limits | jq
# { "ok": { "n": 94513572 }, "ms": 5011 }

That’s a 5-second CPU burn completing under limits: { cpuMs: 50 }. In production the docs say it “will immediately throw an exception.”

The words “local”, “wrangler dev” and “miniflare” appear nowhere in the Dynamic Workers docs, so this is undocumented either way. The practical split: isolation is testable locally, resource limits are not.

The id is a billing key, not just a cache key

Section titled “The id is a billing key, not just a cache key”
UsageBilled as
same id + same code, repeatedly1
same code, different ids1 per id
same id, different code1 per version
no id, or .load(code)1 per invocation

1,000 unique Dynamic Workers/month included, then $0.002 per worker per day. The beta waiver ended 2026-05-26.

Use `t:${tenantId}:v:${codeVersion}` — never a UUID.

Terminal window
curl -s localhost:9042/callback-count | jq
# { "callbackInvocations": 1, "statuses": [200, 200, 200] }

One locally, but the docs are explicit: “The callback passed to loader.get() could be called any number of times.” Keep it pure. Count loads outside it.

Terminal window
curl -s "localhost:9042/dispatch?name=customer-abc" | jq
# { "threwName": "Error", "threw": "Binding DISPATCHER needs to be run remotely" }

And the consequence that bites:

Terminal window
curl -s localhost:9042/dispatch-missing | jq
# { "threw": "Binding DISPATCHER needs to be run remotely", "matchesWorkerNotFound": false }

e.message.startsWith("Worker not found") — the documented 404 idiom — is always false locally. Your 404 branch is dead code in dev. Add "remote": true to the binding to develop against a real namespace.

interface DynamicDispatchLimits { cpuMs?: number; subRequests?: number }

Capital R. Most Cloudflare config is snake_case; this is camelCase with an uppercase R. subrequests and sub_requests don’t error — they’re ignored.

And from the docs: “All your customers’ Workers should live in a single namespace… Do not create a namespace per customer.

Workers for PlatformsDynamic Workers
Who writes the codeyour customeryour Worker (or an LLM, or a pasted snippet)
When it arrivesat deploy timeat runtime, as a string
Invocationenv.DISPATCHER.get(name)env.LOADER.get(id, () => ({ modules }))
Statusmature paid productopen beta (2026-03-24)

Neither replaces the other. Their docs trees never reference each other, and Cloudflare publishes no comparison — the table above is this chapter’s synthesis, not an official position.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch33-multitenant",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-24",
  "observability": { "enabled": true },
  // Dynamic Workers: load code generated at runtime.
  "worker_loaders": [{ "binding": "LOADER" }],
  // Workers for Platforms: dispatch to Workers your CUSTOMERS deployed.
  "dispatch_namespaces": [{ "binding": "DISPATCHER", "namespace": "ch33-tenants" }],
  "kv_namespaces": [{ "binding": "CODE", "id": "ch33-local" }]
}

package.json

{
  "name": "ch33-multitenant",
  "private": true,
  "type": "module",
  "scripts": { "dev": "wrangler dev", "types": "wrangler types --env-interface CloudflareBindings" },
  "devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2" }
}

src/index.ts

const t = async (fn: () => unknown): Promise<Record<string, unknown>> => {
  const started = Date.now();
  try {
    const v = await fn();
    return { ok: v === undefined ? "(undefined)" : v, ms: Date.now() - started };
  } catch (e) {
    return {
      threwName: (e as Error).name,
      threw: String((e as Error).message ?? e).slice(0, 300),
      ms: Date.now() - started,
    };
  }
};

/** A tenant-supplied webhook transformer. In reality this comes from KV/D1. */
const TENANT_CODE = `
export default {
  async fetch(request, env) {
    const body = await request.json();
    return Response.json({
      transformedBy: env.TENANT_ID,
      upper: String(body.text ?? "").toUpperCase(),
      // Only what the host put on env is visible.
      envKeys: Object.keys(env),
      // Proves whether outbound network is reachable.
      canFetch: typeof fetch,
    });
  },
};
`;

export default {
  async fetch(request: Request, env: CloudflareBindings, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const q = url.searchParams;

    switch (url.pathname) {
      case "/shape": {
        const loader = env.LOADER as unknown as object;
        const disp = env.DISPATCHER as unknown as object;
        return Response.json({
          loader: {
            ctorName: Object.getPrototypeOf(loader)?.constructor?.name ?? null,
            protoKeys: Object.getOwnPropertyNames(Object.getPrototypeOf(loader)),
            // get() returns a stub SYNCHRONOUSLY -- not a promise.
            getIsSync: (() => {
              const r = env.LOADER.get("probe", () => ({
                compatibilityDate: "2026-07-24",
                mainModule: "m.js",
                modules: { "m.js": "export default { fetch: () => new Response('x') }" },
              }));
              return { isPromise: r instanceof Promise, keys: Object.getOwnPropertyNames(Object.getPrototypeOf(r)) };
            })(),
          },
          dispatcher: {
            ctorName: Object.getPrototypeOf(disp)?.constructor?.name ?? null,
            protoKeys: Object.getOwnPropertyNames(Object.getPrototypeOf(disp)),
          },
        });
      }

      // ---------------------------------------------- DYNAMIC WORKERS
      // Run code that YOUR Worker produced at runtime.
      case "/dynamic": {
        return Response.json(
          await t(async () => {
            const tenantId = q.get("tenant") ?? "acme";

            const stub = env.LOADER.get(`tenant:${tenantId}`, async () => ({
              compatibilityDate: "2026-07-24",
              mainModule: "main.js",
              modules: { "main.js": TENANT_CODE },
              // CAPABILITY BOUNDARY: the dynamic Worker sees exactly this
              // object as its `env`, and nothing else. No KV, no D1, no
              // secrets -- unless you deliberately pass them.
              env: { TENANT_ID: tenantId },
              // null = no outbound network at all. This is the important knob.
              globalOutbound: q.get("net") ? undefined : null,
            }));

            const res = await stub
              .getEntrypoint()
              .fetch("http://tenant/", {
                method: "POST",
                body: JSON.stringify({ text: "hello" }),
                headers: { "content-type": "application/json" },
              });
            return await res.json();
          }),
        );
      }

      // Does the callback run once, or on every get()?
      case "/callback-count": {
        let calls = 0;
        const results: unknown[] = [];
        for (let i = 0; i < 3; i++) {
          const stub = env.LOADER.get("counted", () => {
            calls++;
            return {
              compatibilityDate: "2026-07-24",
              mainModule: "m.js",
              modules: { "m.js": "export default { fetch: () => Response.json({ ok: true }) }" },
            };
          });
          const r = await stub.getEntrypoint().fetch("http://x/");
          results.push(r.status);
        }
        return Response.json({
          note: "The callback only runs when a cold isolate is needed, and MAY run more than once. Never put side effects in it.",
          callbackInvocations: calls,
          statuses: results,
        });
      }

      // Can the guest reach the network when globalOutbound is null?
      case "/sandbox": {
        const escape = `
          export default {
            async fetch() {
              const probes = {};
              for (const [k, fn] of Object.entries({
                fetch: () => fetch("https://example.com"),
                eval:  () => (0, eval)("1+1"),
                wasm:  () => WebAssembly.compile(new Uint8Array([0,97,115,109,1,0,0,0])),
              })) {
                try { await fn(); probes[k] = "allowed"; }
                catch (e) { probes[k] = String(e.message ?? e).slice(0, 90); }
              }
              return Response.json(probes);
            },
          };
        `;
        return Response.json(
          await t(async () => {
            const stub = env.LOADER.get("escape-test", () => ({
              compatibilityDate: "2026-07-24",
              mainModule: "m.js",
              modules: { "m.js": escape },
              env: {},
              globalOutbound: null,      // fully sealed
            }));
            return await (await stub.getEntrypoint().fetch("http://x/")).json();
          }),
        );
      }

      // Resource limits on the guest.
      case "/limits": {
        const spin = `
          export default {
            fetch() {
              const end = Date.now() + 5000;
              let n = 0;
              while (Date.now() < end) n++;    // burn CPU
              return Response.json({ n });
            },
          };
        `;
        return Response.json(
          await t(async () => {
            const stub = env.LOADER.get("spinner", () => ({
              compatibilityDate: "2026-07-24",
              mainModule: "m.js",
              modules: { "m.js": spin },
              limits: { cpuMs: 50 },
            }));
            return await (await stub.getEntrypoint().fetch("http://x/")).json();
          }),
        );
      }

      // Non-JS module types the loader accepts.
      case "/module-types": {
        return Response.json(
          await t(async () => {
            const stub = env.LOADER.get("modtypes", () => ({
              compatibilityDate: "2026-07-24",
              mainModule: "main.js",
              modules: {
                "main.js": `
                  import cfg from "./cfg.json";
                  import note from "./note.txt";
                  export default { fetch: () => Response.json({ cfg, note }) };
                `,
                "cfg.json": { json: { feature: true } },
                "note.txt": { text: "hello from a text module" },
              },
            }));
            return await (await stub.getEntrypoint().fetch("http://x/")).json();
          }),
        );
      }

      // ------------------------------------- WORKERS FOR PLATFORMS
      // Dispatch to a Worker YOUR CUSTOMER deployed into the namespace.
      case "/dispatch": {
        const name = q.get("name") ?? "customer-abc";
        return Response.json(
          await t(async () => {
            // NOTE the capital R in subRequests -- unlike most Cloudflare
            // config keys, this one is camelCase with an uppercase R.
            const worker = env.DISPATCHER.get(
              name,
              {},
              { limits: { cpuMs: 50, subRequests: 10 } },
            );
            const res = await worker.fetch(request);
            return { status: res.status, body: (await res.text()).slice(0, 200) };
          }),
        );
      }

      // The documented way to turn "no such customer Worker" into a 404.
      case "/dispatch-missing": {
        try {
          const worker = env.DISPATCHER.get("definitely-not-deployed");
          const res = await worker.fetch(request);
          return Response.json({ status: res.status });
        } catch (e) {
          const msg = (e as Error).message;
          return Response.json({
            threwName: (e as Error).name,
            threw: msg.slice(0, 200),
            // This string check is the documented idiom.
            matchesWorkerNotFound: msg.startsWith("Worker not found"),
          });
        }
      }

      default:
        return new Response(
          "/shape /dynamic?tenant=&net=1 /callback-count /sandbox /limits /module-types\n" +
            "/dispatch?name= /dispatch-missing\n",
          { status: 404 },
        );
    }
  },
} satisfies ExportedHandler<CloudflareBindings>;

.gitignore

node_modules/
.wrangler/
worker-configuration.d.ts

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"]
}