Edge Functions 串流回應與 Webhooks 實戰 | Supabase 完整教學

2026/10/23
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 與 SSE data: 格式、如何把 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 一定要做兩件事:

  1. 關掉平台層 JWT 驗證:外部服務不會帶你的 Supabase JWT,所以這個函式要用 --no-verify-jwt 部署(否則請求會被平台擋在門外)。這也代表安全責任轉移到你身上——你必須在函式內部自己驗。
  2. 驗簽(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",
    },
  })
})

三個要點:

  1. return new Response(stream, { headers }):body 是 ReadableStream,Content-Type 設 text/event-stream——這一組合就是 SSE 串流回應的核心。
  2. controller.enqueue(encoder.encode(...)):enqueue 只吃 Uint8Array,所以字串要先過 TextEncoder;SSE 格式規定每則訊息是 data: <內容>\n\n。
  3. 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 })
})

三個要點:

  1. hmacHex 用 crypto.subtle 算 HMAC-SHA256:這是 Deno 內建的 Web Crypto API,importKey 把 secret 變成金鑰、sign 算出簽章。
  2. timingSafeEqualHex 逐字元 XOR 累加、最後才判斷:不論哪裡開始不同都把整串比完,耗時固定——這就是 constant-time compare,堵住時序攻擊。
  3. 先驗簽、後 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 篇章,下一篇見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →