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.
npm installnpx wrangler types --env-interface CloudflareBindingsnpx wrangler dev --port 9042Dynamic Workers routes work locally. The dispatch namespace routes do not — see below.
Routes
Section titled “Routes”| Route | What it shows |
|---|---|
/shape | Both bindings’ real shape, and that LOADER.get() returns synchronously. |
/dynamic?tenant= | A tenant code string loaded and run. Look at envKeys. |
/callback-count | How many times the get() callback actually fires. |
/sandbox | Escape attempts from inside globalOutbound: null. |
/limits | A 5-second CPU spin under limits: { cpuMs: 50 }. |
/module-types | json and text modules imported by the guest. |
/dispatch?name= | Workers for Platforms dispatch. |
/dispatch-missing | The documented Worker not found 404 idiom. |
LOADER.get() is synchronous and lazy
Section titled “LOADER.get() is synchronous and lazy”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.
The capability boundary is real
Section titled “The capability boundary is real”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.
globalOutbound: null seals it
Section titled “globalOutbound: null seals it”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.
Limits are NOT enforced locally
Section titled “Limits are NOT enforced locally”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”| Usage | Billed as |
|---|---|
| same id + same code, repeatedly | 1 |
| same code, different ids | 1 per id |
| same id, different code | 1 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.
The callback may run more than once
Section titled “The callback may run more than once”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.
Workers for Platforms needs remote: true
Section titled “Workers for Platforms needs remote: true”curl -s "localhost:9042/dispatch?name=customer-abc" | jq# { "threwName": "Error", "threw": "Binding DISPATCHER needs to be run remotely" }And the consequence that bites:
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.
Two config details that silently bite
Section titled “Two config details that silently bite”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.”
Which one?
Section titled “Which one?”| Workers for Platforms | Dynamic Workers | |
|---|---|---|
| Who writes the code | your customer | your Worker (or an LLM, or a pasted snippet) |
| When it arrives | at deploy time | at runtime, as a string |
| Invocation | env.DISPATCHER.get(name) | env.LOADER.get(id, () => ({ modules })) |
| Status | mature paid product | open 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.tstsconfig.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"]
}