Edge Functions 串流回應與 Webhooks 實戰 | Supabase 完整教學
上一篇《Background Tasks 與 Cron》我們讓 Edge Function 學會了在回應後繼續幹活、也能定時自己醒來。這一篇補上運算篇章最後兩塊拼圖:串流回應與 Webhooks。前者讓伺服器「一段一段」把內容推給客戶端——用
ReadableStream搭配text/event-stream(Server-Sent Events)做出 ChatGPT 那種逐字浮現的體驗,把 LLM 的串流輸出直接轉發給前端;後者讓你的函式能被「事件」驅動——用 Database Webhooks(底層是pg_net)在資料一變動就觸發函式,並安全地接住 Stripe 這類第三方 webhook:讀原始 body、用 HMAC 驗簽、以 constant-time compare 比對。這一篇收尾 Edge Functions 段。
前言
一句話定義本篇兩個主題:串流回應是「把 Response 的 body 換成一個會逐步吐資料的 ReadableStream、並設 Content-Type: text/event-stream,讓客戶端邊收邊顯示」;Webhooks 則是「讓你的函式被事件驅動——資料庫的資料一變就觸發(Database Webhooks,底層 pg_net),或外部服務主動打進來(第三方 webhook,如 Stripe),後者必須先驗簽才能信任」。
先接住上一篇《Background Tasks 與 Cron》的脈絡。到目前為止,我們的函式回應都是「一次性」的:Deno.serve 收到請求 → 把結果全部算完 → return Response.json(整包結果)。這對「查一筆資料」很夠用,但兩類需求它處理不了:
- 第一類,回應的內容是「逐步產生」的。最典型的就是 LLM:模型是一個 token 一個 token 生出答案的,如果你等它整段生完再一次回傳,使用者要乾瞪眼好幾秒才看到第一個字。理想體驗是邊生邊顯示(像 ChatGPT)——這需要串流回應。
- 第二類,你的函式該被「事件」叫醒,而不是被使用者呼叫。「新使用者一註冊就寄歡迎信」「訂單狀態一改成
paid就發貨」「Stripe 一收到款就開通權限」——這些都不是使用者按按鈕觸發的,而是某個事件發生時該自動執行。這就是 Webhooks。
用一個生活類比。一般回應像外送:廚房把整份餐點做好、裝好、才一次送到你門口,中途你只能等。串流回應則像吃迴轉壽司或 buffet 現做區:師傅做好一盤先給你一盤,你邊吃師傅邊做下一盤,第一口的等待時間大幅縮短。而 Webhook 像門鈴或掛號信通知:不是你主動去問「有沒有我的信」,而是有事情發生時(有人按門鈴、包裹到了),對方主動通知你——pg_net 就是資料庫那顆「門鈴」,Stripe 的 webhook 就是外部寄來的「掛號信」,而掛號信你得先驗明來源(驗簽)才能拆。
本篇你會學到:
- 串流回應(SSE):
ReadableStream的運作原理、text/event-stream與 SSEdata:格式、如何把 LLM 的串流輸出逐段轉發給前端 - Database Webhooks:資料變更(INSERT/UPDATE/DELETE)如何透過底層
pg_net觸發 Edge Function、payload 長什麼樣 - 接收第三方 webhook:以 Stripe 為例,為什麼要
--no-verify-jwt、為什麼要讀原始 body、如何用 HMAC 驗簽 - constant-time compare:驗簽比對為什麼不能用
===、時序攻擊是什麼、SDK 幫你做了什麼
本篇聚焦「串流與 Webhooks」;
Deno.serve、functions deploy --no-verify-jwt、Secrets 與EdgeRuntime.waitUntil等基礎請見前幾篇,這裡不再重複。以下 API 名稱、payload 欄位與行為細節請以官方文件為準。
核心概念
要把這兩塊做對,先建立三張心智圖:串流回應「怎麼一段一段送」、Database Webhooks「資料變更怎麼變成一個 HTTP 請求」、第三方 webhook「怎麼確認這封信真的是它寄的」。
一、串流回應:把 body 換成一個會逐步吐資料的 stream
一般回應的資料流是這樣的:
handler 把結果「全部」算完 → 湊成完整字串 → return Response.json(整包) → 客戶端一次收到
問題在「全部算完」這一步:如果結果需要 5 秒才生完,客戶端就得空等 5 秒。串流回應改變的是這件事——它把 Response 的 body 換成一個 ReadableStream,然後:
handler 立刻 return new Response(stream, { headers: 'text/event-stream' })
→ 客戶端「馬上」開始接收(連線已建立)
→ 每算出一小段,controller.enqueue(那一段) ← 推一段、客戶端就收到一段
→ 算完最後一段,controller.close() ← 結束串流
關鍵物件是 ReadableStream 的 controller:
controller.enqueue(chunk):把一段資料推進串流,客戶端會立刻收到(chunk需為Uint8Array,所以要先用TextEncoder把字串編碼)。controller.close():宣告「沒有更多資料了」,關閉串流。controller.error(e):以錯誤中止串流。
而 SSE(Server-Sent Events) 是一種在 HTTP 上做「伺服器→客戶端單向串流」的標準格式,規則很簡單:
- 回應標頭要有
Content-Type: text/event-stream(外加Cache-Control: no-cache、Connection: keep-alive)。 - 每一則訊息寫成
data: <內容>\n\n——data:開頭、內容、結尾兩個換行(\n\n)代表一則訊息結束。
前端則用瀏覽器內建的 EventSource(或 fetch + reader)逐則接收。這正是把 LLM 串流輸出接給前端的標準做法:OpenAI/Anthropic 的串流 API 本身就是回 SSE,你的 Edge Function 只要「一邊讀 LLM 的串流、一邊把每段轉成自己的 SSE 推給前端」即可。
二、Database Webhooks:資料一變就觸發函式(底層 pg_net)
Edge Function 是「被呼叫才執行」的。上一篇我們用 時間(pg_cron)來呼叫它;這一篇的 Database Webhooks 則是用 資料變更事件 來呼叫它。
運作原理:當你在 Supabase Dashboard 對某張表設定一個 Database Webhook,Supabase 其實是幫你在那張表上建立一個 PostgreSQL 觸發器(trigger)。之後只要這張表發生 INSERT / UPDATE / DELETE,觸發器就會執行,內部呼叫 pg_net 的 net.http_post(...) 把「這次變更」包成一個 HTTP 請求,打到你指定的 Edge Function。
一張圖看懂整條鏈路:
使用者/App 對某張表寫入資料(INSERT / UPDATE / DELETE)
│
▼
[PostgreSQL Trigger] Database Webhook 建立的觸發器被觸發
│ 組出 payload:{ type, table, record, old_record, ... }
▼
[pg_net] net.http_post(
url = '<你的專案>/functions/v1/on-user-created',
headers = { Authorization / 自訂密鑰 },
body = payload
) ← 資料庫「主動」發出 HTTP 請求
│
▼
[Edge Function] on-user-created 被喚醒,讀 payload.record 做事
│ 例如:寄歡迎信、建立預設資料、通知 Slack
▼
[回應] 200 OK → pg_net 記錄回應
Webhook 送來的 payload 大致長這樣(欄位以官方為準):
{
"type": "INSERT",
"table": "users",
"schema": "public",
"record": { "id": "…", "email": "new@example.com", "created_at": "…" },
"old_record": null
}
type:事件類型(INSERT/UPDATE/DELETE)。record:變更後的那筆資料(DELETE時通常為null)。old_record:變更前的那筆資料(INSERT時為null,UPDATE/DELETE才有)。
注意它和 pg_cron 的關係:兩者底層都靠 pg_net 發 HTTP,差別只在「誰觸發」——pg_cron 是時間觸發、Database Webhooks 是資料變更觸發。
三、第三方 webhook:先驗明來源才能信任
Database Webhook 是你自己的資料庫打進來的,來源可信。但第三方 webhook(Stripe、GitHub、LINE…)是外部服務打進來的公開端點,任何人都能對這個 URL 發請求。如果你不驗證,攻擊者就能偽造一個「付款成功」的請求騙你開通權限。
所以第三方 webhook 一定要做兩件事:
- 關掉平台層 JWT 驗證:外部服務不會帶你的 Supabase JWT,所以這個函式要用
--no-verify-jwt部署(否則請求會被平台擋在門外)。這也代表安全責任轉移到你身上——你必須在函式內部自己驗。 - 驗簽(signature verification):第三方服務會用一個只有你和它知道的 signing secret,對請求 body 算出一個 HMAC 簽章,放在標頭(如
Stripe-Signature)。你收到後用同一把 secret、同一套演算法自己再算一次,兩個簽章一致才代表「這封信真的是它寄的、而且中途沒被竄改」。
這裡有三個必須記住的細節:
- 要讀「原始 body」(
await req.text()),不能先req.json()。簽章是對原始位元組算的,JSON.parse再stringify會改變字串(空白、鍵順序、跳脫),導致你算出的簽章對不上。 - 比對要用 constant-time compare,不能用
===。這是為了防時序攻擊(下一段細講)。 - 驗簽用的是 signing secret,不是 API key。兩者是不同的東西,別搞混。
四、關鍵術語:HMAC 與 constant-time compare
- HMAC(Hash-based Message Authentication Code):一種「帶密鑰的雜湊」。輸入是「訊息(body)+ 密鑰(signing secret)」,輸出是一段固定長度的簽章。特性是:不知道密鑰的人算不出正確簽章,且訊息只要改一個位元,簽章就完全不同。所以它同時保證了來源可信(有密鑰才算得出)與內容未竄改。
- constant-time compare(定值時間比較):一種「不管在哪個字元開始不同,都把整串比完才回傳結果」的比較方式,讓每次比對耗時固定。一般的
===/ 字串比較是「一遇到不同字元就馬上回傳false」,耗時會隨「前幾個字元對不對」而變化,這個時間差會洩漏資訊、被**時序攻擊(timing attack)**利用來逐字元猜出正確簽章。驗簽比對必須用定值時間比較(如crypto.subtle、timingSafeEqual)來堵住這個洞。
好消息是:接 Stripe 這類有官方 SDK 的服務時,SDK 的 constructEventAsync() 內部已經幫你做了正確的 HMAC 驗簽與安全比對,你不必手刻。只有在接沒有 SDK 的服務時,才需要自己用 Web Crypto 算 HMAC 並做定值時間比較——下面兩種情況我們都會示範。
實作範例
下面用可直接執行的 TypeScript 與少量 SQL,走一遍「串流回應 → Database Webhook → 接 Stripe webhook → 手動 HMAC 驗簽」。假設你已完成前幾篇、已連結遠端專案並設好 Secrets。
一、串流回應:把 LLM 的串流輸出轉發成 SSE
最經典的場景:前端傳一個 prompt,你的 Edge Function 呼叫 OpenAI 的串流 API,一邊讀模型吐出的 token、一邊用 SSE 推給前端,做出逐字浮現的效果。
// supabase/functions/chat-stream/index.ts
Deno.serve(async (req: Request) => {
const { prompt } = await req.json()
const encoder = new TextEncoder()
// 建立一個 ReadableStream:它的 start() 會在客戶端開始接收時執行
const stream = new ReadableStream({
async start(controller) {
// 小工具:把一段內容包成 SSE 格式(data: ...\n\n)推給客戶端
const send = (obj: unknown) =>
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`))
try {
// 呼叫 OpenAI 串流 API(stream: true → 它會回傳 SSE 串流)
const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
stream: true,
}),
})
const reader = upstream.body!.getReader()
const decoder = new TextDecoder()
// 一段一段讀 LLM 的串流
while (true) {
const { done, value } = await reader.read()
if (done) break
// LLM 回的也是 SSE,逐行解析出 data: 部分
for (const line of decoder.decode(value).split("\n")) {
if (!line.startsWith("data: ")) continue
const data = line.slice(6)
if (data === "[DONE]") {
send({ done: true }) // 通知前端串流結束
continue
}
try {
const token = JSON.parse(data).choices?.[0]?.delta?.content
if (token) send({ token }) // 把這個 token 轉發給前端
} catch (_) {
// 忽略無法解析的行(如空行、keep-alive)
}
}
}
} catch (err) {
send({ error: err instanceof Error ? err.message : String(err) })
} finally {
controller.close() // 一定要關閉,否則連線會一直懸著
}
},
})
// 關鍵:回傳 stream 當 body,並設定 SSE 需要的三個標頭
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
})
})
三個要點:
return new Response(stream, { headers }):body 是ReadableStream,Content-Type設text/event-stream——這一組合就是 SSE 串流回應的核心。controller.enqueue(encoder.encode(...)):enqueue只吃Uint8Array,所以字串要先過TextEncoder;SSE 格式規定每則訊息是data: <內容>\n\n。finally { controller.close() }:不論成功或出錯,最後都要close(),否則客戶端會一直等一個永不結束的串流。
前端接收(瀏覽器)長這樣——注意 SSE 端點若要帶自訂資料常改用 fetch + reader:
// 前端:用 fetch 讀取串流並逐字顯示
const res = await fetch("https://<你的專案>.supabase.co/functions/v1/chat-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${anonKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: "用一句話解釋什麼是 Supabase" }),
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value).split("\n")) {
if (!line.startsWith("data: ")) continue
const payload = JSON.parse(line.slice(6))
if (payload.token) process.stdout.write(payload.token) // 逐字浮現
}
}
二、Database Webhook:資料一寫入就觸發函式
第二類需求:新使用者一寫入 profiles 表,就自動寄一封歡迎信。先寫好被觸發的函式,再設定 Webhook。
先看被觸發的函式。它是「內部端點」,會用 --no-verify-jwt 部署,所以要在函式內部自己驗證請求真的來自你的資料庫(用一個自訂密鑰):
// supabase/functions/on-user-created/index.ts
Deno.serve(async (req: Request) => {
// 自我驗證:Database Webhook 設定時可加一個自訂標頭當密鑰,
// 這裡比對它,擋掉外部亂打的請求
const secret = req.headers.get("x-webhook-secret")
if (secret !== Deno.env.get("DB_WEBHOOK_SECRET")) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
// 解析 Database Webhook 的 payload
const payload = await req.json()
const { type, table, record } = payload
// 只處理 profiles 表的 INSERT
if (type !== "INSERT" || table !== "profiles") {
return Response.json({ skipped: true })
}
// 用 record 拿到新使用者資料,寄歡迎信
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("RESEND_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "hello@example.com",
to: record.email,
subject: "歡迎加入!",
html: `<p>嗨 ${record.email},歡迎你成為我們的一員 🎉</p>`,
}),
})
return Response.json({ ok: true, sentTo: record.email })
})
部署(公開端點,關掉平台層 JWT):
supabase functions deploy on-user-created --no-verify-jwt
接著設定 Database Webhook。最簡單是在 Dashboard → Database → Webhooks 建立:選 profiles 表、勾 INSERT、type 選「Supabase Edge Functions」、指向 on-user-created,並在 HTTP Headers 加一個 x-webhook-secret 對應上面函式驗證的密鑰。
Dashboard 的設定底層等同於一個由 pg_net 發請求的觸發器。若你偏好用 SQL 明確掌控,也可以自己建觸發器(概念示意,欄位以官方為準):
-- 概念示意:資料一 INSERT 就用 pg_net 發 HTTP 呼叫 Edge Function
-- (Dashboard 的 Database Webhooks 底層做的就是類似這件事)
create or replace function notify_user_created()
returns trigger
language plpgsql
as $$
begin
perform net.http_post(
url := 'https://your-project-id.supabase.co/functions/v1/on-user-created',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'x-webhook-secret', 'your-db-webhook-secret' -- 與函式內比對的密鑰一致
),
body := jsonb_build_object(
'type', TG_OP, -- INSERT / UPDATE / DELETE
'table', TG_TABLE_NAME,
'record', row_to_json(NEW) -- 變更後的那筆資料
)
);
return NEW;
end;
$$;
create trigger on_profiles_insert
after insert on public.profiles
for each row execute function notify_user_created();
net.http_post是非同步的:它把請求排入佇列後立刻返回,不會等 Edge Function 跑完。所以「Webhook 有沒有真的呼叫成功、函式回了什麼」要去查net._http_response(HTTP 回應)與函式的 Dashboard 日誌,別以為寫入當下就能拿到函式結果。
三、接收 Stripe webhook:讀原始 body + SDK 驗簽
現在做第三類需求:接住 Stripe 打進來的付款事件(例如 checkout.session.completed 就開通權限)。這是「外部服務主動打進來」,所以驗簽是重中之重。
用 Stripe 官方 SDK 的 constructEventAsync()——它內部已經幫你做了 HMAC 驗簽與 constant-time compare,你只要餵它三樣東西:原始 body、Stripe-Signature 標頭、signing secret:
// supabase/functions/stripe-webhook/index.ts
import Stripe from "https://esm.sh/stripe@17?target=denonext"
const stripe = new Stripe(Deno.env.get("STRIPE_API_KEY")!, {
apiVersion: "2024-11-20.acacia",
})
// Deno 環境要用 Web Crypto 版本的驗簽 provider
const cryptoProvider = Stripe.createSubtleCryptoProvider()
Deno.serve(async (req: Request) => {
const signature = req.headers.get("Stripe-Signature")
if (!signature) {
return new Response("缺少 Stripe-Signature 標頭", { status: 400 })
}
// 關鍵:讀「原始 body」,不能先 req.json()——簽章是對原始位元組算的
const rawBody = await req.text()
let event: Stripe.Event
try {
// SDK 內部:用 signing secret 對 rawBody 算 HMAC,
// 再與 signature 做 constant-time 比對;不符就丟例外
event = await stripe.webhooks.constructEventAsync(
rawBody,
signature,
Deno.env.get("STRIPE_WEBHOOK_SIGNING_SECRET")!, // signing secret,不是 API key
undefined,
cryptoProvider,
)
} catch (err) {
// 驗簽失敗 → 一律回 400,絕不處理未驗證的請求
console.error("Webhook 驗簽失敗:", err)
return new Response(`Webhook Error: ${err instanceof Error ? err.message : err}`, {
status: 400,
})
}
// 走到這裡代表「這封信確實來自 Stripe 且未被竄改」,可以安心處理
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session
console.log("付款完成:", session.customer_email)
// TODO: 開通權限、寫資料庫…
break
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription
console.log("訂閱取消:", sub.id)
break
}
default:
console.log("未處理的事件類型:", event.type)
}
// 盡快回 200 告訴 Stripe「我收到了」,避免它重送
return Response.json({ received: true })
})
部署時務必 --no-verify-jwt(Stripe 不會帶你的 JWT),並把 signing secret 設成 Secret:
supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...
supabase functions deploy stripe-webhook --no-verify-jwt
四、手動 HMAC 驗簽:接沒有 SDK 的服務
如果對方沒有官方 SDK,你就得自己用 Web Crypto 算 HMAC 並做定值時間比較。下面是一個通用的 HMAC-SHA256 驗簽範例(假設對方在 X-Signature 標頭放了 sha256=<hex>):
// supabase/functions/generic-webhook/index.ts
// 定值時間比較兩個 hex 字串:不論哪裡不同都比完,避免時序攻擊
function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i) // 逐字元 XOR,累加差異
}
return diff === 0 // 全部比完才回傳結果,耗時固定
}
// 用 signing secret 對 body 算 HMAC-SHA256,輸出 hex 字串
async function hmacHex(secret: string, body: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
)
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body))
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("")
}
Deno.serve(async (req: Request) => {
const header = req.headers.get("X-Signature") ?? ""
const received = header.replace(/^sha256=/, "") // 取出對方帶來的簽章
// 一樣要讀「原始 body」,不能先 JSON.parse
const rawBody = await req.text()
const expected = await hmacHex(Deno.env.get("WEBHOOK_SIGNING_SECRET")!, rawBody)
// 用定值時間比較,不用 ===
if (!timingSafeEqualHex(received, expected)) {
return new Response("簽章驗證失敗", { status: 401 })
}
// 驗簽通過,才 parse body 處理業務邏輯
const payload = JSON.parse(rawBody)
console.log("已驗證的 webhook:", payload)
return Response.json({ ok: true })
})
三個要點:
hmacHex用crypto.subtle算 HMAC-SHA256:這是 Deno 內建的 Web Crypto API,importKey把 secret 變成金鑰、sign算出簽章。timingSafeEqualHex逐字元 XOR 累加、最後才判斷:不論哪裡開始不同都把整串比完,耗時固定——這就是 constant-time compare,堵住時序攻擊。- 先驗簽、後
JSON.parse:rawBody(req.text())拿去算簽章,驗過了才JSON.parse進業務邏輯,順序不能顛倒。
常見錯誤與最佳實踐
坑一:串流回應忘了設 Content-Type: text/event-stream(或回傳的不是 stream)。
最常見的是把結果先組成完整字串才 return,或回了 ReadableStream 卻沒設對標頭,導致客戶端不是收不到、就是不當串流解析。正確做法:return new Response(readableStream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" } }),每則訊息用 data: <內容>\n\n 格式,enqueue 前記得用 TextEncoder 編碼。
坑二:串流忘了 controller.close(),或 body 沒逐段讀就整包 await。
沒 close() 客戶端會一直懸著等,體感像卡死;把上游 LLM 的回應 await response.json() 整包讀掉,就失去串流意義了。正確做法:用 reader.read() 迴圈逐段讀、逐段 enqueue,並在 finally 裡 controller.close();出錯時用 send({ error }) 通知前端再關閉。
坑三:接第三方 webhook 完全沒驗簽,或先 req.json() 才驗。
公開端點人人可打,不驗簽等於任何人都能偽造「付款成功」。而先 req.json() 再想驗簽會失敗,因為簽章是對原始 body 算的,parse 過就對不上了。正確做法:一律 --no-verify-jwt + 函式內驗簽;先 await req.text() 拿原始 body 去驗簽,通過後才 JSON.parse;用 signing secret(不是 API key)。
坑四:驗簽比對用 === 或一般字串比較,留下時序攻擊破口。
=== 一遇到不同字元就回傳,耗時會洩漏「對到第幾個字元」,可被逐字元猜出正確簽章。正確做法:用 constant-time compare——Stripe SDK 的 constructEventAsync() 已內建;手動驗簽時自己寫「逐字元 XOR 累加、最後才判斷」的比較函式,或用 crypto.subtle / timingSafeEqual。
坑五:把 Database Webhook 當成同步、或以為它和 pg_cron 沒關係。
Database Webhooks 底層是 pg_net,發出請求後不等函式跑完;也常有人搞混它和 pg_cron——其實兩者底層都是 pg_net,差別只在「資料變更觸發」vs「時間觸發」。正確做法:要「資料一變就做事」用 Database Webhooks、要「定時做事」用 pg_cron;成效都去查 net._http_response 與函式日誌,別期待寫入當下拿到結果。
坑六:內部 webhook 端點裸奔,沒有任何自我驗證。
Database Webhook 的函式用了 --no-verify-jwt,若函式內也不驗,等於一個公開 URL 任人呼叫。正確做法:在 Webhook 設定加一個自訂標頭(如 x-webhook-secret),函式內比對它;比對敏感密鑰時同樣建議用定值時間比較。
坑七:webhook 處理太慢,讓對方逾時而重送。
Stripe 等服務對 webhook 回應有時間要求,你若在請求裡同步做一堆慢事(寄信、跑報表),可能還沒回 200 就被判逾時、觸發重送,造成重複處理。正確做法:先驗簽、先回 200,把慢動作用上一篇的 EdgeRuntime.waitUntil(...) 丟背景做;並讓事件處理冪等(用 event id 去重),因為 webhook 本來就可能被重送。
最佳實踐總結:
- 串流三件套:
return new Response(stream, …)+Content-Type: text/event-stream+ 每則data: …\n\n;逐段enqueue、finally裡close() - 接 webhook 一律驗簽:
--no-verify-jwt+ 先req.text()拿原始 body 驗簽 + 用 signing secret - 比對用定值時間:
===有時序攻擊風險;優先用 SDK 內建驗簽,手動時自己做 constant-time compare - Database Webhooks 是事件驅動:底層
pg_net、非同步、payload 帶record/old_record;和pg_cron(時間驅動)分工清楚 - 內部端點也要自我驗證:
--no-verify-jwt不等於可裸奔,加自訂密鑰比對 - 快回應 + 冪等:先驗簽先回 200、慢動作丟
waitUntil、用 event id 去重防重送 - 不確定的細節以官方為準:SSE 行為、Database Webhooks payload 欄位、
pg_net表結構、Stripe API 版本都可能調整,正式決策前查官方文件
小結
這是 Supabase 系列教學的第 034 篇,也是 SB-4 運算與 AI 篇章中 Edge Functions 段的收尾。上一篇《Background Tasks 與 Cron》我們讓函式能在回應後繼續幹活、也能定時自己醒來;這一篇補上最後兩塊——串流回應讓函式能「一段一段」把內容推給客戶端、Webhooks 讓函式能被「事件」驅動並安全接住外部請求。
核心觀念濃縮成一句話:串流回應是 return new Response(readableStream, { headers: { "Content-Type": "text/event-stream" } }) 搭配每則 data: …\n\n 的 SSE 格式、逐段 enqueue 再 close(),最適合轉發 LLM 的逐字輸出;Database Webhooks 用底層 pg_net 在資料 INSERT/UPDATE/DELETE 時觸發函式(事件驅動,對照 pg_cron 的時間驅動);接第三方 webhook(如 Stripe)則必須 --no-verify-jwt + 讀原始 body(req.text())+ 用 HMAC 驗簽,且比對務必用 constant-time compare 以防時序攻擊。
- 串流回應:
ReadableStream+text/event-stream+data: …\n\n、enqueue/close、轉發 LLM 串流 - Database Webhooks:底層
pg_net、資料變更觸發、payload 的type/record/old_record - 第三方 webhook:
--no-verify-jwt、原始 body、HMAC 驗簽、signing secret - constant-time compare:
===有時序攻擊風險,優先用 SDK 內建、手動則自己做定值時間比較
到這裡,Edge Functions 段正式收尾——你已經能建立、部署、驗證、排程、串流、接事件,把運算層的常見需求都覆蓋了。運算與 AI 篇章接下來要轉向**「AI」這條線:下一篇《pgvector 向量入門》,我們回到資料庫,認識 pgvector 這個把「語意」變成可查詢向量的擴充功能,為之後的向量搜尋與 RAG**打底。運算篇章告一段落,AI 篇章,下一篇見。