最近在做 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 和錯誤路徑說清楚。

worker-proxy.svg

為什麼 serverless Worker 特別適合 TypeScript?

Worker 不需要管理伺服器,但不代表它沒有 production 風險。相反地,部署後的例外會在 edge 發生:某個 secret 沒有綁定、把 request 的屬性拼錯、或把不該快取的上游錯誤存進 cache,都可能直接影響使用者。

TypeScript 不會取代測試,也不會阻止 NPS API 在網路上失敗;它處理的是另一類問題:在執行前檢查程式對資料和平台 contract 的假設是否一致。對這支 Worker 而言,這包括:

  • 程式碼是否以正確的名稱讀取 env 的 secret?
  • fetch handler 是否收到正確的 RequestEnvExecutionContext
  • 非同步快取工作是否交給 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.jsoncsecrets.required 列出 NPS_API_KEYwrangler 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 已經替我們提供的型別

RequestResponseExecutionContext 都是 Workers runtime 的 API 型別。把它們直接寫在函式簽名上,閱讀程式碼時就能知道每個值的責任。

request 是 browser 送進來的 Web Request,所以可以安全使用 request.methodrequest.urlResponse 是 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;本例中的 Envwrangler typeswrangler.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.defaultExecutionContext 的 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.jsontest/**/* 也納入 strict typecheck。unit test 的 env: Env 會要求測試提供 Worker 所需的 NPS_API_KEY,而 worker.fetch(request, env, ctx) 也會在編譯期檢查 RequestEnvExecutionContext 參數。若改了 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 原始碼不用匯入 RequestResponseExportedHandler。前面的小範例手動寫 interface Env 是為了介紹型別 contract;完整 Worker 則直接使用生成的 Env,避免兩份宣告日後不同步。詳情可參考 Cloudflare TypeScript 文件

無論採用哪種方式,都要讓 tsconfig.json 把生成檔納入 typecheck;本文的設定用 include 收錄 worker-configuration.d.ts。接著在部署前執行:

npm run typecheck
npm run test:unit
npm run deploy

tsc --noEmit 只做檢查,不產生 JavaScript;Wrangler 仍負責本機開發、打包與部署。npm run test:unit 不依賴 live NPS API;需要檢查整條部署路徑時,再用 npm run test:e2e,並先確認 WORKER_URL 指向預期環境。npm test 會讓 Vitest 執行兩個測試檔,所以目前也會跑 E2E test,預設連到正式 Worker。tsconfig.jsonstrict: 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 原則——快取的是可重複使用的成功結果,不是暫時的失敗。