跳到內容

多租戶執行使用者程式碼:Workers for Platforms vs Dynamic Workers

查證日期
驗證環境wrangler@4.118.0·compatibility_date: 2026-07-24

如果你在做一個平台型產品,遲早會遇到這個需求:讓客戶跑他們自己的程式碼。Shopify 的 app、Zapier 的自訂步驟、CI 平台的 build script、或者 LinkForge 讓租戶自訂重導向前置邏輯。

Cloudflare 有兩個產品解決這件事,名字都很像、都在 2026 年很活躍、而且官方文件從來沒有把它們放在一起比較過。本章最主要的價值就是把它們分乾淨。


Workers for PlatformsDynamic Workers
誰寫程式碼你的客戶你的 Worker(或 AI,或使用者貼上的片段)
什麼時候上傳客戶部署時(上傳到 namespace)執行期,就是一個字串
怎麼叫用env.DISPATCHER.get(name)env.LOADER.get(id, () => ({ modules }))
設定鍵dispatch_namespacesworker_loaders
狀態成熟的付費產品(無 beta 標示)Open beta(2026-03-24)
計費$25/月方案 + 超出的 per-script 費用每月含 1,000 個 unique Worker,之後 $0.002 / 個 / 天

判準:

  • 客戶自己 deploy 一個 Worker,你用名字去 dispatch → Workers for Platforms
  • 你的 Worker 在執行期產生或取得一段程式碼並載入它 → Dynamic Workers

⚠️ 兩者互不取代。 官方沒有任何一頁比較它們 —— 兩棵文件樹完全不相交:Workers for Platforms 的文件從未提到 “Dynamic Workers” 或 “Worker Loader”,Dynamic Workers 的文件與發表 blog 也從未提到 Workers for Platforms。

所以「Dynamic Workers 取代了 Workers for Platforms」這種說法是沒有根據的。上面那張表的判準是本章自己的整理,不是官方立場。


{ "worker_loaders": [{ "binding": "LOADER" }] }

schema 只有 binding 一個欄位,additionalProperties: false

實測 binding 的形狀:

{
"ctorName": "WorkerLoader",
"protoKeys": ["get", "load", "constructor"]
}

這是一個真正的類別,不是第 23、30、32 章那種 Fetcher + JsRpcProperty 的 RPC proxy。方法真的在 prototype 上。

interface WorkerLoader {
get(name: string | null, getCode: () => WorkerLoaderWorkerCode | Promise<...>): WorkerStub;
load(code: WorkerLoaderWorkerCode): WorkerStub;
}

實測:

{ "isPromise": false, "keys": ["getEntrypoint", "getDurableObjectClass", "constructor"] }

get() 立刻回傳一個 WorkerStub,不是 Promise。 callback 也還沒被呼叫 —— 它是懶惰的。這個設計讓「取得 stub」變成零成本,實際的載入延後到第一次真的發請求時。

WorkerStub 有兩個方法:

  • getEntrypoint<T>(name?, options?)Fetcher<T>
  • getDurableObjectClass<T>(name?, options?)DurableObjectClass<T>

第二個代表 dynamic Worker 可以匯出 Durable Object 類別,這是 33.6 那個 DO Facets 的基礎。

官方文件講得很直白:

「永遠不保證兩個請求會進到同一個 isolate。即使你用同一個 WorkerStub 發多個請求,它們也可能在不同的 isolate 執行。傳給 loader.get() 的 callback 可能被呼叫任意次數(雖然被呼叫超過一次並不常見)。」

實測連續三次 get() + fetch()

{ "callbackInvocations": 1, "statuses": [200, 200, 200] }

本機只呼叫了一次(warm isolate 被重用),但這不是保證

實務規則:callback 必須是冪等的、純函式的。 絕對不要在裡面做計費、寫 log、扣配額、或任何有副作用的事。它的工作只有一件:回傳程式碼。

需要「載入一次就記一次」的話,記在 get() 外面

這是 Dynamic Workers 最重要的性質。實測 —— 主 Worker 有 CODE(KV)、DISPATCHERLOADER 三個 binding,但載入的 Worker 看到的是:

{
"transformedBy": "acme",
"upper": "HELLO",
"envKeys": ["TENANT_ID"],
"canFetch": "function"
}

Object.keys(env) 只有 ["TENANT_ID"] —— 就是我明確傳進去的那個。KV、dispatcher、loader 一個都看不到。

const stub = env.LOADER.get(`tenant:${tenantId}`, async () => ({
compatibilityDate: "2026-07-24",
mainModule: "main.js",
modules: { "main.js": tenantCode },
env: { TENANT_ID: tenantId }, // ← 客戶程式碼能看到的全部
globalOutbound: null, // ← 完全沒有對外網路
}));

這是能力導向的安全模型(capability-based security):客戶程式碼不是「被禁止存取 KV」,而是根本不知道 KV 存在。沒有可以被繞過的黑名單。

需要給它一個受控的能力時,傳一個你自己實作的 WorkerEntrypoint 進去:

env: {
TENANT_ID: tenantId,
// 一個只能讀這個租戶前綴的 KV 代理,方法簽章由你決定
STORE: ctx.exports.TenantStore({ props: { tenantId } }),
}

官方:「設 globalOutboundnull 可以讓 dynamic Worker 與網路完全隔離」、「這會讓 dynamic Worker 發出的任何 fetch()connect() 請求拋出例外」。

實測讓客戶程式碼嘗試三種逃逸:

{
"fetch": "This worker is not permitted to access the internet via global functions like fetch(). It ",
"eval": "Code generation from strings disallowed for this context",
"wasm": "WebAssembly.compile(): Wasm code generation disallowed by embedder"
}

三條路全部封死。注意後兩條不是 Dynamic Workers 特有的 —— 那是第 27 章實測過的、整個 Workers 平台的性質。合起來看:

globalOutbound: null 的 dynamic Worker 裡,客戶程式碼既不能連外,也不能在執行期產生新的程式碼。 它能做的只有純運算,加上你放進 env 的那些能力。這是一個很紮實的沙箱邊界。

null 更彈性的做法是把 globalOutbound 指向你自己的 WorkerEntrypoint,這樣所有出站請求都會經過你的 handler,你可以檢查、改寫、或阻擋。

實測型別:

interface WorkerLoaderModule {
js?: string; cjs?: string; text?: string;
data?: ArrayBuffer; json?: any; py?: string; wasm?: ArrayBuffer;
}

py —— Python 模組。 還有 cjs(CommonJS)、jsontextwasm

實測 json 與 text 模組:

modules: {
"main.js": `import cfg from "./cfg.json"; import note from "./note.txt"; ...`,
"cfg.json": { json: { feature: true } },
"note.txt": { text: "hello from a text module" },
}
{ "cfg": { "feature": true }, "note": "hello from a text module" }

modules 的值可以直接給字串(等同 { js: "..." })或給物件指定型別。

資源限制:production 保證,本機不保證

Section titled “資源限制:production 保證,本機不保證”
const stub = env.LOADER.get("spinner", () => ({
...,
limits: { cpuMs: 50 },
}));

官方:「如果 Dynamic Worker 撞到這兩個限制中的任何一個,它會立刻拋出例外」。也可以在 getEntrypoint() 上設,「兩者取較低者」。

實測本機:完全沒有生效。 一段刻意燒 5 秒 CPU 的程式碼在 cpuMs: 50 的設定下跑完了:

{ "ok": { "n": 94513572 }, "ms": 5011 }

⚠️ Dynamic Workers 的文件裡完全沒有提到本機開發。 「local」、「wrangler dev」、「miniflare」這些字在整個文件集裡一次都沒有出現 —— 所以沒有任何一頁說本機會不會套用限制。

實測結果是不會。這代表:沙箱的隔離性(globalOutboundenv)本機測得出來,但資源上限(cpuMssubRequests)測不出來。 一段會吃爆 CPU 的租戶程式碼在本機看起來完全正常。

每月含 1,000 個 unique Dynamic Worker,之後 $0.002 / 個 / 天。計數規則(官方):

用法計為
同 ID 同程式碼、重複呼叫1 個
同程式碼、不同 ID每個 ID 算 1 個
同 ID、不同程式碼每個版本算 1 個
不給 ID,或用 .load(code)每次呼叫算 1 個

最後一列是災難。load() 每次都是全新的一次計費。

所以 get(id, cb)id 不只是快取鍵,它是計費鍵。 正確的 id 應該是「租戶 + 程式碼版本」:

env.LOADER.get(`t:${tenantId}:v:${codeVersion}`, () => ({ ... }))

crypto.randomUUID() 當 id,或每次都算一次程式碼的 hash 當 id 而程式碼其實沒變,都會讓帳單線性成長。

另外注意:beta 期間的免費期已經結束 ——「Starting May 26, 2026, Dynamic Workers created daily are billed as part of Dynamic Workers pricing」。

狀態:2026-03-24 進入 open beta,僅 Workers Paid。我找不到任何 GA 公告;不過現行的文件頁上也沒有 beta 橫幅了,所以狀態在文件裡是模糊的。最後一次正式的狀態聲明是 open beta。


{
"dispatch_namespaces": [
{ "binding": "DISPATCHER", "namespace": "production", "remote": true }
]
}
const worker = env.DISPATCHER.get(
customerWorkerName,
{}, // args(官方所有範例都給空物件)
{ limits: { cpuMs: 50, subRequests: 10 } },
);
const res = await worker.fetch(request);

注意 subRequests 的大寫 R。 實測型別定義:

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

Cloudflare 的設定檔大多用 snake_case(compatibility_datenew_sqlite_classes),這裡卻是 camelCase 而且第二個字大寫。寫成 subrequestssub_requests 都不會報錯,只會靜默不生效

官方 dynamic-dispatch 頁展示的就是這個:

try {
const worker = env.DISPATCHER.get(name);
return await worker.fetch(request);
} catch (e) {
if ((e as Error).message.startsWith("Worker not found")) {
return new Response("", { status: 404 });
}
if ((e as Error).message.includes("CPU time limit")) {
return new Response("", { status: 429 });
}
throw e;
}

沒有型別化的錯誤類別,字串比對是官方示範的做法。這很脆弱,但目前沒有替代方案。

這是很多人第一次做會做錯的。官方原文:

「你所有客戶的 Worker 都應該放在單一 namespace(例如 production)。不要為每個客戶建立一個 namespace。

唯一建議的第二個 namespace 是 staging,用來安全測試變更。

隔離靠的是 Worker 的名字與 dispatch 時的參數,不是 namespace。

實測不加 remote: true 直接呼叫:

{ "threwName": "Error", "threw": "Binding DISPATCHER needs to be run remotely" }

而且 —— 這一點很重要 —— 這個錯誤讓 33.3 那段 404 處理邏輯在本機根本測不到

{ "threw": "Binding DISPATCHER needs to be run remotely", "matchesWorkerNotFound": false }

e.message.startsWith("Worker not found") 在本機永遠是 false。你的 404 分支在本機是死程式碼。

官方 local-development 頁只描述了 remote: true 這一條路:

「這會告訴你在本機執行的 dispatch Worker 去連接遠端的 production namespace。」

沒有任何一頁說明純本機模式會怎樣(沒有限制清單、沒有說會失敗)—— 上面那個錯誤訊息是我實測出來的。

「Workers for Platforms Paid plan 是每月 $25」,含 2,000 萬次請求、6,000 萬 CPU ms、1,000 個 script

超出:每百萬請求 +$0.30、每百萬 CPU ms +$0.02、每多一個 script +$0.02

一個很值得記住的計費特性:

「Workers for Platforms 在整條鏈上(dispatch → user → outbound)只收 1 次請求費用」,但 CPU time 三段都會計。Subrequest 不計費。

所以 dispatch Worker 本身寫得薄一點是有價值的 —— 它的 CPU 也算錢。

第三個參數的 outbound 讓你攔截客戶 Worker 的所有出站請求:

"dispatch_namespaces": [{
"binding": "DISPATCHER", "namespace": "production",
"outbound": { "service": "my-outbound-worker", "parameters": ["params_object"] }
}]
env.DISPATCHER.get(name, {}, { outbound: { params_object: contextFromDispatcher } });

這是 Workers for Platforms 對應 Dynamic Workers globalOutbound 的機制。 兩個產品在這一點上的設計思路是一致的:出站流量必須可以被平台方攔截。


本章刻意糾正三件事:

(一)「Dynamic Workers 已經 GA」 —— 沒有。最後一次正式狀態聲明是 2026-03-24 的 open beta,我找不到 GA 公告。

(二)「Dynamic Workflows 是 Workflows v2」 —— 不是。見 33.5。

(三)「Workers for Platforms 被 Dynamic Workers 取代了」 —— 沒有任何根據。兩份文件互不引用,兩者解決的是不同時間點的問題(deploy time vs runtime)。


第 21 章的 Workflows 有一個對平台型產品致命的限制:workflow class 必須存在於你部署的程式碼裡class_name 在部署時就綁死了)。

這對「每個客戶帶自己的程式碼」的平台是不可行的 —— engine 在幾小時後醒來要繼續執行時,得能重新進入正確那個租戶的程式碼。

@cloudflare/dynamic-workflows 就是解這件事的。官方描述:

「一個 Workflow 通常在部署時綁定到單一的靜態 class_name」,而目標是「從單一 dispatcher worker,為每個租戶執行不同的 Cloudflare Workflow 實作」。

兩個主要 API:wrapWorkflowBinding()createDynamicWorkflowEntrypoint()

⚠️ 一個很容易漏的步驟:DynamicWorkflowBinding 必須從你的 Worker Loader Worker 再匯出一次。

export { DynamicWorkflowBinding } from "@cloudflare/dynamic-workflows";

漏掉的話 engine 找不到入口。

狀態:MIT 授權的函式庫(repo cloudflare/dynamic-workflows),2026-05-01 的 changelog 發表。它是一個函式庫,不是一個計費產品,而且 README 裡沒有任何 experimental / beta / production-ready 的穩定性標示。

所以它不是「Workflows v2」。 底層還是第 21 章那個 Workflows,這只是一層讓 workflow 實作可以動態解析的包裝。


實測 WorkerStub.getDurableObjectClass() 的存在,指向另一個能力:

this.ctx.facets.get(name: string, callback: () => FacetStartupOptions): Fetcher

這屬於 Dynamic Workers,不是 Durable Objects 的核心功能。 文件在 /dynamic-workers/usage/durable-object-facets/,不在 /durable-objects/。這個歸屬很容易搞錯。

官方:「Durable Object Facets 讓你從 Dynamic Worker 載入一個 Durable Object 類別,並把它當作你自己的 Durable Object 的子物件來執行。」

三層模型:

你的 supervisor DO
└─ 從 Dynamic Worker 動態載入的 DO 類別
└─ facet 實例(每個有自己隔離的 SQLite)

注意 ctx.facets.get(name, cb)LOADER.get(id, cb)同一個懶惰 callback 模式 —— callback 只在 facet 尚未啟動或已 hibernate 時執行。所以 33.2 那條「callback 必須冪等」的規則在這裡同樣適用。

每個 facet 有自己隔離的 SQLite —— 這正是第 15 章那個「每個 DO 一顆資料庫」再往下一層。對「每個租戶一個 AI 生成的小應用,各自有狀態」這種需求非常合適。

文件上沒有任何 beta / experimental 標示,也沒有提到需要 compatibility flag


33.7 LinkForge:租戶自訂重導向邏輯

Section titled “33.7 LinkForge:租戶自訂重導向邏輯”

需求:讓租戶提供一小段 JS,在重導向前決定要不要改目標 URL(A/B 測試、地區分流、時段限制)。

這是 Dynamic Workers 的場景(程式碼是使用者在你的 UI 上貼的,不是他們 deploy 的 Worker)。

app.get("/:slug", async (c) => {
const link = await resolve(c.env, c.req.param("slug"));
if (!link) return c.notFound();
// 沒有自訂邏輯就走快路徑,不要付 Dynamic Worker 的錢
if (!link.hookVersion) return c.redirect(link.url, 302);
const code = await c.env.CODE.get(`hook:${link.tenantId}:${link.hookVersion}`);
if (!code) return c.redirect(link.url, 302);
// id 同時是快取鍵與計費鍵:租戶 + 版本
const stub = c.env.LOADER.get(`hook:${link.tenantId}:${link.hookVersion}`, () => ({
compatibilityDate: "2026-07-24",
mainModule: "hook.js",
modules: { "hook.js": code },
env: {
// 只給它需要的資料,不給任何 binding
TENANT_ID: link.tenantId,
DEFAULT_URL: link.url,
},
globalOutbound: null, // 租戶程式碼不能連外
limits: { cpuMs: 20, subRequests: 0 },
}));
let target = link.url;
try {
const res = await stub.getEntrypoint().fetch("http://hook/", {
method: "POST",
body: JSON.stringify({
country: c.req.raw.cf?.country ?? null,
ua: c.req.header("user-agent") ?? "",
hour: new Date().getUTCHours(),
}),
headers: { "content-type": "application/json" },
});
const out = (await res.json()) as { url?: string };
// 一定要驗證回傳值 —— 租戶程式碼可以回傳任何東西
if (out.url && isSafeRedirect(out.url, link.tenantId)) target = out.url;
} catch (e) {
console.warn("hook failed", link.tenantId, String(e)); // 失敗就用預設值
}
return c.redirect(target, 302);
});

五個設計點,每一個都對應本章的一項實測:

  1. 沒有 hook 就完全不碰 loader —— 計費是按 unique Worker 算的,沒必要為沒有自訂邏輯的租戶付錢。
  2. id = 租戶 + 版本 —— 33.2 的計費規則。版本沒變就不會產生新的計費單位。
  3. env 只放資料,不放 binding —— 33.2 的能力邊界。租戶程式碼看不到 KV、D1、任何東西。
  4. globalOutbound: null + subRequests: 0 —— 雙重保險。但記住 33.2 的實測:limits 本機不生效,所以本機看起來正常不代表 production 安全。
  5. 回傳值必須驗證。 沙箱保證租戶程式碼不能做壞事,但不保證它回傳的東西是好的isSafeRedirect() 要擋掉 javascript:、擋掉指向其他租戶網域的 URL、擋掉 open redirect。

最後這一點值得強調:沙箱管的是「它能做什麼」,不是「它說了什麼」。 從沙箱回來的資料和使用者輸入是同一個信任等級。

如果需求變成「租戶自己用 wrangler deploy 一個完整的 Worker 上來」,那才切換到 Workers for Platforms。


#結論影響
1兩個產品的官方文件互不引用,沒有任何比較頁「取代」的說法沒有根據
2WorkerLoader 是真正的類別(get/load 在 prototype 上)不是 RPC proxy(對照第 23、30、32 章)
3LOADER.get() 同步回傳 WorkerStubisPromise: false),callback 尚未執行取得 stub 是零成本的
4WorkerStubgetEntrypoint()getDurableObjectClass()後者是 DO Facets 的基礎
5官方明載 callback 可能被呼叫任意次數必須冪等,不可放副作用
6實測載入的 Worker Object.keys(env) 只有你傳進去的那些能力導向安全模型,不是黑名單
7globalOutbound: nullfetch 丟「not permitted to access the internet」網路完全封死
8同一沙箱內 evalWebAssembly.compile 也都被禁但那是平台性質(第 27 章),非 Dynamic Workers 特有
9WorkerLoaderModule 支援 js/cjs/text/data/json/py/wasmPython 模組可載入
10limits: { cpuMs: 50 } 本機完全不生效(5 秒 CPU 迴圈跑完)沙箱隔離本機測得出,資源上限測不出
11Dynamic Workers 文件裡「local」「wrangler dev」「miniflare」一次都沒出現本機行為完全無文件
12計費規則:不給 ID 或用 .load()每次呼叫算一個 unique Workerid 是計費鍵,不只是快取鍵
13beta 免費期已於 2026-05-26 結束
14Dynamic Workers 最後一次正式狀態聲明是 open beta(2026-03-24);找不到 GA 公告現行文件也沒有 beta 橫幅,狀態在文件裡模糊
15DynamicDispatchLimitscpuMssubRequests(大寫 R)拼錯會靜默不生效
16官方示範用 e.message.startsWith("Worker not found").includes("CPU time limit")沒有型別化錯誤類別
17本機沒有 remote: true 時丟 Binding DISPATCHER needs to be run remotely
18由 17 推得:startsWith("Worker not found") 在本機永遠 false404 分支在本機是死程式碼
19官方明文:所有客戶共用單一 namespace,不要一客戶一 namespace
20WfP $25/月含 20M 請求、60M CPU ms、1,000 script;超出每個 script +$0.02
21WfP 整條鏈只收 1 次請求費,但 CPU 三段都計dispatch Worker 要寫薄
22Dynamic Workflows 是 MIT 授權的函式庫,不是計費產品,README 無穩定性標示不是「Workflows v2」
23DynamicWorkflowBinding 必須從 Worker Loader Worker 再匯出很容易漏
24DO Facets 的文件在 /dynamic-workers/,不在 /durable-objects/歸屬容易搞錯
25每個 facet 有自己隔離的 SQLite;ctx.facets.get(name, cb) 是同一個懶惰 callback 模式冪等規則同樣適用

  1. get() 的 callback 裡放一個 KV 寫入當作「計次」,然後用不同的 id 與相同的 id 各打 20 次,觀察計數與你的預期差多少 —— 這是 33.2 那條「callback 不可有副作用」的實證。
  2. 部署一個 limits: { cpuMs: 20 } 的 dynamic Worker 跑一段無窮迴圈,比較本機(跑完)與 production(拋例外)的差異。
  3. globalOutboundnull 換成一個你自己寫的 WorkerEntrypoint,記錄租戶程式碼嘗試的每一個出站請求。
  4. subrequests(小寫 s)取代 subRequests,確認它被靜默忽略。
  5. 為 LinkForge 的 hook 寫一個惡意的租戶程式碼:回傳 javascript:alert(1)、回傳指向別的租戶的 URL、回傳一個 10 MB 的字串。確認 isSafeRedirect() 全部擋下 —— 沙箱不會幫你做這件事。

Workers for Platforms

Dynamic Workers

第 33 章結束 Part 6。下一章起進入 Part 7 — AI:第 34 章 Workers AI,平台上唯一真正有 GPU 的地方(對照第 29 章的 Containers 完全沒有)。