用 Workers 設計 REST API
這是「前端 ↔ 後端」裡的後端那一半:用一個 Worker 檔案,像正規的 REST API 一樣回應 GET、POST、PUT、DELETE。
我們要做什麼?
我們要在單一個 Cloudflare Worker 上做一個 REST API。所謂「REST API」(Representational State Transfer,表現層狀態轉換)其實就是前端跟後端「用 HTTP 要資料」的一套共同約定:你用一段網址路徑代表一種資源(例如 /api/users),再用一個 HTTP 方法(GET、POST、PUT、DELETE)表示你想對它做什麼。
整個後端就放在一個 Worker 裡。當一個請求進來,Worker 會讀兩樣東西——方法(method)跟路徑(path)——決定該跑哪一段處理程式,做完事,再回傳一個帶狀態碼的 JSON 回應。就這樣,連框架都不用。
把它想成一家餐廳
網址路徑就像桌號(指哪一種資源),HTTP 方法就是你對服務生說的話:GET =「給我看菜單」、POST =「我要點這個」、PUT =「改一下我的餐點」、DELETE =「取消」。狀態碼則是服務生的回覆:200「好的給你」、201「已幫你下單」、404「我們沒有這個」。
路由器怎麼做決定
在 Worker 內部,路由其實就是依據兩個從請求讀到的值來分流:request.method,以及 new URL(request.url) 取出的 pathname(路徑)。每一組「方法/路徑」對應到一個處理函式,每個處理函式都回傳一個帶有合適狀態碼的回應。
OPTIONS — 預檢
瀏覽器的 CORS 安全檢查。最先處理它,回 204 加上 CORS 標頭,再談後面的事。
GET — 讀取
列出整批(/api/users)或拿單一筆(/api/users/:id)。回 200,找不到就回 404。
POST — 新增
讀取 JSON 內容、驗證、建立資源。回 201 Created,內容不合法就回 400。
PUT — 更新
用 :id 找到該筆並替換欄位。回 200、輸入不對回 400、找不到回 404。
DELETE — 刪除
用 :id 刪掉該筆。刪掉了回 200,本來就沒有就回 404。
其他情況
不認識的路徑回 404;路徑存在但方法不對,回 405 Method Not Allowed(方法不允許)。
瀏覽器呼叫的逐步流程
當某個網域上的網頁去呼叫另一個網域的 API 時,瀏覽器會執行 CORS(Cross-Origin Resource Sharing,跨來源資源共用)——這是一條保護使用者、避免被偷偷跨站請求的規則。對某些請求,瀏覽器會先送出一個「預檢(preflight)」:一個 OPTIONS 請求,問 API「你同意我呼叫你嗎?」。只有當 API 回應正確的 Access-Control-* 標頭,瀏覽器才會送出真正的請求。
不是每個請求都會預檢
「簡單請求」(單純的 GET,或用基本內容類型的 POST)會跳過 OPTIONS 那一步。但只要你送出 Content-Type: application/json 或自訂標頭,瀏覽器就會預檢——這就是為什麼 JSON API 一定要處理 OPTIONS。
動手做
以下是完整後端(單一檔案)、對應的前端 fetch 範例,以及 wrangler 設定。Worker 用一個小小的記憶體陣列當作「假資料庫」,讓你專心看路由——要真正持久保存資料時,再換成 D1 或 KV。
建立 Worker 專案
用 JavaScript 產生一個最單純的「Hello World」Worker,然後打開 src/index.js。
npm create cloudflare@latest -- my-api cd my-api寫 REST 路由器
把 src/index.js 換成下面的後端程式碼。它會讀取方法與路徑、路由到對應的處理函式,並用 Response.json(...) 回傳正確狀態碼與 CORS 標頭。
先本機跑,再部署
在 http://localhost:8787/api/users 測試,再發佈到公開的 *.workers.dev 網址。
npx wrangler dev npx wrangler deploy
// A single-file REST API on one Worker.
// CORS = Cross-Origin Resource Sharing(跨來源資源共用):
// 這些標頭讓「別的網域」的瀏覽器前端可以呼叫我們。
const CORS = {
"Access-Control-Allow-Origin": "*", // 誰可以呼叫(* = 任何人;正式環境請鎖成你的網域)
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
// 一個小小的記憶體資料(重啟會清空——正式請改用 D1 / KV)。
let users = [
{ id: 1, name: "Ada" },
{ id: 2, name: "Linus" },
];
let nextId = 3;
// 用一個 helper 統一產生每個 JSON 回應:資料 + 狀態碼 + CORS 標頭。
function json(data, status = 200) {
return Response.json(data, { status, headers: CORS });
}
export default {
async fetch(request) {
const { pathname } = new URL(request.url); // 例如 "/api/users/2"
const method = request.method; // 例如 "GET"
// 1) CORS 預檢:瀏覽器在真正跨站請求前會先送 OPTIONS。
if (method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS });
}
// 2) 只服務我們的 API 路徑;其餘一律 404 Not Found(找不到)。
if (!pathname.startsWith("/api/users")) {
return json({ error: "Not found" }, 404);
}
// 3) 從 /api/users/:id 取出可有可無的 id。
const parts = pathname.split("/").filter(Boolean); // ["api","users","2"]
const id = parts[2] ? Number(parts[2]) : null;
try {
// GET /api/users → 列出全部(200 OK)
if (method === "GET" && id === null) {
return json(users, 200);
}
// GET /api/users/:id → 取單筆(200,找不到 404)
if (method === "GET" && id !== null) {
const user = users.find((u) => u.id === id);
return user ? json(user, 200) : json({ error: "User not found" }, 404);
}
// POST /api/users → 新增(201 Created,輸入不合法 400 Bad Request)
if (method === "POST" && id === null) {
const body = await request.json();
if (typeof body?.name !== "string") {
return json({ error: "name is required" }, 400);
}
const user = { id: nextId++, name: body.name };
users.push(user);
return json(user, 201);
}
// PUT /api/users/:id → 更新(200 / 400 / 404)
if (method === "PUT" && id !== null) {
const user = users.find((u) => u.id === id);
if (!user) return json({ error: "User not found" }, 404);
const body = await request.json();
if (typeof body?.name !== "string") {
return json({ error: "name is required" }, 400);
}
user.name = body.name;
return json(user, 200);
}
// DELETE /api/users/:id → 刪除(200,找不到 404)
if (method === "DELETE" && id !== null) {
const before = users.length;
users = users.filter((u) => u.id !== id);
return before === users.length
? json({ error: "User not found" }, 404)
: json({ ok: true }, 200);
}
// 路徑存在,但方法不對 → 405 Method Not Allowed(方法不允許)。
return json({ error: "Method not allowed" }, 405);
} catch (err) {
// JSON 內容壞掉或其他意外 → 400 Bad Request。
return json({ error: "Invalid request" }, 400);
}
},
};// 這段跑在瀏覽器裡。換成你部署後拿到的網址。
const API = "https://my-api.<your-subdomain>.workers.dev/api/users";
// GET:列出全部使用者
const res = await fetch(API);
console.log(res.status, await res.json()); // 200, [ {id:1,...}, ... ]
// POST:新增一筆(把 JSON 放在 body,並標明 Content-Type)
const created = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Grace" }),
});
console.log(created.status, await created.json()); // 201, { id: 3, name: "Grace" }
// PUT:更新 id = 3 那筆
await fetch(`${API}/3`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Grace H." }),
});
// DELETE:刪掉 id = 3 那筆
await fetch(`${API}/3`, { method: "DELETE" });name = "my-api"
main = "src/index.js"
compatibility_date = "2025-01-01"記憶體資料不會留存
每個 Worker isolate(隔離環境)都有自己一份 users 陣列,而且過一段時間就會重置。拿來學路由很完美,但要存真資料就需要儲存層。可參考 D1 CRUD 教學,把這個 API 接到 SQL 資料庫。
重點概念
HTTP 方法
請求的「動詞」——GET 讀、POST 建立、PUT 更新、DELETE 刪除。用 request.method 讀到它。
URL 與路徑
new URL(request.url) 會解析網址。它的 pathname(例如 /api/users/2)告訴你對方想要哪個資源。
JSON 內容
POST 與 PUT 會在請求內容(body)裡帶資料。await request.json() 把它轉成 JavaScript 物件——記得一定要驗證。
狀態碼
三位數的結果:2xx 成功、4xx 用戶端出錯、5xx 伺服器出錯。它讓前端不必解析文字就能判斷狀況。
Response.json()
Workers 的便利函式:把物件序列化成 JSON 並自動設好 Content-Type。第二個參數可放 { status, headers }。
CORS
授權「別的網域」瀏覽器呼叫你的標頭。要處理 OPTIONS,並在每個回應加上 Access-Control-Allow-*。
路由
把「方法 + 路徑」對應到一個處理函式。路由不多時,幾個 if 判斷就贏過任何框架;路由很多時,可改用 itty-router 或 Hono。
冪等性
GET、PUT、DELETE 重複呼叫結果應一樣;POST 通常每次都會新建一筆。這會幫你決定該選哪個動詞。
最常用的狀態碼
- 200 OK — 請求成功(GET、PUT、DELETE)
- 201 Created — 成功建立了新資源(POST)
- 204 No Content — 成功但沒有內容(很適合 OPTIONS 預檢)
- 400 Bad Request — 用戶端送了不合法的資料
- 404 Not Found — 該路徑 / id 沒有對應的資源
- 405 Method Not Allowed — 路徑存在,但不支援這個動詞
- 500 Internal Server Error — 你這邊的程式爆掉了
小提示與常見陷阱
最常見的 CORS 錯誤
如果你忘了處理 OPTIONS,或在錯誤回應上漏掉 CORS 標頭,瀏覽器就會擋下這次呼叫,主控台會出現讓人摸不著頭緒的「CORS error」——即使你的 Worker 其實有跑。請在「每一個」回應上都附上 CORS 標頭,不論成功或失敗。
正式環境請鎖定來源
學習階段用 Access-Control-Allow-Origin: * 沒問題,但正式環境請改成你真正的前端網域(例如 https://app.example.com),這樣才只有你的網站能從瀏覽器呼叫這個 API。
讓 API 保持乾淨的好習慣
- 永遠把 request.json() 包在 try/catch 裡——內容壞掉時要回 400,而不是讓 Worker 當掉
- 信任任何欄位前都要先驗證;絕不要把未檢查的輸入寫進儲存層
- 錯誤一律用一致的 JSON 形狀,例如 { "error": "訊息" },前端才能統一處理
- 依「意圖」選動詞:讀取 = GET、新增 = POST、整筆替換 = PUT、刪除 = DELETE
- 保留單一個 json() helper,讓狀態碼與 CORS 標頭只在一個地方設定
- 當路由變多時,從一堆 if 改用 Hono 之類的路由函式庫,維持可讀性
好的 REST API 是「刻意無聊」的:路徑可預測、動詞可預測、狀態碼也可預測。