最近在做 US National Parks Passport scratchbook 時,我們需要從手機 app 讀取美國國家公園管理局(NPS)的 alerts 和 visitor centers。NPS Developer API 有 API key,也有每小時的使用量限制;把 key 放進 mobile client,等於交給每一位安裝 app 的人。
因此我們在 Cloudflare Worker 放了一層很小的 proxy:client 只呼叫自己的 Worker,Worker 從 secret binding 取得 NPS_API_KEY,再向官方 API 請求。它也嘗試把成功回應快取 30 分鐘,讓同一個 data center 的後續 request 有機會直接回傳,減少重複打上游。
最初版本是 JavaScript,功能沒有問題;但把它搬到 TypeScript 後,才發現這種小小的 serverless 程式正是學 TypeScript 的好材料。它的型別不是為了讓程式碼看起來正式,而是在流量真的抵達 edge 前,把環境設定、Web API 和錯誤路徑說清楚。
為什麼 serverless Worker 特別適合 TypeScript?
Worker 不需要管理伺服器,但不代表它沒有 production 風險。相反地,部署後的例外會在 edge 發生:某個 secret 沒有綁定、把 request 的屬性拼錯、或把不該快取的上游錯誤存進 cache,都可能直接影響使用者。
TypeScript 不會取代測試,也不會阻止 NPS API 在網路上失敗;它處理的是另一類問題:在執行前檢查程式對資料和平台 contract 的假設是否一致。對這支 Worker 而言,這包括:
- 程式碼是否以正確的名稱讀取
env的 secret? fetchhandler 是否收到正確的Request、Env與ExecutionContext?- 非同步快取工作是否交給
ctx.waitUntil()? catch裡的值是否能安全轉成可回傳的錯誤訊息?
先替 Worker 的環境命名
在 JavaScript 裡,env.NPS_API_KEY 可以直接讀;如果 secret 名稱打成 NPS_APIKEY,通常只能等到某個請求走進 production 才發現是 undefined。TypeScript 的第一步很單純:用 interface 描述這個 Worker 依賴的 binding。
export interface Env {
NPS_API_KEY: string;
}Env 不是執行期的 secret,也不會把 key 寫進 bundle;它是給 compiler 的契約。接著在 handler 的第二個參數標上 Env:
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// env.NPS_API_KEY 是 string
}這裡先手動宣告 interface Env,方便看出程式依賴哪些 binding;完整 Worker 則使用 Wrangler 產生的全域 Env,避免手寫型別和部署設定分開維護。
現在拼錯 env.NPS_API_KEY 的名稱就會在 npm run typecheck 被指出。這是編譯期檢查,不代表 TypeScript 讀過 Cloudflare 帳號設定。本文在 wrangler.jsonc 以 secrets.required 列出 NPS_API_KEY:wrangler types 會據此產生 Env,Wrangler 也會在本機開發與部署時檢查必要 secret 是否已設定。實際 secret 值仍要透過 wrangler secret put NPS_API_KEY 或 Cloudflare Dashboard 設定。
這支 proxy 的完整骨架
以下是 National Parks Passport Worker 專案中的 worker/src/index.ts。它提供 /alerts 與 /visitorcenters 兩個 GET endpoint,也接受 CORS 的 OPTIONS preflight;兩個 endpoint 都要求 parkCode,並把真正的 API key 留在 Worker 環境裡。
// CORS enables browser access; it does not restrict callers or prevent quota abuse.
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Accept, Content-Type",
"Access-Control-Max-Age": "86400",
};
function withCors(response: Response): Response {
const headers = new Headers(response.headers);
for (const [name, value] of Object.entries(CORS_HEADERS)) {
headers.set(name, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function jsonResponse(body: unknown, status: number): Response {
return withCors(
new Response(JSON.stringify(body), {
status,
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
},
}),
);
}
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
if (request.method === "OPTIONS") {
return withCors(new Response(null, { status: 204 }));
}
if (request.method !== "GET") {
const response = jsonResponse({ error: "Method Not Allowed" }, 405);
response.headers.set("Allow", "GET, OPTIONS");
return response;
}
const url = new URL(request.url);
const path = url.pathname;
if (path !== "/alerts" && path !== "/visitorcenters") {
return jsonResponse(
{ error: "Endpoint not found. Use /alerts or /visitorcenters" },
404,
);
}
const parkCode = url.searchParams.get("parkCode")?.trim().toLowerCase();
if (!parkCode) {
return jsonResponse(
{ error: "Missing required query parameter: parkCode" },
400,
);
}
const apiKey = env.NPS_API_KEY?.trim();
if (!apiKey) {
console.error("NPS_API_KEY binding is missing or empty.");
return jsonResponse({ error: "Worker is not configured correctly" }, 500);
}
const npsUrl = new URL(`https://developer.nps.gov/api/v1${path}`);
npsUrl.searchParams.set("parkCode", parkCode);
if (path === "/visitorcenters") {
npsUrl.searchParams.set("limit", "10");
}
// The cache key contains only this Worker URL and normalized public query data.
const cacheUrl = new URL(request.url);
cacheUrl.pathname = path;
cacheUrl.search = "";
cacheUrl.searchParams.set("parkCode", parkCode);
const cacheKey = new Request(cacheUrl.toString(), { method: "GET" });
const cache = caches.default;
try {
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
return withCors(cachedResponse);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("NPS response cache lookup failed:", message);
}
try {
const upstreamResponse = await fetch(npsUrl.toString(), {
headers: {
"X-Api-Key": apiKey,
Accept: "application/json",
"User-Agent": "ScratchbookNationalParks/1.0",
},
});
const response = withCors(upstreamResponse);
// Cache successful full responses; the Cache API cannot store 206 responses.
const shouldCache = upstreamResponse.ok && upstreamResponse.status !== 206;
response.headers.set(
"Cache-Control",
shouldCache ? "public, max-age=1800" : "no-store",
);
if (shouldCache) {
ctx.waitUntil(
cache.put(cacheKey, response.clone()).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error("NPS response cache write failed:", message);
}),
);
}
return response;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("NPS upstream request failed:", message);
return jsonResponse({ error: "Upstream NPS gateway error" }, 502);
}
},
} satisfies ExportedHandler<Env>;export default 是這個模組的預設匯出;Wrangler 會把這個物件視為 Worker 入口,並在每個 request 到達時呼叫它的 fetch()。
有幾個容易忽略的細節。上游 URL 需要包含 path,所以 /alerts 會轉成 https://developer.nps.gov/api/v1/alerts,而不是只有 API 的 base URL。apiKey 透過 X-Api-Key 標頭傳遞給 NPS API,而不是放在上游 URL 裡;這樣做能避免 secret 出現在 URL、存取記錄(access logs)或中間代理的日誌中。快取鍵使用公開的 Worker URL、路徑與正規化後的 parkCode,不包含 secret 或其他不影響上游結果的查詢參數。
這個 Worker 也處理 browser 的 CORS preflight:OPTIONS 回傳 204,且成功、錯誤與快取命中的回應都會帶上 CORS 標頭。Access-Control-Allow-Origin: * 適用於這份公開資料;若改成需要 cookie 或其他憑證的 API,就不能搭配萬用來源。
把 NPS_API_KEY 留在 Worker 只避免 app 使用者直接取得 key,不會限制誰能呼叫這個公開 endpoint。caches.default 也不是 rate limit:cache miss 仍會打到 NPS,而不同的 parkCode 可以持續製造 miss。NPS 文件列出的預設 hourly limit 是每把 key 每小時 1,000 次,實際限制可能依 API 服務而異;公開部署前應替 Worker 設定合適的 Cloudflare rate limit 或其他 abuse control,並留意 NPS 用量。NPS API 使用指南列出限額與回應中的 X-RateLimit headers。
Cloudflare 已經替我們提供的型別
Request、Response 和 ExecutionContext 都是 Workers runtime 的 API 型別。把它們直接寫在函式簽名上,閱讀程式碼時就能知道每個值的責任。
request 是 browser 送進來的 Web Request,所以可以安全使用 request.method、request.url。Response 是 handler 的回傳契約;Promise<Response> 表示這是一個非同步 handler,但最後一定要交回 HTTP 回應。
ctx 則是容易被忽略、卻很適合 edge 程式的部分:
ctx.waitUntil(
cache.put(cacheKey, response.clone()).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error("NPS response cache write failed:", message);
}),
);waitUntil() 告訴 Worker runtime:可以先把 response 回給使用者,但請讓 cache.put() 在背景繼續完成。response.clone() 也很重要,因為 response body 是 stream;一份要交給 client,另一份才交給 cache,不能共用同一個已被讀取的 body。若 cache.put() 拋出例外,catch 會在背景記錄 log;不過 Cache API 也可能完成呼叫卻沒有留下快取項目,因此不能把呼叫成功當成儲存保證。
satisfies:驗證 contract,又不犧牲推論
檔案結尾的這一行是這次 migration 最值得記住的語法:
} satisfies ExportedHandler<Env>;satisfies 是 TypeScript 4.9 引入的 operator。它會檢查左邊的 Worker 物件是否符合右邊的 ExportedHandler<Env> contract;例如 handler 名稱、fetch 的參數和回傳值不對,compiler 會報錯。
ExportedHandler 是 Cloudflare Workers 為預設匯出物件提供的型別 contract。尖括號裡的 Env 是泛型參數,代表這支 Worker 的 bindings;本例中的 Env 由 wrangler types 從 wrangler.jsonc 產生,因此 fetch 的第二個參數會包含 NPS_API_KEY。這個型別只在 typecheck 時檢查程式碼;必要 secret 是否已設定,則由 Wrangler 的設定與部署檢查負責。
// 讀作:使用 Env bindings 的 Cloudflare Worker handler 型別。
type NpsWorker = ExportedHandler<Env>;它和把整個物件直接註記成 ExportedHandler<Env> 的差別,在於 satisfies 仍保留左邊物件本身推論出的精確型別,不會把它整體「變寬」成 annotation 的型別。對只有一個 fetch 的 Worker 看起來差異不大;但當物件還有自訂欄位、或想保留更精確的回傳值推論時,這個特性很有用。
換句話說,它像是在說:「請確認這是合法的 Cloudflare Worker handler,但不要抹掉我這個物件原本知道的細節。」
unknown 不是麻煩,而是 catch 的安全欄杆
strict TypeScript 不應假設所有被拋出的值都是 Error。JavaScript 允許 throw "network down"、throw 42,甚至丟出任意 object。因此 catch 到的值應該是 unknown:
catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("NPS upstream request failed:", message);
return jsonResponse({ error: "Upstream NPS gateway error" }, 502);
}在 err instanceof Error 的 true 分支,TypeScript 知道可以讀 err.message;其餘情況先經過 String(err),log 裡仍有可診斷的訊息,不會因為硬讀不存在的 .message 再引發第二個例外。對外只回傳通用的 502 訊息,避免把 runtime 或網路細節交給呼叫端。這種根據條件把較寬型別收窄到可安全操作範圍的過程,叫做 type narrowing。
快取真正保護的是成功回應
這支 proxy 的目的之一是保護 NPS API 的 hourly rate limit。直覺上,拿到上游回應後就 cache.put() 很合理,但 production 裡有個危險的反例:NPS 暫時回 403、429 或 5xx 時,若我們照樣快取,接下來 30 分鐘的每個使用者都只會讀到那個失敗。
所以快取必須放在成功條件後面:
const shouldCache = upstreamResponse.ok && upstreamResponse.status !== 206;
response.headers.set(
"Cache-Control",
shouldCache ? "public, max-age=1800" : "no-store",
);
if (shouldCache) {
ctx.waitUntil(
cache.put(cacheKey, response.clone()).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error("NPS response cache write failed:", message);
}),
);
}Response.ok 代表 HTTP status 在 200 到 299;不過 Cloudflare Cache API 不接受 206 Partial Content,所以這支 Worker 另外排除 206。失敗回應使用 Cache-Control: no-store,不會被 Worker cache 寫入,也不會要求 browser 或中間 cache 保留。這裡的取捨是刻意的:下一個 request 仍可能再打上游。若未來需要處理短暫 outage,可以另行設計 stale cache 或短 TTL 的 error policy;不要不加區分地把錯誤快取 30 分鐘。
還有一個 edge cache 的範圍問題:caches.default 的項目只存在處理這個 request 的 data center,不會自動複製到其他 Cloudflare edge location,也不能搭配 tiered cache。因此它能減少同一個 data center 後續請求打到 NPS 的次數,不代表同一區域或全球的請求都能共用這份快取。cache.put() 也不保證每次都成功儲存;30 分鐘是這支程式要求的 TTL。Cache-Control: public, max-age=1800 同時允許 browser 與中間 cache 保存成功回應,這裡的資料是公開資訊。詳情可參考 Cloudflare Cache API 文件。
測試會驗證型別以外的事
TypeScript 可以檢查程式和測試程式是否符合型別 contract,但它不會執行 Worker,也不會證明每個分支真的回傳正確結果。這裡用兩種測試補上不同的觀察範圍:unit test 隔離 Worker handler 的邏輯;E2E test 則透過 HTTP 呼叫實際部署的 Worker,確認它和 NPS API 接在一起後的行為。
Unit test:隔離 handler 的分支
test/proxy.spec.ts 用 Vitest 直接呼叫匯出的 worker.fetch()。測試會用 spy 假造上游 fetch(),也會以記憶體中的 Map 模擬 caches.default;ExecutionContext 的 mock 則會收集 waitUntil() 交付的背景工作,讓測試可以等 cache write 完成後再檢查結果。這樣不需要真的呼叫 NPS,就能穩定檢查 Worker 自己的處理邏輯。
例如,這個測試確認 secret 走 X-Api-Key header、正規化後的 parkCode 進入上游 URL,以及成功 response 會排入快取:
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ data: [] }), { status: 200 }),
);
const { ctx, pendingPromises } = createMockContext();
const request = new Request(
"https://national-parks.simplypatrick.workers.dev/alerts?parkCode=%20YOSE%20",
);
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(200);
const [, init] = fetchSpy.mock.calls[0];
expect(new Headers(init?.headers).get("X-Api-Key")).toBe(env.NPS_API_KEY);
await Promise.all(pendingPromises);
expect(mockCache.put).toHaveBeenCalledTimes(1);目前的 unit tests 也檢查 CORS preflight、非支援的 HTTP method 和路徑、缺少或空白的 parkCode、未設定的 API key、兩個 NPS endpoint 的 query、cache hit、上游錯誤與 206 不寫入快取、cache lookup 失敗後仍繼續呼叫上游,以及網路錯誤時回傳通用的 502。它們很適合快速確認輸入驗證、安全邊界和錯誤分支;由於上游與 cache 都是 mock,這些測試本身不會驗證 Cloudflare 的實際 edge 行為。
E2E test:走過實際部署路徑
test/proxy.e2e.spec.ts 用標準 fetch() 對 Worker URL 發 HTTP request。預設目標是正式 Worker;設定 WORKER_URL 可以改指測試部署或本機 wrangler dev。測試會以十個不同區域的 park code 查 alerts 與 visitor centers,也會檢查大小寫和前後空白的正規化,以及 OPTIONS、缺少參數、不支援的路徑和 POST 等 HTTP 行為。
以 alerts 測試為例,這裡的 assertion 會檢查實際回應,而不是 mock handler 的內部呼叫:
const response = await fetch(`${BASE_URL}/alerts?parkCode=${code}`);
expect(response.status).toBe(200);
expect(response.headers.get("access-control-allow-origin")).toBe("*");
expect(response.headers.get("cache-control")).toContain("public");
const body = (await response.json()) as {
data?: Array<{ id: string; title: string }>;
};
expect(Array.isArray(body.data)).toBe(true);E2E test 會透過實際 HTTP endpoint 走過 Worker 的 request path;若指定的是部署中的 Worker,也會經過 Cloudflare runtime 和正式部署設定,cache miss 時再呼叫 NPS。它比 unit test 更接近使用者實際走過的路徑,但不會強制每個測試都命中或 miss cache。代價是它需要網路和可用的測試 Worker、secret 與上游 API,NPS 資料或服務暫時異常時也可能失敗。這組測試會查詢多個 park code,cache miss 仍會消耗 NPS API quota;不要把它當成完全離線、每次都相同的 unit test。npm run test:e2e 會使用 WORKER_URL 指定的位置,而目前預設值是公開的正式 Worker URL,執行前要留意 target。
TypeScript 在測試裡幫了什麼?
tsconfig.json 把 test/**/* 也納入 strict typecheck。unit test 的 env: Env 會要求測試提供 Worker 所需的 NPS_API_KEY,而 worker.fetch(request, env, ctx) 也會在編譯期檢查 Request、Env 和 ExecutionContext 參數。若改了 binding 名稱、handler 參數或 response 型別卻沒同步修正測試,tsc --noEmit 就能先指出程式和測試之間的落差;編輯器也能根據 Vitest 型別提供 assertion 與 mock 的補完。
不過 TypeScript 不會替執行期 JSON 做驗證。E2E 範例裡的 as { data?: ... } 只是告訴 compiler 我們預期的形狀,真正檢查 data 是不是 array 的是 Vitest assertion。unit test 把精簡的 fake context 轉成 ExecutionContext 型別,也是測試替身的邊界;它不會因此變成 Cloudflare runtime。型別、mock 測試與 live E2E 各自提供不同證據,合起來才比較容易定位問題。
讓型別檢查進入日常流程
Worker 的 package.json 很精簡:
{
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"cf-typegen": "wrangler types",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:unit": "vitest run test/proxy.spec.ts",
"test:e2e": "vitest run test/proxy.e2e.spec.ts",
"test:watch": "vitest"
}
}先在 wrangler.jsonc 宣告這支 Worker 必須設定的 secret:
{
"secrets": {
"required": ["NPS_API_KEY"]
}
}這讓 wrangler types 能穩定產生 NPS_API_KEY 型別,不必依賴開發機上的 .dev.vars 或 .env。Wrangler 也會在本機開發與部署時檢查這個必要 secret。接著產生 Cloudflare 對應的宣告檔:
npm run cf-typegen它會產生 worker-configuration.d.ts,提供 Worker runtime 型別,也依 Wrangler 設定產生全域 Env 宣告,包含 NPS_API_KEY。這是 TypeScript 編譯期讀取的型別,不會變成執行期 JavaScript,所以 Worker 原始碼不用匯入 Request、Response 或 ExportedHandler。前面的小範例手動寫 interface Env 是為了介紹型別 contract;完整 Worker 則直接使用生成的 Env,避免兩份宣告日後不同步。詳情可參考 Cloudflare TypeScript 文件。
無論採用哪種方式,都要讓 tsconfig.json 把生成檔納入 typecheck;本文的設定用 include 收錄 worker-configuration.d.ts。接著在部署前執行:
npm run typecheck
npm run test:unit
npm run deploytsc --noEmit 只做檢查,不產生 JavaScript;Wrangler 仍負責本機開發、打包與部署。npm run test:unit 不依賴 live NPS API;需要檢查整條部署路徑時,再用 npm run test:e2e,並先確認 WORKER_URL 指向預期環境。npm test 會讓 Vitest 執行兩個測試檔,所以目前也會跑 E2E test,預設連到正式 Worker。tsconfig.json 的 strict: true 值得保留:它會讓可為空的值、隱含 any 和未處理的型別情況更早浮出來,而不是在 edge log 裡才第一次見面。
從 JavaScript migration 帶走的事
這個 Worker 沒有突然變得很複雜。它仍是一個驗證 request、向 NPS 發送請求、快取成功結果並回傳 response 的小程式。TypeScript 把 secret 名稱、handler 參數、背景工作和錯誤值的 contract 變成編譯器可檢查的資訊;unit tests 會逐條走過 handler 的成功與失敗分支,E2E tests 再確認部署環境中的實際 HTTP 行為。
我很喜歡把這類 edge proxy 當作 TypeScript 的實戰題目:程式碼短、每個型別都有現實意義,也能看出型別檢查和測試如何互補。型別能先抓出程式結構不一致的地方;要確認回應真的正確,還是得讓測試執行程式,甚至走過部署中的 Worker。過程也帶出一個 production 原則——快取的是可重複使用的成功結果,不是暫時的失敗。