首页
/ 编写高质量的 Supabase Edge Functions:从请求处理器契约到 `withSupabase` 安全封装的完整实践指南

编写高质量的 Supabase Edge Functions:从请求处理器契约到 `withSupabase` 安全封装的完整实践指南

2026-09-06 18:46:59作者:庞眉杨Will

本文源自 Supabase 仓库中为 AI 编码助手(Cursor Rules)准备的 edge-functions.md,系统化整理了在 TypeScript/Deno 运行时中编写 Supabase Edge Functions 的 13 条核心准则与 5 个可直接落地的函数模板。无论你是要为自己的项目编写第一个边缘函数,还是希望用 AI 辅助生成安全、可维护的 Serverless 代码,读完本文后你都能掌握:标准请求处理器 export default { fetch } 的写法、基于 withSupabase 的一行式鉴权/客户端注入、npm:/jsr: 规范的依赖管理,以及多路由、后台任务、临时文件等边界场景的正确姿势。

一句话背景:Supabase Edge Functions 是什么

Supabase Edge Functions 是在 Deno JavaScript 运行时上运行的 Serverless 函数,用于处理鉴权回调、Webhook、图像/文档生成、AI 推理等需要贴近用户或第三方系统触发的逻辑。整个仓库本身(docs 搜索、RAG、og-image 等场景)也是由这些函数支撑的,例如 supabase/functions 目录下就运行着 search-embeddingsog-images 两个面向文档站的边缘函数。本文的 13 条准则,正是从这类真实生产函数中提炼出的"避免踩坑清单"。

准则 1:优先使用 Web API 与 Deno 核心 API

Edge Functions 运行在 Deno 上,能直接使用标准的 Web 平台 API,因此优先用内置能力代替第三方依赖

  • fetch 代替 Axios;
  • 用 WebSockets API 代替 node-ws
  • Response.jsonRequestURL 等标准接口处理 HTTP。

仓库里的真实例子可以佐证这一点:location 函数 直接使用 req.headers.get(...) 读取 x-forwarded-for 头,并用全局 fetch 调用 ipinfo.io 的 IP 定位 API;而 postgres-on-the-edge 函数 则直接基于 Response.json 组装返回值。减少依赖意味着更小的冷启动体积、更少的供应链风险,也更贴合 Serverless 的运行时约束。

准则 2:公共工具放进 _shared,用相对路径导入

当多个 Edge Functions 需要复用同一段工具逻辑时:

  • 把共享代码放进 supabase/functions/_shared 目录;
  • 通过相对路径导入,例如 import { AuthMiddleware } from '../_shared/jwt/default.ts'
  • 严禁在 Edge Functions 之间产生相互依赖(每个函数应能独立部署)。

仓库中的 examples 项目就完整实践了这一点:custom-jwt-validation../_shared/jwt/default.ts 导入鉴权中间件,drizzle 复用 ../_shared/schema.ts 中的数据库表结构,unit-testing 复用 ../_shared/types.ts 类型定义。这种布局让每个函数保持单一入口,便于 CI 单独构建与部署。

准则 3 与 4:外部依赖必须显式加前缀并锁定版本

Deno 的模块解析默认不支持 Node 风格的裸模块名,因此:

  1. 不要使用裸标识符(bare specifiers)。需要外部依赖时,必须使用 npm:jsr: 前缀,例如 @supabase/supabase-js 应写作 npm:@supabase/supabase-js
  2. 外部导入必须定义版本号,例如 npm:express 应写作 npm:express@4.18.2

仓库内两个方向都能找到依据:新风格函数(如 location)使用 npm:@supabase/server@^1;老风格函数(如 postgres-on-the-edge)使用 jsr:@db/postgres@^0,并明确锁定了主版本。为稳定起见始终锁定版本,能避免上游发版导致的不可控行为变化。

准则 5:npm:/jsr: 优先,少用 CDN 直链

对于 deno.land/xesm.shunpkg.com 等 CDN 上的包,准则给出的处理方式是:把 CDN 主机名替换为 npm: 标识符,统一走 npm:/jsr: 注册中心。

需要说明的是,本仓库存在历史遗留代码并不完全遵循这条建议——例如 supabase/functions/search-embeddings/index.ts 中仍能看到 import 'https://deno.land/x/xhr@0.2.1/mod.ts'https://esm.sh/openai@3.1.0 的写法。这正说明该规则是写给新代码的演进方向:新函数(用 withSupabase 的模板)一律走 npm:/jsr:,老函数逐步迁移。写新代码时应直接遵循本条准则。

准则 6:需要时可用 Node 内置 API

Deno 提供 Node 兼容层,Node 的内置模块通过 node: 前缀导入:

import process from 'node:process'
import { randomBytes } from 'node:crypto'

触发条件是在 Deno 原生 API 存在缺口时才使用 Node APInode: 前缀让 Deno 运行时能正确识别并加载对应的 Node 兼容实现,同时保留将来迁移到纯 Node 环境的可能性。

准则 7:请求处理器契约——export default { fetch }

这是 Edge Functions 最重要的结构性约定。不要使用 import { serve } from "https://deno.land/std@0.168.0/http/server.ts",也不要使用 Deno.serve 自建服务器。正确姿势是导出一个带 fetch 方法的默认对象:

export default {
  fetch: async (req: Request) => {
    return Response.json({ message: 'Hello world' })
  },
}

这是 Supabase Edge Functions 的请求处理器契约,同样无需修改即可运行在 Cloudflare Workers 与 Bun 上,具有极强的可移植性。对照仓库可以发现这种演进趋势:较新的示例函数(location、postgres-on-the-edge 等)全部采用 export default { fetch } 结构,而 search-embeddings 这类较早的函数仍用 Deno.serve,二者并存印证了契约的迁移路径。按准则第 8 条,正式函数还应把该 handler 用 withSupabase 包装起来。

准则 8:用 withSupabase 一次性解决鉴权、授权、客户端与 CORS

npm:@supabase/server@^1 导出的 withSupabase 封装器,把以下重复劳动收敛为一行代码:

  • Authentication(认证):校验调用方的凭证;
  • Authorization(授权):只放行符合你所声明 auth 模式的调用方;
  • 预配置客户端ctx.supabase(按调用方 RLS 策略限定作用域)与 ctx.supabaseAdmin(绕过 RLS);
  • CORS 处理:包括 preflight 预检请求。

你唯一需要做的决定是选择 auth 模式。基础用法如下:

import { withSupabase } from 'npm:@supabase/server@^1'

export default {
  fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
    const { data, error } = await ctx.supabase.from('countries').select('*')
    if (error) throw error
    return Response.json({ data })
  }),
}

依据调用方选择 auth 模式

调用方 auth 取值 verify_jwt 注入的客户端
已登录用户(Authorization 头携带 JWT) 'user' true(默认,可省略) ctx.supabase(受 RLS 约束)
Cron、worker、pg_net 或其他函数 'secret' false ctx.supabaseAdmin(绕过 RLS)
公开客户端 'publishable' false ctx.supabase
公开端点或外部 webhook(需在代码内自行校验) 'none' false 需要时用 ctx.supabaseAdmin

对应到仓库证据:

  • 需要登录用户访问的 location 函数 使用 withSupabase({ auth: 'user' }, ...),并在 config.toml 中保留 verify_jwt = true(该文件里 location 段即如此配置);
  • 需要公开访问的函数(如 browser-with-corsdiscord-botstripe-webhookstelegram-bot)则统一在 config.toml 中声明 verify_jwt = false

关闭 JWT 校验的配置

对任何非 'user' 模式,都必须在 supabase/config.toml 中为对应函数显式设置 verify_jwt = false

[functions.my-function]
verify_jwt = false

本仓库根目录的 supabase/config.toml 中就有这样的真实案例:[functions.search-embeddings] verify_jwt = false,表明该函数不依赖调用方 JWT(其鉴权在代码内通过 secret key 完成)。examples 项目下的 config.toml 更是把几十个函数的 verify_jwt 逐一显式声明,是理解该配置的最佳范本。注意:config.toml 中函数名是目录名(例如 simple-mcp-server 需配合 entrypoint 指定嵌套路径,见该文件中 [functions.simple-mcp-server] 段的写法)。

更细粒度的密钥鉴权

  • ctx.userClaims 保存经过验证的用户身份信息;
  • 若只想接受某一把指定的命名密钥,使用 auth: 'secret:<name>'auth: 'publishable:<name>'
  • 公开端点使用 auth: 'none',仍能获得 CORS 处理与 ctx.supabaseAdmin

准则 9:认识内置环境变量(无需手动设置)

以下环境变量在本地与云端 Supabase 环境中都会被自动预填,用户无需手动配置:

  • SUPABASE_URL:项目 API 地址;
  • SUPABASE_PUBLISHABLE_KEYS:可发布密钥(publishable keys)的 JSON 映射;
  • SUPABASE_SECRET_KEYS:服务端密钥(secret keys)的 JSON 映射;
  • SUPABASE_DB_URL:Postgres 连接串(用于直连数据库,如 postgres-on-the-edge 中的用法)。

withSupabase 会自动读取这些变量,因此优先依赖它而不是手工读 key。如果确实要绕过 SDK 手工读取某个 key,需要解析 JSON 映射并按名字索引:

const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
// 取默认 secret key
const defaultKey = SUPABASE_SECRET_KEYS['default']

publishable keys 通过 SUPABASE_PUBLISHABLE_KEYS 以同样方式解析。注意:示例里的早期函数(search-embeddings)还依赖旧的 SUPABASE_SERVICE_ROLE_KEY 单值变量并会报"Missing environment variable"错误,这说明新版多 key 机制(SECRET_KEYS/PUBLISHABLE_KEYS)正是为了取代旧变量而引入的——新代码应直接使用前者。

准则 10:设置其他自定义密钥(secrets)

除上述内置变量外的自定义环境变量,通过 env 文件批量注入:

supabase secrets set --env-file path/to/env-file

仓库示例同样印证了 env-file 工作流,例如 elevenlabs-speech-to-text 等函数 依赖 ELEVENLABS_API_KEY 一类自定义密钥;location 示例使用 IPINFO_TOKEN,同样需要开发者自行通过 supabase secrets set 配置后才能在 index.ts 中通过 Deno.env.get('IPINFO_TOKEN') 读取。本地开发时这类变量通常放在 .env 文件中,部署到远端则用上述命令上传。

准则 11:单函数多路由——建议用 Hono 或 Express

一个 Edge Function 可以承载多个路由。对于需要维护的复杂函数,建议引入路由库(如 Hono 或 Express),更便于开发者理解与维护。有两个关键点:

  1. 每个路由必须以 /function-name 前缀开头,确保请求被正确路由到对应函数(Edge Functions 的请求路径与函数名强绑定);
  2. 若使用 Hono 并需要按路由做 Supabase 鉴权,使用 npm:@supabase/server@^1/adapters/hono 提供的适配器。

用 Express 组织函数内路由

import express from 'npm:express@^5'

const app = express()

app.get(/(.*)/, (req, res) => {
  res.send('Welcome to Supabase')
})

app.listen(8000)

Express 示例在 app.listen(8000) 处监听端口——这正是 Supabase Edge Functions 提供给函数体的内部端口约定,外部流量由平台网关转发进来。同理,使用 node:httpcreateServer 自建 HTTP 服务时也应监听该固定端口(见"示例函数:使用 Node 内置 API")。仓库中 restful-tasks 即展示了在单个函数内组织多个 RESTful 路由的完整结构。

准则 12:文件写入只允许在 /tmp 目录

Edge Functions 是无状态的,运行时文件系统基本不可写,唯一的例外是 /tmp 临时目录。所有文件写入操作(无论用 Deno 还是 Node 的 File API)都只能在 /tmp 下进行,且数据不会在多次调用间持久化。涉及图像处理、PDF 生成、缓存下载内容等场景(例如仓库里的 image-manipulationpuppeteer 函数)都必须遵守此约束,需要持久化时应写入 Supabase Storage。

准则 13:用 EdgeRuntime.waitUntil 执行后台长任务

在函数响应返回后仍需执行的长任务(如清理、异步通知、埋点上报),应使用静态方法:

EdgeRuntime.waitUntil(promise)

它能让你把耗时的后台任务挂到请求生命周期之外,不阻塞当前响应。但不要假设它在所有请求/执行上下文中都可用——在调用前需要做能力探测或把调用收敛到统一封装中,以保证在纯 Deno 或其它运行时下的兼容回退。

可直接复用的函数模板

原文档提供了 5 个开箱即用的模板,下面逐一给出完整实现与要点注释。

模板 1(推荐):基于 withSupabase 的 CRUD 函数

import { withSupabase } from 'npm:@supabase/server@^1'

export default {
  fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
    const { data, error } = await ctx.supabase.from('countries').select('*')
    if (error) throw error
    return Response.json({ data })
  }),
}

这是社区推荐的标准形态:鉴权、RLS 作用域客户端、CORS 全部由封装器完成,函数体只需关注业务。部署时配合 verify_jwt = true(默认值)即可。仓库中的 location 以及 select-from-table-with-auth-rls 都是这一形态的实例。

模板 2:简单 Hello World

interface reqPayload {
  name: string
}

console.info('server started')

export default {
  fetch: async (req: Request) => {
    const { name }: reqPayload = await req.json()
    const data = {
      message: `Hello ${name} from foo!`,
    }

    return Response.json(data)
  },
}

注意模板中 interface reqPayload 的命名略不规范,实际项目中建议改写为 PayloadRequestBodyconsole.info('server started') 会在函数冷启动时输出一次,可用于确认部署成功。

模板 3:使用 Node 内置 API 的函数

import { randomBytes } from 'node:crypto'
import { createServer } from 'node:http'
import process from 'node:process'

const generateRandomString = (length) => {
  const buffer = randomBytes(length)
  return buffer.toString('hex')
}

const randomString = generateRandomString(10)
console.log(randomString)

const server = createServer((req, res) => {
  const message = `Hello`
  res.end(message)
})

server.listen(9999)

展示 node: 前缀导入与 Node HTTP 服务的写法。需要提醒的是,server.listen(9999) 是非标准端口,实际部署时应监听平台约定的内部端口(参见准则 11 中 Express listen(8000) 的说明),并优先考虑直接使用 export default { fetch } 契约而非自建服务。类型标注上建议补全:(req, res)(length) 都未声明类型,生产代码应显式标注为 Request/ServerResponse 等 Node 类型(通过 npm:@types/node 引入)。

模板 4:在函数中使用 npm 包

import express from 'npm:express@^5'

const app = express()

app.get(/(.*)/, (req, res) => {
  res.send('Welcome to Supabase')
})

app.listen(8000)

结合准则 3、4、5 理解:npm:express@^5 同时满足"前缀化"与"版本锁定"两条要求。app.get(/(.*)/, ...) 使用正则捕获全部 GET 路径,是兜底路由的常见写法。

模板 5:用内置 @Supabase.ai API 生成向量嵌入

const model = new Supabase.ai.Session('gte-small')

export default {
  fetch: async (req: Request) => {
    const params = new URL(req.url).searchParams
    const input = params.get('text')
    const output = await model.run(input, { mean_pool: true, normalize: true })
    return Response.json(output)
  },
}

Supabase.ai.Session('gte-small') 在函数运行时直接初始化嵌入模型会话;model.run(input, { mean_pool, normalize }) 完成推理,mean_pool: true 表示对 token 向量取均值池化、normalize: true 表示对结果做 L2 归一化,二者配合可让余弦相似度直接作为向量检索的评分。该能力适合在不额外调用外部 AI 服务的情况下快速产出向量——这也正是本仓库 向量搜索迁移 方向背后的基础设施之一。

在仓库中继续深挖的路径指引

小结:写 Edge Functions 的最终检查清单

动笔(或让 AI 代笔)生成函数代码前,对照这 13 条逐一检查:

  1. ✅ 优先 Web API / Deno 核心 API,避免多余依赖;
  2. ✅ 复用代码收进 _shared 并相对路径导入,函数间零耦合;
  3. ✅ 外部依赖必须带 npm:/jsr: 前缀;
  4. ✅ 所有外部导入锁定版本号;
  5. ✅ 用 npm:/jsr: 替换 deno.land/xesm.shunpkg.com 直链;
  6. ✅ Node 内置模块用 node: 前缀导入;
  7. ✅ 采用 export default { fetch } 处理器契约,不用 Deno.serve 或旧版 std serve
  8. ✅ 用 withSupabase 声明 auth 模式,非 'user' 模式同步在 config.tomlverify_jwt = false
  9. ✅ 优先使用预填的内置环境变量,不手工拼读 secret key;
  10. ✅ 自定义密钥用 supabase secrets set --env-file 管理;
  11. ✅ 多路由函数用 Hono/Express,路由加 /function-name 前缀,Hono 鉴权走 @supabase/server 适配器;
  12. ✅ 任何文件写入只允许落在 /tmp
  13. ✅ 长任务用 EdgeRuntime.waitUntil 且做好可用性降级。

遵循这套准则产出的函数,天然具备可移植性、最小依赖面与内建安全边界,既能在本地 supabase start 中无缝调试,也能平滑部署到托管环境。

登录后查看全文
热门项目推荐
相关项目推荐