首页
/ Supabase Edge Functions 生成 Open Graph 图片并用 Storage CDN 做缓存:解析 supabase 仓库中的 lwx-og 示例

Supabase Edge Functions 生成 Open Graph 图片并用 Storage CDN 做缓存:解析 supabase 仓库中的 lwx-og 示例

2026-09-06 16:48:01作者:翟江哲Frasier

本篇以 lwx-og 函数的说明文档 为主体,讲清「在 Supabase Edge Function 里用 Deno + React 服务端渲染实时生成 Open Graph 图片,再把产物上传到 Supabase Storage 公共桶、由 Storage CDN 托管缓存」这一完整方案。读完后你可以复现本地运行与部署流程,并理解从请求解析、数据库取数、ImageResponse 渲染到 CDN 缓存穿透的每一步实现,以及它与 lwx-ticketlwx-ticket-og 两个伴生函数的协作关系。

方案要解决什么问题

Open Graph(OG)图片决定了链接分享到社交平台时的卡片预览效果。传统做法是预生成静态图,但 lwx-og 这类「个性化分享卡」(Supabase Launch Week 的门票卡片)需要为每个用户动态生成一张独一无二的图片,且会随用户资料、分享行为(例如同时在 Twitter 和 LinkedIn 分享后升级 platinum 版)而变化。

该 README 给出的一行式方案概括是:用 Deno 和 Supabase Edge Functions 生成 Open Graph 图片,并用 Supabase Storage 的 CDN 缓存生成后的图片。即把「生成」放在边缘函数、「存储 + 分发缓存」交给 Storage 公共桶,两者各取所长。

整体架构与请求流程

从源码结构看,整个链路如下:

  1. 社交爬虫或浏览器请求 GET /functions/v1/lwx-og?username=<用户名>
  2. 函数用 service role 客户端查 Postgres(实际查的是物化视图 lwx_tickets_golden),拿到姓名、票号、分享状态等数据;
  3. 根据票种(regular / platinum / secret)选择配色与背景素材,用 ImageResponse 渲染一张 1200×628 的 PNG;
  4. 把 PNG 以 upsert 方式上传到 Storage 的 images 桶,路径为 lwx/og/<票种>/<用户名>.png
  5. 触发伴生函数 lwx-ticket-og(fire-and-forget),让它再合成一张「嵌入门票」的复合 OG 卡片;
  6. 最后 return await fetch(公共 Storage URL + 时间戳)——响应实际是 Storage 公共对象,经 Storage CDN 回源并缓存,后续同一路径请求直接由 CDN 命中。

关键文件只有两个:

入口文件非常薄,仅做依赖声明和挂载:

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_URLMISC_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 实体转义残留(& 被转义成 &amp;)的兜底解析,说明该端点曾被以 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_CONGIFhandler.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 函数组件树传给 ImageResponsehandler.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,
  })

三个细节是缓存策略的关键:

  1. 对象路径按「票种 + 用户名」确定性命名:同一用户同一票种永远命中同一路径,天然形成内容寻址;
  2. upsert: true + cacheControl: '0':源码注释写明「用户可能会更新自己的资料」,所以允许覆盖写入、对象本身不设长缓存,靠下一次访问时重新生成来纠正内容;
  3. 返回的是公共 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」是安全的降级。

可复用的工程要点小结

  1. 生成与分发解耦:动态图片在 Edge Function 里一次性渲染,产物落 Storage 公共桶,由 CDN 承接后续流量;
  2. 确定性路径 + upsert:用业务主键(票种/用户名)命名对象路径,允许覆盖以实现「内容可更新、路径不变」;
  3. 缓存策略分层:对象 cacheControl: 0 保证内容可纠偏,响应头 immutable 一年缓存 + 时间戳查询参数做定向穿透;
  4. --no-verify-jwt 只用于面向爬虫的端点,需要鉴权的业务端点不应照搬;service role 密钥只存在于函数环境;
  5. 静态素材(字体、背景、Logo)也放进同一个 Storage 公共桶,让函数素材同样享受 CDN 分发,并用 ?t= 时间戳管理素材版本。

按 README 的命令本地 serve 验证、deploy 上线之后,这套「Edge Function 渲染 + Storage CDN 缓存」的 OG 图片方案即可直接迁移到自己的 Supabase 项目中使用。

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