Supabase Edge Functions 生成 Open Graph 图片并用 Storage CDN 做缓存:解析 supabase 仓库中的 lwx-og 示例
本篇以 lwx-og 函数的说明文档 为主体,讲清「在 Supabase Edge Function 里用 Deno + React 服务端渲染实时生成 Open Graph 图片,再把产物上传到 Supabase Storage 公共桶、由 Storage CDN 托管缓存」这一完整方案。读完后你可以复现本地运行与部署流程,并理解从请求解析、数据库取数、ImageResponse 渲染到 CDN 缓存穿透的每一步实现,以及它与 lwx-ticket、lwx-ticket-og 两个伴生函数的协作关系。
方案要解决什么问题
Open Graph(OG)图片决定了链接分享到社交平台时的卡片预览效果。传统做法是预生成静态图,但 lwx-og 这类「个性化分享卡」(Supabase Launch Week 的门票卡片)需要为每个用户动态生成一张独一无二的图片,且会随用户资料、分享行为(例如同时在 Twitter 和 LinkedIn 分享后升级 platinum 版)而变化。
该 README 给出的一行式方案概括是:用 Deno 和 Supabase Edge Functions 生成 Open Graph 图片,并用 Supabase Storage 的 CDN 缓存生成后的图片。即把「生成」放在边缘函数、「存储 + 分发缓存」交给 Storage 公共桶,两者各取所长。
整体架构与请求流程
从源码结构看,整个链路如下:
- 社交爬虫或浏览器请求
GET /functions/v1/lwx-og?username=<用户名>; - 函数用 service role 客户端查 Postgres(实际查的是物化视图
lwx_tickets_golden),拿到姓名、票号、分享状态等数据; - 根据票种(regular / platinum / secret)选择配色与背景素材,用
ImageResponse渲染一张 1200×628 的 PNG; - 把 PNG 以
upsert方式上传到 Storage 的images桶,路径为lwx/og/<票种>/<用户名>.png; - 触发伴生函数
lwx-ticket-og(fire-and-forget),让它再合成一张「嵌入门票」的复合 OG 卡片; - 最后
return await fetch(公共 Storage URL + 时间戳)——响应实际是 Storage 公共对象,经 Storage CDN 回源并缓存,后续同一路径请求直接由 CDN 命中。
关键文件只有两个:
- 入口 apps/www/supabase/functions/lwx-og/index.ts:基于 Deno 标准库的 HTTP server 挂载 handler;
- 核心 apps/www/supabase/functions/lwx-og/handler.tsx:全部业务逻辑(取数、渲染、上传、联动)。
入口文件非常薄,仅做依赖声明和挂载:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { handler } from './handler.tsx'
console.log(`Function "lwx-og" up and running`)
serve(handler)
本地运行与部署(README 原文操作)
README 给出的本地运行步骤:
supabase start
supabase functions serve lwx-og --no-verify-jwt --env-file ./supabase/.env.local
然后访问本地函数端点(Supabase CLI 的本地端点路径与函数名一致):
http://localhost:54321/functions/v1/lwx-og?username=thorwebdev
说明:README 原文 中「Navigate to」与 Demo 一行写的是同目录
lwx-ticket函数的端点(/functions/v1/lwx-ticket?username=thorwebdev),这是姊妹函数的门票图端点;本文函数lwx-og对应的本地路径为/functions/v1/lwx-og。
两个参数值得注意:
--no-verify-jwt:关闭 JWT 校验。OG 图片是由社交平台爬虫抓取的,爬虫不会携带 Supabase JWT,所以必须允许匿名访问;--env-file ./supabase/.env.local:本地显式注入环境变量(如函数用到的MISC_USE_URL、MISC_USE_SERVICE_ROLE_KEY),部署时则由 Supabase 平台侧提供。
部署命令只有一条:
supabase functions deploy lwx-og --no-verify-jwt
README 还列出了参考资源:og_edge 库的官方文档(deno.land/x/og_edge,README 中写的是 0.0.2 版)以及 Vercel 文档中的 OG Image Generation 示例章节——og_edge 正是 Vercel OG 方案在 Deno 环境的移植实现。
核心实现详解(handler.tsx)
下面按 handler 的执行顺序拆解 handler.tsx。
依赖与模块级初始化
文件头导入三样核心依赖(handler.tsx#L1-L3):
import { ImageResponse } from 'https://deno.land/x/og_edge@0.0.4/mod.ts'
import React from 'https://esm.sh/react@18.2.0?deno-std=0.140.0'
import { createClient } from 'jsr:@supabase/supabase-js@2'
注意源码实际锁定的 og_edge 版本是 0.0.4,而 README 参考链接写的是 0.0.2,以源码为准。
模块加载时就发起了字体预取(handler.tsx#L10-L17)——字体和背景素材都放在同一个 Storage 公共桶里,由 Storage CDN 分发:
const STORAGE_URL = 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/lwx'
// Load custom font
const FONT_URL = `${STORAGE_URL}/font/CircularStd-Book.otf`
const MONO_FONT_URL = `${STORAGE_URL}/font/SourceCodePro-Regular.ttf?t=2023-07-18T13%3A03%3A29.474Z`
const font = fetch(new URL(FONT_URL, import.meta.url)).then((res) => res.arrayBuffer())
const mono_font = fetch(new URL(MONO_FONT_URL, import.meta.url)).then((res) => res.arrayBuffer())
两个字体 fetch 被写成模块级 Promise:函数冷启动即开始下载 CircularStd-Book.otf(正文)与 SourceCodePro-Regular.ttf(等宽),渲染时 await 复用,避免每次请求重复拉字体。URL 里的 ?t=... 时间戳参数是典型的缓存穿透手段。
此外源码定义了 CORS 头,允许任意来源与授权头(handler.tsx#L5-L8),仓库中另有一个共享版 cors.ts 可供其他函数复用。
参数解析与数据库客户端
handler 从 URL 解析参数(handler.tsx#L44-L46):
const username = url.searchParams.get('username') ?? url.searchParams.get('amp;username')
const assumePlatinum = url.searchParams.get('platinum') ?? url.searchParams.get('amp;platinum')
amp;username 是 HTML 实体转义残留(& 被转义成 &)的兜底解析,说明该端点曾被以 HTML 属性形式内嵌过。缺少 username 时直接抛错并走 400 分支。
数据库客户端使用 service role(服务端特权密钥):
const supabaseAdminClient = createClient(
Deno.env.get('MISC_USE_URL') ?? '',
Deno.env.get('MISC_USE_SERVICE_ROLE_KEY') ?? ''
)
源码注释说明这两个环境变量在部署时由平台侧导出;service role 能绕过 RLS,仅适合放在服务端函数中,不能出现在浏览器代码里。
社交分享追踪与票种判定
函数利用请求头 user-agent 判断来源平台,首次被 Twitter / LinkedIn 抓取时把对应字段置为 'now'(handler.tsx#L60-L72):
if (userAgent?.toLocaleLowerCase().includes('twitter')) {
await supabaseAdminClient
.from(LW_TABLE) // lwx_tickets
.update({ sharedOnTwitter: 'now' })
.eq('username', username)
.is('sharedOnTwitter', null) // 只在首次分享时写入
} else if (userAgent?.toLocaleLowerCase().includes('linkedin')) {
// 同理写 sharedOnLinkedIn
}
.is('sharedOnTwitter', null) 保证只有「尚未分享过」的行才会被更新,实现幂等埋点。随后从物化视图读取展示数据(handler.tsx#L75-L87):
const LW_TABLE = 'lwx_tickets'
const LW_MATERIALIZED_VIEW = 'lwx_tickets_golden'
const { data, error } = await supabaseAdminClient
.from(LW_MATERIALIZED_VIEW)
.select('name, ticketNumber, sharedOnTwitter, sharedOnLinkedIn, metadata')
.eq('username', username)
.maybeSingle()
票种判定规则(handler.tsx#L85-L87):
metadata.hasSecretTicket为真 →secret;- 否则 Twitter 与 LinkedIn 都分享过 →
platinum; - 其余 →
regular。
若请求显式带 platinum 参数但用户实际不是 platinum,函数会直接返回桶内预置的趣味图 assets/golden_no_meme.png,而不是生成正式卡片——一个轻量的「防装」降级设计。三种票种各有独立的背景图、Logo 与配色(STYLING_CONGIF,handler.tsx#L22-L41),如 regular 是深灰底 #303030 + 浅色字,platinum/secret 是浅灰底 #f1f1f1 + 深色字。
说明:
lwx_tickets表与lwx_tickets_golden物化视图定义在 www 项目的 Supabase 数据库中;仓库里检入了它们的引用(上述两个函数)以及部分 Launch Week 相关迁移(如 20240723155310_add_lw12_ticketing_schema.sql),但从函数中select的字段列表(name、ticketNumber、sharedOnTwitter、sharedOnLinkedIn、metadata)可以完整推知视图对外暴露的列。
用 React JSX 渲染 1200×628 的 PNG
渲染部分把整张卡片写成一个 React 函数组件树传给 ImageResponse(handler.tsx#L128-L294)。布局常量(handler.tsx#L119-L126):
const OG_WIDTH = 1200
const OG_HEIGHT = 628
const TICKET_WIDTH = 1000
const TICKET_HEIGHT = TICKET_WIDTH / 2
const TICKET_PADDING_X = 60
const TICKET_PADDING_Y = 60
const OG_PADDING_X = (OG_WIDTH - TICKET_WIDTH) / 2
const OG_PADDING_Y = (OG_HEIGHT - TICKET_HEIGHT) / 2
卡片由四层构成:position: absolute 铺满的 OG 背景图(zIndex 0)、居中圆角带阴影的票面背景图(zIndex 1)、左上角的姓名 + 头衔/公司名区块(无元数据时回退显示 @username)、左下角的 Logo + 补零后的票号(NO 00000001 风格)+「Launch Week X / DEC 11-15 / 8AM PT」文字区块。字体通过 fonts 选项以 ArrayBuffer 注入渲染引擎:
{
width: OG_WIDTH,
height: OG_HEIGHT,
fonts: [
{ name: 'Circular', data: fontData, style: 'normal' },
{ name: 'SourceCodePro', data: monoFontData, style: 'normal' },
],
headers: {
'content-type': 'image/png',
'cache-control': 'public, max-age=31536000, s-maxage=31536000, no-transform, immutable',
'cdn-cache-control': 'max-age=31536000',
},
}
这里 cache-control 声明了「一年、immutable」的激进缓存策略:票卡内容稳定后几乎不变,适合长缓存;真正需要刷新时靠下面上传 + 时间戳机制解决。
上传 Storage 并借 CDN 完成缓存(README 主题的核心)
渲染完成后立即上传(handler.tsx#L296-L307):
const { error: storageError } = await supabaseAdminClient.storage
.from('images')
.upload(`lwx/og/${ticketType}/${username}.png`, generatedTicketImage.body!, {
contentType: 'image/png',
cacheControl: `0`,
// Update cached og image, people might need to update info
upsert: true,
})
三个细节是缓存策略的关键:
- 对象路径按「票种 + 用户名」确定性命名:同一用户同一票种永远命中同一路径,天然形成内容寻址;
upsert: true+cacheControl: '0':源码注释写明「用户可能会更新自己的资料」,所以允许覆盖写入、对象本身不设长缓存,靠下一次访问时重新生成来纠正内容;- 返回的是公共 Storage URL 而不是函数直出 PNG:
const NEW_TIMESTAMP = new Date()
return await fetch(`${STORAGE_URL}/og/${ticketType}/${username}.png?t=${NEW_TIMESTAMP}`)
函数 fetch 桶内刚写入的对象并把其响应体流式返回给调用方。这一跳让首次请求就经过 Storage CDN(回源拉取并缓存),而后续所有爬虫/浏览器的请求只要不带时间戳,就会直接命中 CDN 上已缓存的对象——这就是 README 标题「cache the generated image with Supabase Storage CDN」的含义:生成逻辑在边缘函数,但分发与缓存完全由 Storage CDN 承担,边缘函数不再成为热点。?t=${NEW_TIMESTAMP} 仅用于强制本次读取拿到最新写入的对象,避免上传后立即读旧缓存。
伴生函数联动:lwx-ticket-og
上传成功后,handler 还会以 fire-and-forget 方式触发另一个边缘函数(handler.tsx#L309-L321):
// Generate og image
fetch('https://obuldanrptloktxcffvn.supabase.co/functions/v1/lwx-ticket-og', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer <anon key>', // 源码中为内联的 anon JWT
},
body: JSON.stringify({ username, platinum }),
}).catch((err) => console.log('generate og err', err))
被触发的 lwx-ticket-og/handler.tsx 接收 { username, platinum },从 Storage 取出已生成的门票图(lwx/tickets/<票种>/v1/<用户名>.png),套上 og_bg_regular.png / og_bg_platinum.png 背景再渲染一张 1200×628 的复合卡片,同样以 upsert: true 写回 lwx/og/...,最后返回 JSON { success: true }。由于 lwx-og 主链路不等待这个 POST(.catch 吞掉异常),门票卡的首次可用性不受复合卡生成的影响,两条产物路径(门票图与复合 OG 卡)解耦。
同目录下的 lwx-ticket/handler.tsx 则是门票图本身的生成端(渲染 1200×628 门票、上传到 lwx/tickets/<票种>/<用户名>.png),README 里的 Demo 链接指向的就是它;再往前还有 Launch Week 11 时期的同构实现 lw11-og/README.md,其运行/部署命令与本文完全一致,可见这是该仓库沉淀下来的一套标准打法。
错误处理与响应约定
整段 handler 被 try/catch 包裹,任何失败(缺参数、查无此用户、Storage 上传失败等)统一返回 400 JSON 并带上 CORS 头(handler.tsx#L326-L331):
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
对爬虫而言,拿到 4xx 时社交平台会优雅地放弃卡片预览,因此「出错就返回结构化 JSON 400」是安全的降级。
可复用的工程要点小结
- 生成与分发解耦:动态图片在 Edge Function 里一次性渲染,产物落 Storage 公共桶,由 CDN 承接后续流量;
- 确定性路径 + upsert:用业务主键(票种/用户名)命名对象路径,允许覆盖以实现「内容可更新、路径不变」;
- 缓存策略分层:对象
cacheControl: 0保证内容可纠偏,响应头immutable一年缓存 + 时间戳查询参数做定向穿透; --no-verify-jwt只用于面向爬虫的端点,需要鉴权的业务端点不应照搬;service role 密钥只存在于函数环境;- 静态素材(字体、背景、Logo)也放进同一个 Storage 公共桶,让函数素材同样享受 CDN 分发,并用
?t=时间戳管理素材版本。
按 README 的命令本地 serve 验证、deploy 上线之后,这套「Edge Function 渲染 + Storage CDN 缓存」的 OG 图片方案即可直接迁移到自己的 Supabase 项目中使用。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00