Supabase Edge Functions 入門:第一個 Deno 無伺服器函式 | Supabase 完整教學

2026/10/20
Supabase Edge Functions 入門:第一個 Deno 無伺服器函式 | Supabase 完整教學

前一篇我們替 Realtime 補上授權、做出安全的即時聊天室,替「資料與即時」畫下句點。這一篇正式踏入 運算與 AI:當你需要在伺服器端跑自訂邏輯——呼叫第三方 API、把私密金鑰藏起來、處理 Webhook——就輪到 Edge Functions 登場。本篇帶你認識以 Deno 為底層的無伺服器(serverless) TypeScript 函式,從 supabase functions new 建立、用 Deno.serve 寫 handler、本機 supabase functions serve 測試,到用 supabase.functions.invoke 從前端呼叫,寫出你的第一個部署到全球邊緣網路的函式,並釐清它與 Database Functions 的分工。

前言

一句話定義本篇主題:Edge Functions 是 Supabase 提供的無伺服器運算能力——你用 TypeScript 寫一個接收 Request、回傳 Response 的函式,它跑在以 Deno 為底層、部署至全球邊緣節點的執行環境裡,能以極低延遲回應世界各地的請求,並深度整合 Supabase 生態系與任何第三方 API。

先接住前面的脈絡。SB-3 我們把 Realtime 三大能力(Postgres Changes、Broadcast、Presence)加上授權練齊,那些邏輯幾乎都在資料庫與客戶端之間流動。但真實產品裡,總有一類需求沒辦法只靠資料庫和前端解決,例如:

  • 要呼叫 OpenAI 生成摘要、呼叫 Stripe 建立結帳、呼叫寄信服務發送 email——這些都需要一個你能信任的伺服器端去代打。
  • API 金鑰不能寫在前端。任何寫進瀏覽器 JavaScript 的金鑰都等於公開,OPENAI_API_KEY、STRIPE_API_KEY 必須藏在伺服器。
  • 要接收外部服務的 Webhook(例如 Stripe 付款完成通知),需要一個公開可達的 HTTP 端點去驗證與處理。

過去這類需求你得自己架一台伺服器、或開一個 AWS Lambda。Supabase 把這件事變簡單了——這就是 Edge Functions。

用一個生活類比:把 Supabase 想成一間餐廳。資料庫(Postgres) 是後場的冷藏庫與食材,Realtime 是傳菜的即時通道,而 Edge Function 就是外場那位能跑腿的服務生——客人(前端)點了一杯「需要去隔壁店買原料才能做的特調」(呼叫第三方 API),服務生拿著只有店家才有的 VIP 卡(藏在伺服器的金鑰)去採買、調好、端回來。客人全程碰不到那張 VIP 卡,也不必知道原料哪裡來。

本篇你會學到:

  • Deno runtime:Edge Function 的執行環境為什麼是 Deno 而不是 Node.js,這對你寫程式碼有什麼實際影響
  • 建立與本機開發:supabase functions new 建立函式、Deno.serve 寫 handler、supabase functions serve 本機熱重載測試
  • 從前端呼叫:用 supabase.functions.invoke('name', { body }) 呼叫函式,以及 CORS 這關為什麼一定要處理
  • 何時該用 Edge Function:呼叫第三方 API、隱藏金鑰、伺服器邏輯——以及它與 Database Functions 的分工界線

本篇是 SB-4 運算與 AI 的首篇,聚焦「入門」。Secrets 管理、部署、背景任務、Cron、串流等進階主題會在後續篇章展開。

核心概念

要把 Edge Functions 用對,先建立三張心智圖:一張是「Edge Function 在整體架構中的位置」,一張是「Deno runtime 帶來的差異」,一張是「Edge Function vs Database Function 怎麼選」。

一、Edge Function 在架構中的位置

先看一個請求從前端到 Edge Function 的路徑:

[使用者瀏覽器 / App]
        │  supabase.functions.invoke('my-fn', { body })
        ▼
[Edge Gateway:驗證 JWT、套用流量規則、限速]
        │
        ▼
[最近的邊緣節點執行你的 Deno 函式]
        │  在這裡你可以…
        ├──▶ 用 service_role / anon key 讀寫 Postgres(可繞過或遵守 RLS)
        ├──▶ 帶著藏起來的金鑰呼叫第三方 API(OpenAI / Stripe / 寄信)
        └──▶ 做任何 TypeScript 邏輯(驗證、轉換、編排)
        │
        ▼
[回傳 Response 給前端]

三個要點:

  • 它是一個標準的 HTTP 端點。 每個 Edge Function 部署後都有一個 URL(https://<PROJECT_ID>.supabase.co/functions/v1/<function-name>),你可以用 supabase.functions.invoke()、fetch、curl、或讓外部服務(Webhook)打它。
  • 它「就地」整合 Supabase 生態系。 函式執行環境會自動注入一批環境變數(如 SUPABASE_URL、SUPABASE_SERVICE_ROLE_KEY),你不必手動設定就能在函式裡建立 Supabase 客戶端讀寫資料庫。
  • 它跑在「邊緣」。 相比集中式的傳統伺服器,Edge Functions 分散部署在全球節點,請求會路由到離使用者最近的節點執行,延遲更低。

二、Deno runtime:和 Node.js 的關鍵差異

Edge Functions 的執行環境是 Deno,不是 Node.js。這是入門時最容易誤會、也最需要先建立的觀念。Deno 的關鍵特性:

特性說明對你的影響
TypeScript 原生支援無需 tsconfig.json 或編譯步驟,直接執行 .ts寫完就跑,開發體驗輕快
Web 標準 API用標準 Request/Response、fetch、ReadableStreamhandler 收 Request 回 Response,和瀏覽器同一套
套件匯入用 URL/前綴支援 npm:、jsr:、esm.sh,無需 package.jsonimport ... from 'npm:zod' 直接載入
安全沙箱預設停用檔案系統與網路存取,需明確授權別預期能隨意讀寫本機檔案
隔離架構冷啟動極快isolate-based,冷啟動約 0–5ms適合高頻、短生命週期的請求

最重要的一句話:Deno 不是 Node,別假設 Node 的一切都能用。 具體來說:

  • 可以用的:純 JS/TS 的 npm 套件(npm:@supabase/supabase-js、npm:zod、npm:stripe 等)、所有 Web 標準 API(fetch、crypto、URL、TextEncoder…)。
  • 不能/不建議用的:依賴 Node 原生模組的套件(fs 檔案系統、net 等)、需要原生二進位或多執行緒的套件(sharp/libvips 影像處理)、以及假設 Node global 行為的舊套件。

所以在 Edge Function 裡,你發 HTTP 請求用的是全域的 fetch(Web 標準),而不是 Node 的 http/axios——這也是本篇範例會反覆出現 fetch 的原因。

三、Edge Function vs Database Function 怎麼選

這是入門就要建立、否則之後會一直踩的分界。兩者都是「伺服器端跑邏輯」,但適用場景完全不同:

維度Edge Function(Deno)Database Function(PL/pgSQL)
執行位置Deno 邊緣節點(資料庫之外)Postgres 內部(離資料最近)
語言TypeScript(完整程式語言)SQL/PL/pgSQL
最擅長呼叫第三方 API、藏金鑰、Webhook、複雜編排資料驗證、彙總計算、trigger、複雜查詢封裝
能藏 API 金鑰✅(金鑰放伺服器環境變數)⚠️ 不適合(存取外部世界很彆扭)
交易/RLS 整合需透過客戶端呼叫✅ 天生可包在交易裡、被 RLS/trigger 直接使用
網路往返客戶端 → Edge → DB客戶端 → DB(就地完成)

一個好記的判準:

邏輯若「圍繞資料、能用 SQL 表達、要在資料庫層就地完成」→ Database Function;邏輯若「要呼叫外部世界、需要程式語言彈性、要藏金鑰」→ Edge Function。

例如「計算某使用者本月訂單總額」——一句 SQL 就能做完,用 Database Function 最自然、最快,還能被 RLS 直接管。反過來「拿文章內容去呼叫 OpenAI 生成摘要,再存回資料庫」——這需要藏 OPENAI_API_KEY、需要 fetch 外部 API、需要程式語言的錯誤處理,就非 Edge Function 莫屬。把彙總硬塞進 Edge Function 只會多繞一次網路往返;把呼叫外部 API 硬塞進 Database Function 則會讓可維護性變差。

實作範例

下面用可直接執行的指令與 TypeScript,走一遍「建立函式 → 寫 handler → 本機測試 → 前端呼叫」的完整入門流程。假設你已安裝 Supabase CLI 與 Docker,並在專案根目錄執行過 supabase init 與 supabase start。以下 API 名稱與行為細節請以官方文件為準。

一、建立第一個 Edge Function

用 CLI 建立一個名為 hello-world 的函式:

# 建立新函式:會產生 supabase/functions/hello-world/index.ts
supabase functions new hello-world

執行後專案會多出這樣的目錄結構:

supabase/
└── functions/
    └── hello-world/
        └── index.ts

打開 index.ts,預設內容大致長這樣——這就是一個最小的 Edge Function:

// supabase/functions/hello-world/index.ts
// Deno.serve:註冊一個 HTTP handler,收 Request、回 Response
Deno.serve(async (req: Request) => {
  // 從請求 body 讀取 JSON(Web 標準 API,和瀏覽器 fetch 同一套)
  const { name } = await req.json()

  const data = {
    message: `Hello ${name}!`,
  }

  // 回傳標準 Response 物件
  return new Response(JSON.stringify(data), {
    headers: { "Content-Type": "application/json" },
  })
})

三個入門重點:

  • Deno.serve(handler) 是進入點:它啟動一個 HTTP 伺服器,把每個進來的請求交給你的 handler。handler 是一個 async (req: Request) => Response 的函式。
  • req 是 Web 標準的 Request:req.json()、req.text()、req.headers.get(...)、req.method、req.url 這些都和你在瀏覽器用的一模一樣。
  • 回傳的是 Web 標準 Response:你可以 new Response(...),或用更方便的 Response.json({...})(自動設好 Content-Type)。

二、寫一個「呼叫第三方 API」的實用 handler

hello-world 太簡單,看不出 Edge Function 的價值。我們改寫成一個真正需要伺服器端的例子:接收一個城市名稱,去呼叫第三方天氣 API,把結果整理後回傳。重點是——API 金鑰藏在伺服器,前端完全碰不到。

// supabase/functions/get-weather/index.ts

// 在 handler「外部」讀取環境變數與初始化:只執行一次,避免每次請求重建
const WEATHER_API_KEY = Deno.env.get("WEATHER_API_KEY")!

Deno.serve(async (req: Request) => {
  // 統一用 try/catch 包住,未捕捉的例外會變成 500
  try {
    // 1) 只允許 POST
    if (req.method !== "POST") {
      return Response.json({ error: "Method not allowed" }, { status: 405 })
    }

    // 2) 讀取並驗證輸入
    const { city } = await req.json()
    if (!city || typeof city !== "string") {
      return Response.json({ error: "缺少 city 參數" }, { status: 400 })
    }

    // 3) 帶著藏起來的金鑰呼叫第三方 API(用 Web 標準 fetch,不是 axios)
    const url =
      `https://api.example-weather.com/v1/current` +
      `?key=${WEATHER_API_KEY}&q=${encodeURIComponent(city)}`

    const apiRes = await fetch(url)
    if (!apiRes.ok) {
      return Response.json(
        { error: "天氣服務暫時無法使用" },
        { status: 502 },
      )
    }

    const raw = await apiRes.json()

    // 4) 只把前端需要的欄位整理後回傳(也順便過濾敏感資訊)
    return Response.json({
      city,
      tempC: raw.current?.temp_c,
      condition: raw.current?.condition?.text,
    })
  } catch (err) {
    // 錯誤會出現在 Dashboard 的函式日誌
    console.error("get-weather 失敗:", err)
    return Response.json({ error: "Internal Server Error" }, { status: 500 })
  }
})

這個範例濃縮了 Edge Function 的三大價值:藏金鑰(WEATHER_API_KEY 在伺服器)、呼叫外部 API(fetch)、伺服器端邏輯(驗證輸入、整理輸出、統一錯誤處理)。注意昂貴的初始化(讀環境變數、之後你會建立的 Supabase 客戶端)都放在 Deno.serve 外面,這樣冷啟動一次後就能重複使用。

本機測試時,WEATHER_API_KEY 要放進 supabase/functions/.env(或用 --env-file 指定)。Secrets 的完整管理留給下一篇,這裡先知道「金鑰從 Deno.env.get() 讀、絕不寫死在程式碼」即可。

三、本機啟動與測試

寫好之後,先在本機跑起來。CLI 內建 Deno 執行環境與熱重載:

# 啟動所有函式的本機開發伺服器(含熱重載,改檔即時生效)
supabase functions serve

# 只啟動特定函式,並指定環境變數檔
supabase functions serve get-weather --env-file supabase/functions/.env

啟動後,函式的本機位址為 http://localhost:54321/functions/v1/<函式名稱>。用 curl 送一個請求測試(本機呼叫需帶上 supabase start 顯示的 anon key):

# 用環境變數存放 anon key 比較方便
export ANON_KEY="<你的 anon key,supabase start 會印出>"

curl -X POST "http://localhost:54321/functions/v1/get-weather" \
  -H "Authorization: Bearer $ANON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"city":"Taipei"}'

# 預期回傳類似:
# {"city":"Taipei","tempC":28.5,"condition":"Partly cloudy"}

如果請求成功、日誌沒有紅字,代表函式邏輯正確。console.log/console.error 的輸出會即時印在 supabase functions serve 的終端機(部署後則出現在 Dashboard 的函式日誌)。

四、從前端呼叫:supabase.functions.invoke

本機驗證通過後,換前端呼叫。用 supabase-js 的 functions.invoke() 最方便——它會自動帶上目前登入使用者的 JWT 與 apikey,你不必手動組標頭:

import { createClient } from "@supabase/supabase-js"

const supabase = createClient(
  "https://your-project.supabase.co",
  "YOUR_PUBLISHABLE_KEY", // 前端用的可公開金鑰
)

async function fetchWeather(city: string) {
  // 第一個參數是函式名稱,body 會被序列化成 JSON 送出
  const { data, error } = await supabase.functions.invoke("get-weather", {
    body: { city },
  })

  if (error) {
    // invoke 失敗(網路、CORS、非 2xx 回應、或函式拋錯)都會進這裡
    console.error("呼叫函式失敗:", error.message)
    return null
  }

  console.log("天氣:", data) // { city, tempC, condition }
  return data
}

fetchWeather("Taipei")

invoke 的幾個入門重點:

  • 第一個參數是函式名稱(不是完整 URL),supabase-js 會自動組出 /functions/v1/get-weather。
  • body 會被序列化成 JSON,對應到 handler 裡的 await req.json()。
  • JWT 自動帶入:登入使用者的 token 會自動附上;因此 handler 預設會通過 JWT 驗證。若要做公開端點(免登入),部署時用 --no-verify-jwt(下一篇會談)。

五、處理 CORS:瀏覽器呼叫的必修課

上面的 get-weather 從 curl 或伺服器端呼叫沒問題,但從瀏覽器呼叫時,你很可能會撞上 CORS(跨來源資源共享)。原因是瀏覽器在正式請求前會先送一個 OPTIONS preflight 探路,你的函式若不回應它、或回應沒帶對標頭,請求就會被瀏覽器擋下。

正確做法:在 handler 開頭處理 OPTIONS,並讓所有回應都帶上 CORS 標頭。把 CORS 標頭抽成共用常數:

// supabase/functions/_shared/cors.ts
// 以 _ 開頭的資料夾不會被部署為獨立函式,適合放共用模組
export const corsHeaders = {
  "Access-Control-Allow-Origin": "*", // 正式環境建議指定具體網域
  "Access-Control-Allow-Headers":
    "authorization, x-client-info, apikey, content-type",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
}

在函式裡套用:

// supabase/functions/get-weather/index.ts(加上 CORS 版)
import { corsHeaders } from "../_shared/cors.ts"

const WEATHER_API_KEY = Deno.env.get("WEATHER_API_KEY")!

Deno.serve(async (req: Request) => {
  // 1) 先回應瀏覽器的 preflight(OPTIONS),否則跨來源請求會被擋
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders })
  }

  try {
    const { city } = await req.json()
    if (!city) {
      return Response.json(
        { error: "缺少 city 參數" },
        { status: 400, headers: corsHeaders }, // 2) 錯誤回應也要帶 CORS 標頭
      )
    }

    const apiRes = await fetch(
      `https://api.example-weather.com/v1/current` +
        `?key=${WEATHER_API_KEY}&q=${encodeURIComponent(city)}`,
    )
    const raw = await apiRes.json()

    // 3) 成功回應同樣帶上 CORS 標頭
    return Response.json(
      { city, tempC: raw.current?.temp_c, condition: raw.current?.condition?.text },
      { headers: corsHeaders },
    )
  } catch (err) {
    console.error("get-weather 失敗:", err)
    return Response.json(
      { error: "Internal Server Error" },
      { status: 500, headers: corsHeaders },
    )
  }
})

記住一句話:只要函式會被瀏覽器直接呼叫,就一定要處理 OPTIONS 並在每個回應帶上 CORS 標頭。 「curl 通、瀏覽器不通」幾乎都是這關沒過。

常見錯誤與最佳實踐

坑一:把 Edge Function 當 Node.js 寫,直接搬 fs、axios、sharp。 Edge Function 跑在 Deno,不是 Node。import fs from 'fs' 讀本機檔案會失敗(沙箱預設關閉檔案系統)、sharp 這類需要原生多執行緒的影像套件不支援、部分假設 Node global 的舊套件也會爆。正確做法:發 HTTP 用 Web 標準 fetch(不是 axios/Node http);需要 npm 套件時用 npm: 前綴匯入(如 npm:@supabase/supabase-js@2),匯入優先序 npm: > jsr: > esm.sh;遇到套件裝不起來,先確認它是不是碰了 Node 原生能力。

坑二:把該用 SQL 的邏輯硬塞進 Edge Function。 「算某使用者本月訂單總額」一句 SQL 就能在資料庫就地完成,硬做成 Edge Function 反而多一次網路往返(客戶端 → Edge → DB → Edge → 客戶端),還失去交易與 RLS 的天然整合。正確做法:圍繞資料、能用 SQL 表達的邏輯(驗證、彙總、trigger)交給 Database Function;只有「要呼叫外部世界、要藏金鑰、要程式語言彈性」時才動用 Edge Function。選對工具比寫得漂亮更重要。

坑三:瀏覽器呼叫時沒處理 CORS,一直被擋。 函式在 curl 下正常,一放到網頁就 CORS error——因為沒回應 OPTIONS preflight,或回應沒帶 Access-Control-Allow-* 標頭。正確做法:handler 開頭處理 req.method === 'OPTIONS' 回一個帶 CORS 標頭的回應,並讓成功與錯誤的每個回應都帶上同一組 CORS 標頭(把它抽成 _shared/cors.ts 共用);正式環境把 Access-Control-Allow-Origin 從 * 收斂成你的具體網域。

坑四:把 API 金鑰寫死在程式碼、甚至寫進前端。 金鑰硬編碼進函式原始碼、或想省事直接放前端,等於公開你的付費 API 帳號。正確做法:金鑰一律用 Deno.env.get('XXX_KEY') 從環境變數讀取,本機放 supabase/functions/.env(記得加進 .gitignore),正式環境用 Secrets 管理(下一篇詳談);Edge Function 存在的一大理由就是當金鑰的保險箱,別讓它外洩。

坑五:不做錯誤處理,函式一拋錯就回神秘的 500。 未捕捉的例外會讓函式回傳 500,前端只看到「呼叫失敗」卻查不出原因。正確做法:用 try/catch 包住 handler 主體,回傳語意明確的狀態碼(400 輸入錯、401 未授權、502 上游 API 掛了、500 內部錯),並用 console.error 記錄細節——這些日誌會出現在 supabase functions serve 終端機與 Dashboard 的函式日誌,是你排查問題的主要依據。

坑六:每次請求都重新初始化昂貴資源。 把 createClient(...)、讀環境變數、建連線池都寫進 Deno.serve 的 handler 裡,每個請求都重做一次,浪費且變慢。正確做法:把只需做一次的初始化放在 Deno.serve 外面(模組層級),利用 Deno isolate 冷啟動後會重複使用實例的特性,讓客戶端與設定只建立一次。

最佳實踐總結:

  • 記住這是 Deno 不是 Node:fetch 而非 axios、npm: 前綴匯入、別碰 Node 原生模組
  • 選對工具:資料/SQL 邏輯用 Database Function,外部 API/藏金鑰/編排用 Edge Function
  • 瀏覽器呼叫必處理 CORS:回應 OPTIONS、每個回應帶 CORS 標頭、正式環境收斂 origin
  • 金鑰只從環境變數讀:Deno.env.get(),.env 進 .gitignore,讓 Edge Function 當保險箱
  • 統一 try/catch 與語意化狀態碼:搭配 console.error/函式日誌排查
  • 昂貴初始化放 handler 外:善用 isolate 重用,降低冷啟動後的開銷
  • 不確定的 API 以官方為準:Deno.serve、functions.invoke、CLI 指令、npm/jsr 支援細節都可能調整,正式決策前查官方文件

小結

這是 Supabase 系列教學的第 031 篇,也是 SB-4 運算與 AI 的開篇。上一篇《Realtime Authorization 與實戰》我們用 config: { private: true } 與 realtime.messages 的 RLS policy,做出安全、可上線的即時聊天室,替 SB-3 核心服務收尾。這一篇我們正式踏進伺服器端運算——認識以 Deno 為底層的 Edge Functions,從 supabase functions new 建立、用 Deno.serve 寫 handler、supabase functions serve 本機測試,到用 supabase.functions.invoke 從前端呼叫,並釐清了它與 Database Functions 的分工。

核心觀念濃縮成一句話:Edge Function 是跑在 Deno 邊緣節點的無伺服器 TypeScript 函式,用 Deno.serve 接收 Request、回傳 Response;它擅長呼叫第三方 API、隱藏金鑰、處理 Webhook 與伺服器編排(這些用 SQL 很彆扭),而圍繞資料、能用 SQL 表達的邏輯則交給 Database Function;因為底層是 Deno 不是 Node,要用 fetch 與 npm: 匯入、避開 Node 原生模組;只要被瀏覽器呼叫就一定要處理 CORS。

  • 執行環境:Deno(TypeScript 原生、Web 標準 API、npm:/jsr:/esm.sh 匯入、無需 package.json)
  • 入門四步:functions new 建立 →Deno.serve 寫 handler → functions serve 本機測試 → functions.invoke 前端呼叫
  • 何時用:呼叫第三方 API、藏金鑰、Webhook、複雜編排 → Edge Function;資料/SQL 邏輯 → Database Function
  • 兩大必修:金鑰用 Deno.env.get() 讀、瀏覽器呼叫要處理 CORS

你已經寫出並在本機跑通了第一個 Edge Function,但它還沒真正上線,金鑰也還躺在本機的 .env 裡。下一篇《Secrets、環境與部署》,我們要處理「怎麼把私密金鑰安全地交給正式環境的函式」以及「怎麼把函式部署到 Supabase 的全球邊緣網路」,讓你的第一個函式真正對外服務。運算篇章已經啟程,下一篇見。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →