Bindings:`env` 是整個平台的介面
這篇要解決的問題
Section titled “這篇要解決的問題”第 2 篇教了怎麼在 wrangler.jsonc 裡設定 binding,第 3 篇說了 env 是三個 handler 參數之一。這一篇要回答為什麼它長這樣。
因為如果你把 env 當成「Cloudflare 版的 process.env」,你會錯過整個設計裡最重要的東西 —— 而且會寫出比實際需要更不安全的程式碼。
三個具體問題:
env.DB和DATABASE_URL差在哪? 差在前者不是字串。import { env } from "cloudflare:workers"和 handler 參數的env是同一個嗎? 實測:不是同一個物件。- 開了
nodejs_compat之後,你的 secret 會出現在哪裡? 答案可能會讓你想改設定。
binding 是能力,不是連線字串
Section titled “binding 是能力,不是連線字串”傳統後端連資料庫長這樣:
DATABASE_URL=postgres://user:pa55w0rd@db.internal:5432/prod這個字串裡藏了四件事:位置(主機、port)、身分(帳密)、授權(這組帳密能做什麼)、網路可達性(你的 process 得連得到 db.internal)。四件事全部混在一起,而且全部是明文、可複製、可外洩的。
Workers 的 binding 長這樣:
const { results } = await env.DB.prepare("SELECT * FROM links WHERE slug = ?") .bind(slug) .all();env.DB 不是字串,是一個物件。你的程式碼裡沒有主機、沒有密碼、沒有 port、沒有連線池設定。
三個直接後果:
① 沒有 credential 可以外洩。 沒有密碼寫在設定裡、沒有密碼在記憶體裡、console.log(env.DB) 也印不出任何可以拿去別處用的東西。
② 沒有網路設定。 不需要 VPC、security group、IP 允許清單。存取權來自 Worker 的身分本身。
③ 無法從外部偽造或列舉。 binding 是部署時由平台注入的。攻擊者就算完全控制了送進來的 request,也沒辦法讓 env 多出一個原本不存在的 binding。
這是 capability-based security:持有那個物件本身就是授權。你的 Worker 能做的事,精確等於你在 wrangler.jsonc 裡給它的那幾個 binding —— 不多不少。這也是為什麼第 33 篇跑使用者提供的程式碼時,「只把該給的 binding 放進 env」就是完整的沙箱策略。
順帶一提,這個設計讓「最小權限」變成預設值而不是紀律。傳統架構要達到同樣效果,得靠 IAM policy、VPC 切分、密鑰輪替三層工程;這裡就是「沒寫進設定檔就是沒有」。
完整的 binding 目錄
Section titled “完整的 binding 目錄”要看有哪些 binding,最可靠的來源不是文件頁,是 Wrangler 自己的 JSON schema:
cat node_modules/wrangler/config-schema.json | jq '.properties | keys'2026 年 7 月的完整清單(30+ 種),按用途分組:
| 分類 | 設定鍵 |
|---|---|
| 設定與機密 | vars、secrets、secrets_store_secrets、version_metadata |
| 儲存 | kv_namespaces、d1_databases、r2_buckets、durable_objects、hyperdrive、vectorize |
| 訊息與工作流 | queues、workflows、pipelines |
| 運算與組合 | services、dispatch_namespaces、containers、worker_loaders、assets |
| AI | ai、ai_search、ai_search_namespaces |
| 媒體 | images、stream、media、browser |
| 可觀測性 | analytics_engine_datasets、tail_consumers、streaming_tail_consumers |
| 網路與安全 | ratelimits、mtls_certificates、vpc_services、send_email |
其中有四個很少出現在教學裡,但相當實用:
ratelimits —— runtime 內建的限流器
{ "ratelimits": [ { "name": "LIMITER", "namespace_id": "1001", "simple": { "limit": 5, "period": 10 } } ]}const { success } = await env.LIMITER.limit({ key: userId });if (!success) return new Response("Too many requests", { status: 429 });不需要 KV、不需要 DO、不需要外部服務,而且本機開發就能跑。實測 limit 5 / period 10:
$ for i in $(seq 7); do curl -s -o /dev/null -w "%{http_code} " localhost:8787/limit; done200 200 200 200 200 429 429第 41 篇會比較它和 DO-based 限流的取捨(簡述:這個是每個 colo 獨立計數的近似值,要精確全域計數才需要 DO)。
version_metadata —— 知道自己是哪個版本
{ "version_metadata": { "binding": "VERSION" } }{ "id": "19cc8aac-1016-4c42-9f66-acc9d6283610", "tag": "", "timestamp": "2026-07-28T04:56:31.361Z" }把 VERSION.id 塞進每一筆 log 和每一個錯誤回報,漸進式發佈(第 40 篇)出問題時就能立刻分辨是哪個版本在噴錯。成本是零,價值在出事那天才會顯現。
secrets_store_secrets —— 帳號層級的共用 secret
{ "secrets_store_secrets": [ { "binding": "STRIPE_KEY", "store_id": "<store>", "secret_name": "stripe-live" } ]}和 wrangler secret put 的差別:後者是每個 Worker 一份拷貝,輪替金鑰要逐一更新;Secrets Store 是集中存放、多個 Worker 共用引用,輪替一次就好。多 Worker 的專案值得一開始就用它。
vpc_services —— 連進你自己的私有網路
{ "vpc_services": [{ "binding": "INTERNAL_API", "service_id": "<id>" }] }用來從 Worker 打自架的內部服務。這是把 binding 的 capability 模型延伸到私有網路的做法 —— 一樣沒有 IP 允許清單要維護。
兩個 env,而且不是同一個物件
Section titled “兩個 env,而且不是同一個物件”存取 binding 有兩條路:
// A. handler parameterexport default { async fetch(request, env, ctx) { return new Response(env.APP_NAME); },};
// B. module-level importimport { env } from "cloudflare:workers";直覺上會以為 B 只是 A 的別名。實測:
$ curl -s localhost:8787/identity{ "sameObject": false, "topLevelKeys": ["API_KEY","APP_NAME","CACHE","LIMITER","PUBLIC_URL","VERSION"], "handlerKeys": ["API_KEY","APP_NAME","CACHE","LIMITER","PUBLIC_URL","VERSION"]}鍵完全相同,但是兩個不同的物件。 所以 topLevelEnv === env 是 false,任何依賴引用相等的程式碼(放進 Map 當 key、用 === 比對)都會出乎意料。
該用哪一個?
handler 參數 env | import { env } from "cloudflare:workers" | |
|---|---|---|
| 讀 vars / secrets | ✅ | ✅(模組頂層就能讀) |
| 呼叫 binding 做 I/O | ✅ | ⚠️ 只能在請求上下文內 |
| 深層 util function 取用 | 要一路傳下去 | ✅ 直接 import |
| 測試時替換 | 容易(傳參數) | 較難 |
實務建議:
- 設定值(例如
env.PUBLIC_URL、feature flag)用 import 版本,省去把env一路傳到第五層函式的痛苦。 - I/O(KV、D1、Queue)用 handler 參數,因為那是明確的依賴,測試時好替換。
⚠️ 再強調一次第 1 篇的規則:import 版本讓你在模組頂層讀取值,但不允許在頂層做 I/O。
這個 import 也解釋了為什麼 React Router v8 能拿掉
AppLoadContext(第 24 篇)—— loader 直接import { env }就好,不需要框架幫忙把env傳進來。
vars / secrets / Secrets Store
Section titled “vars / secrets / Secrets Store”三層,用途不同:
| 存哪 | 可見性 | 用途 | |
|---|---|---|---|
vars | wrangler.jsonc(進版控) | dashboard 看得到、型別檔看得到值 | 非機密設定 |
| secret | Cloudflare(wrangler secret put) | 寫入後讀不回來 | 該 Worker 專用的機密 |
| Secrets Store | 帳號層級 | 集中管理、可稽核 | 多 Worker 共用的機密 |
本機的 secret 放 .dev.vars(務必進 .gitignore)。
secrets.required:宣告式的 secret 契約
Section titled “secrets.required:宣告式的 secret 契約”這個功能很少被提到。在 wrangler.jsonc 裡:
{ "secrets": { "required": ["API_KEY", "WEBHOOK_SIGNING_KEY"] } }它做兩件事:
① 產生型別,而且不需要 .dev.vars。 實測把 .dev.vars 移走再跑 wrangler types:
interface __BaseEnv_CloudflareBindings { CACHE: KVNamespace; LIMITER: RateLimit; VERSION: WorkerVersionMetadata; APP_NAME: "ch04-bindings"; PUBLIC_URL: "https://example.com"; API_KEY: string; // ← 只靠 secrets.required 就有了}② 把「這個 Worker 需要哪些 secret」變成 repo 裡看得到的宣告,而不是散落在部署腳本和某人的記憶裡。新人 clone 下來看設定檔就知道要準備什麼。
⚠️ 官方文件把它描述為部署時的必要性檢查。我在沙箱裡無法驗證真實 deploy 的行為(
--dry-run不會檢查,因為那需要查詢帳號已存的 secret)。型別生成的部分我實測過,部署時的強制行為請自行驗證。
🔴 nodejs_compat 會把 secret 送進 process.env
Section titled “🔴 nodejs_compat 會把 secret 送進 process.env”Workers 沒有 process 全域物件 —— 除非開了 nodejs_compat。實測,不開的時候:
$ curl -s localhost:8787/procenv{"hasProcess":false,"appName":null,"apiKey":null}加上 "compatibility_flags": ["nodejs_compat"] 之後:
$ curl -s localhost:8787/procenv{"hasProcess":true,"appName":"ch04-bindings","apiKey":"<present>"}注意 apiKey —— process.env 裡不只有 vars,secret 也在裡面。
好處是很多 npm 套件靠 process.env.SOMETHING 讀設定,這讓它們能直接運作。壞處是:
開了
nodejs_compat,你 bundle 裡的任何一行第三方程式碼都能讀到你所有的 secret。
一個在錯誤處理時 dump process.env 的日誌套件、一個做環境偵測的 SDK,都會把你的 API key 寫進 log。這在 Node.js 世界一直都是這樣,只是 Workers 的預設本來更安全 —— 開這個 flag 等於主動放棄那層保護。
不是叫你別開(很多場景非開不可,例如第 28 篇的 Hyperdrive)。而是:開了就要意識到 secret 的暴露面變了,並且對依賴多一分審視。
缺少的 binding 是 undefined,不是錯誤
Section titled “缺少的 binding 是 undefined,不是錯誤”$ curl -s localhost:8787/missing{"value":null}存取一個不存在的 binding 不會拋錯。你會拿到 undefined,然後在下一行 env.NOPE.get(...) 的地方收到 TypeError: Cannot read properties of undefined。
這正是第 2 篇那個「環境不繼承 binding」的坑會如此致命的原因:wrangler deploy --env staging 只給 warning,部署成功,然後在第一個真實請求時炸掉。
兩道防線:
wrangler types --check進 CI(第 2 篇)。- 部署後跑 smoke test,實際打到每個會用到 binding 的路徑(第 40 篇)。
動手做:binding 探針
Section titled “動手做:binding 探針”完整程式碼:
examples/ch04-bindings/
cd examples/ch04-bindingsnpm installecho 'API_KEY="local-dev-key"' > .dev.varsnpm run cf-typegennpm run dev# 兩個 env 是不是同一個物件?curl -s localhost:8787/identity
# process.env 存在嗎?curl -s localhost:8787/procenv
# 我是哪個版本?curl -s localhost:8787/version
# 限流器(第 6 次會變 429)for i in $(seq 7); do curl -s -o /dev/null -w "%{http_code} " localhost:8787/limit; done; echo
# 不存在的 bindingcurl -s localhost:8787/missing練習:把 wrangler.jsonc 裡 nodejs_compat 那一行的註解拿掉,重跑 /procenv。看著你的 API key 出現在 process.env 裡,然後決定這個專案要不要開這個 flag。
接進 LinkForge
Section titled “接進 LinkForge”LinkForge 有五個 Worker,每個的 binding 需求都不同。這一篇定下三個規則。
規則一:每個 Worker 只拿它需要的 binding
Section titled “規則一:每個 Worker 只拿它需要的 binding”// apps/redirector — hot path. Deliberately minimal.{ "kv_namespaces": [{ "binding": "LINKS", "id": "..." }], "queues": { "producers": [{ "binding": "CLICKS", "queue": "click-events" }] }, "version_metadata": { "binding": "VERSION" }, "ratelimits": [{ "name": "ABUSE", "namespace_id": "1", "simple": { "limit": 100, "period": 60 } }]}// apps/api — needs the database, redirector does not.{ "d1_databases": [{ "binding": "DB", "database_name": "linkforge", "database_id": "..." }], "kv_namespaces": [{ "binding": "LINKS", "id": "..." }], "durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "LinkCounter" }] }, "r2_buckets": [{ "binding": "EXPORTS", "bucket_name": "linkforge-exports" }], "version_metadata": { "binding": "VERSION" }, "secrets": { "required": ["JWT_SECRET", "RESEND_API_KEY"] }}redirector 拿不到 D1。就算它被攻破,攻擊者也碰不到使用者資料表 —— 這不是靠程式碼裡的檢查,是靠它的 env 裡根本沒有那個物件。這就是 capability 模型的實際價值。
規則二:version_metadata 每個 Worker 都要,而且進每一筆 log
Section titled “規則二:version_metadata 每個 Worker 都要,而且進每一筆 log”function log(event: Record<string, unknown>, env: CloudflareBindings) { console.log(JSON.stringify({ ...event, v: env.VERSION.id, ts: Date.now() }));}第 39、40 篇會用到。現在多寫這一行,之後漸進式發佈出事時就能一眼分辨版本。
規則三:secret 用 secrets.required 宣告,不要只存在部署腳本裡
Section titled “規則三:secret 用 secrets.required 宣告,不要只存在部署腳本裡”每個 app 的 wrangler.jsonc 都列出它需要的 secret。這份清單同時是文件、是型別、是新人的 checklist。
dev(本機)/ staging / production 三個。每個環境的每個 binding 都要完整重寫(第 2 篇的坑)。第 26 篇會談用腳本生成設定來消除這個重複。
本篇交付物:五個 app 的完整 wrangler.jsonc binding 規劃、共用的 log() helper、secrets.required 清單。
① 把 secret 寫進 vars
vars 會出現在 dashboard,而且會以 literal type 出現在生成的型別檔裡(連值都在)。secret 一律走 wrangler secret put / .dev.vars / Secrets Store。
② 開了 nodejs_compat 卻沒意識到 secret 進了 process.env
bundle 裡任何第三方程式碼都讀得到。
③ 以為兩個 env 是同一個物件
鍵相同,但 === 是 false。
④ 期待缺少的 binding 會報錯
它是 undefined。搭配第 2 篇的「環境不繼承 binding」,這是 staging 環境最常見的爆炸原因。
⑤ 在模組頂層用 binding 做 I/O
import { env } from "cloudflare:workers";const config = await env.CACHE.get("config"); // ❌ Disallowed in global scope讀 vars/secrets 可以,I/O 不行。
⑥ 一份 Env 介面給所有 Worker 共用
binding 是 per-Worker 的。共用一份介面等於騙過型別系統,然後在 runtime 拿到 undefined。每個 Worker 跑自己的 wrangler types。
⑦ 用 Service Environments
已棄用。用 Wrangler Environments(env.<name>)。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- **binding 是能力不是連線字串。**沒有 credential、沒有網路設定、無法從外部偽造 —— 最小權限是預設值,不是紀律。
- **
nodejs_compat會把 secret 放進process.env。**bundle 裡每一行第三方程式碼都讀得到。 - **缺少的 binding 是
undefined而不是錯誤。**這就是為什麼型別檢查要進 CI、smoke test 要打到每個 binding。
- Bindings 總覽
- Secrets 與 Secrets Store
- Rate Limiting binding
- Version metadata binding
node_modules/wrangler/config-schema.json—— binding 目錄最權威的來源
下一篇:05. 用 Hono 打好 API 骨架 —— routing、middleware、型別安全驗證,以及 Hono 的 Env 和 Wrangler 的 Env 撞名該怎麼解。