在 Vercel 部署一个 OpenGraph Image 服务
这篇文章是实现 Follow 的 Separate main application and external pages and SEO support 这个 PR 的一部分技术细节,由于其中的技术细节较多,所以这将是系列文中的一篇。
用过 Next.js 的同学应该都知道,Next.js 自带了 ImageResponse
方法,可以用来生成 OpenGraph Image。
如果我们服务端框架不使用 Next.js,那么我们也可以使用 Vercel 提供的 @vercel/og
或者 satori
库来生成 OpenGraph Image。
vercel/og
是对 satori
的封装,使用起来更简单。但是这里我们使用 satori
来实现。
这一篇文章会介绍如何使用 satori
生成 OpenGraph Image,如何处理远程图片,字体,然后如何正确编译,最后部署到 Vercel 上。
初始化项目
起步
我们使用 Fastify 作为基座服务,其中一个接口去处理 OpenGraph Image 的生成。
npm i fastify dotenv
创建一个 index.ts
文件,写入以下内容:
const isVercel = process.env.VERCEL === '1'
export const createApp = async () => {
const app = Fastify({})
return app
}
if (!isVercel) {
// 如果不是 Vercel 环境,则开启一个常驻服务
createApp().then(async (app) => {
await app.listen({ port: 2234 })
console.info('Server is running on http://localhost:2234')
})
}
创建 lib/load-env.ts
文件,加载环境变量:
import { resolve } from 'node:path'
import { config } from 'dotenv'
config({
path: resolve(__dirname, '../../.env'),
})
Note注意此项目不要使用 ESM,在package.json
中不需要设置type: module
,否则后续部署到 Vercel 会可能会出现不可预见的问题。
安装 tsx
使用 tsx watch index.ts
启动项目,方便调试。
路由初始化
我们使用 /og
路由处理 OpenGraph Image。
首先安装 satori
库:
npm i satori @resvg/resvg-js
创建 routes/og.ts
文件,写入以下内容:
import { FastifyInstance } from 'fastify'
export const ogRoute = (app: FastifyInstance) => {
app.get('/og', async (req, reply) => {})
}
在 index.ts
中引入:
import { ogRoute } from './routes/og'
ogRoute(app)
封装 satori
satori
没有 @vercel/og
那么方便,需要我们自己再次封装,比如引入字体,生成 PNG 图片等。
引入字体
如果 OG Image 中存在字符,我们首先需要引入至少一种字体作为默认字体,如果需要显示中文,则需要引入中文字体。
这里我们使用小赖字体。安装字体:
npm i kose-font
创建一个 lib/log/font.ts
:
const koseFontPath = require.resolve('kose-font')
const koseFontData = fs.readFileSync(koseFontPath)
fontsData.push({
name: 'Kose',
data: koseFontData,
weight: 400,
style: 'normal' as 'italic' | 'normal',
})
export default fontsData
编写一个 renderToImage
方法,使用 resvg
生成 PNG 图片:
import { Resvg } from '@resvg/resvg-js'
import type { ReactElement } from 'react'
import type { SatoriOptions } from 'satori'
import satori from 'satori'
import fonts from './fonts'
export async function renderToImage(
node: ReactElement,
options: {
width?: number
height: number
debug?: boolean
fonts?: SatoriOptions['fonts']
},
) {
const svg = await satori(node, {
...options,
fonts: options.fonts || fonts,
})
const w = new Resvg(svg)
const image = w.render().asPng()
return {
image,
contentType: 'image/png',
}
}
要使用 JSX 的方式去表达 OG Image,还是需要安装 react
的。
npm i react
现在可以在 routes/og.ts
中使用 renderToImage
方法了。
import { FastifyInstance } from 'fastify'
import { renderToImage } from '../lib/og/render-to-image'
export const ogRoute = (app: FastifyInstance) => {
app.get('/og', async (req, reply) => {
const imageRes = await renderToImage(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
}}
>
<h1>你好,世界</h1>
</div>,
{
width: 1200,
height: 600,
},
)
const stream = new Readable({
read() {
this.push(imageRes.image)
this.push(null)
},
})
reply.type(imageRes.contentType).headers({
// 设置 CDN 缓存以减少计算资源的开销
'Cache-Control':
'max-age=3600, s-maxage=3600, stale-while-revalidate=600',
'Cloudflare-CDN-Cache-Control':
'max-age=3600, s-maxage=3600, stale-while-revalidate=600',
'CDN-Cache-Control':
'max-age=3600, s-maxage=3600, stale-while-revalidate=600',
})
return reply.send(stream)
})
}
引入图片
和 @vercel/og
不一样的是,在 satori
中的 <img />
不接受传入 arrayBuffer 作为 src
,只接受一个合法的可达的图片(非 SVG)的链接或者 base64 如果图片不可达或者图片格式不支持,则会导致 OG Image 生成失败,导致报错。
为了保证链接合法性和图片的可访问性,我们可以封装一个方法专门去获取图片,如果图片不可达则使用 fallback 图片。
async function getImageBase64(image: string | null | undefined) {
if (!image) {
return null
}
const url = new URL(image)
return await fetch(image, {
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
Referer: url.origin,
},
}).then(async (res) => {
const isImage = res.headers.get('content-type')?.startsWith('image/')
if (isImage) {
const arrayBuffer = await res.arrayBuffer()
return `data:${res.headers.get('content-type')};base64,${Buffer.from(
arrayBuffer,
).toString('base64')}`
}
return null
})
}
上面的方法会首先去访问图片,如果图片不可达,则返回 null。如果链接可达,并且是合法的图片则编码为 base64 返回,使用这种方法还可以正确渲染 SVG 图片。
const image = await getImageBase64('https://innei.in/innei.svg')
const FALLBACK_IMAGE_BASE64 = 'data:image/svg+xml;base64,...'
await renderToImage(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
flexDirection: 'column',
gap: '1rem',
height: '100%',
}}
>
<img
src={image || FALLBACK_IMAGE_BASE64}
style={{ width: 256, height: 256 }}
/>
<h1>Innei</h1>
</div>,
{
width: 1200,
height: 600,
},
)
部署到 Vercel
预备知识
Vercel 是一个 Serverless 平台,并不支持常驻服务,所以需要使用 Vercel 提供的 API 路由来部署。
开始之前,我们想要了解 Vercel API 是如何工作的。
当项目中存在 api
目录,Vercel 在构建项目时会自动识别并编译 api
目录中的文件,然后挂载到 /api
路由下。
在项目构建时,Vercel 会优先执行你设置的 build 指令,然后再使用 @vercel/node
去编译 api
目录中的文件。
编写 API 路由
首先我们需要在项目根目录新建文件夹 api
,然后在里面新建 index.ts
文件,写入以下内容:
const { createApp } = require('../dist/server/index.js') // 这里使用编译产物,防止 Vercel 编译 ts 文件时的错误
module.exports = async function handler(req: any, res: any) {
const app = await createApp()
await app.ready()
app.server.emit('request', req, res)
}
dist
是项目编译后的文件夹,index.js
是项目入口文件。这个文件在没有编译的情况下是不存在的,所以需要我们手动编译。
安装 tsup
去编译:
npm i tsup
写入 tsup 配置:
import { defineConfig } from 'tsup'
export default defineConfig(() => {
return {
entry: ['index.ts'],
outDir: 'dist/server',
splitting: false,
clean: true,
format: ['cjs'],
treeshake: true,
}
})
现在就可以使用 tsup
去编译项目了:
tsup
Vercel 创建项目
我们可以使用 Vercel CLI 在本地构建看看输出文件,开始之前首先需要在 Vercel 上创建一个项目,并且已经和 Vercel CLI 关联。
在 Vercel 中项目中,build 指令填写:
pnpm tsup
现在可以使用 vercel build
预览 Vercel 的构建结果了。
$ tree -L 2 .vercel
.vercel
├── README.txt
├── output
│ ├── builds.json
│ ├── config.json
│ ├── functions
│ └── static
└── project.json
解决字体文件缺失
在 output/functions
目录下会 API 编译后的文件。
$ tree -L 3
.
└── api
└── index.func
├── apps
└── node_modules
继续追踪 node_modules
目录,会发现缺少了 kose-font
这个依赖。导致 OG 没有找到字体报错。
回想一下,我们是通过 require.resolve
去获取字体文件的,经过 tsup
编译之后变成了 __require.resolve()
, __require
是 tsup
转换后的 require
导致 @vercel/node
在编译的时候没有正确识别这个需要的依赖,从而被舍弃了。
那么,只要我们注入原始的 require.resolve('kose-font')
就可以解决这个问题了。为了保证 tsup 不转换,我们可以提前注入原始代码块。
修改 tsup.config.ts
文件:
import { defineConfig } from 'tsup'
export default defineConfig(() => {
return {
entry: ['index.ts'],
outDir: 'dist/server',
splitting: false,
clean: true,
format: ['cjs'],
treeshake: true,
banner: { // [!code ++]
js: `try { require.resolve('kose-font') } catch {}`, // [!code ++]
}, // [!code ++]
}
})
现在,项目可以部署到 Vercel 了。
API 重写
Vercel API 默认都是以 /api
作为前缀,而我们希望以 /og
作为前缀,所以需要重写 API 路由。
根目录建立 vercel.json
文件,写入以下内容:
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/api"
}
]
}
这样,所有的请求都会被重写到 /api
路由下。
大功告成。