这篇文章是实现 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 的生成。
创建一个 index.ts
文件,写入以下内容:
创建 lib/load-env.ts
文件,加载环境变量:
[!NOTE] 注意此项目不要使用 ESM,在
package.json
中不需要设置type: module
,否则后续部署到 Vercel 会可能会出现不可预见的问题。
安装 tsx
使用 tsx watch index.ts
启动项目,方便调试。
我们使用 /og
路由处理 OpenGraph Image。
首先安装 satori
库:
创建 routes/og.ts
文件,写入以下内容:
在 index.ts
中引入:
satori
satori
没有 @vercel/og
那么方便,需要我们自己再次封装,比如引入字体,生成 PNG 图片等。
如果 OG Image 中存在字符,我们首先需要引入至少一种字体作为默认字体,如果需要显示中文,则需要引入中文字体。
这里我们使用小赖字体。安装字体:
创建一个 lib/log/font.ts
:
编写一个 renderToImage
方法,使用 resvg
生成 PNG 图片:
要使用 JSX 的方式去表达 OG Image,还是需要安装 react
的。
现在可以在 routes/og.ts
中使用 renderToImage
方法了。
和 @vercel/og
不一样的是,在 satori
中的 <img />
不接受传入 arrayBuffer 作为 src
,只接受一个合法的可达的图片(非 SVG)的链接或者 base64 如果图片不可达或者图片格式不支持,则会导致 OG Image 生成失败,导致报错。
为了保证链接合法性和图片的可访问性,我们可以封装一个方法专门去获取图片,如果图片不可达则使用 fallback 图片。
上面的方法会首先去访问图片,如果图片不可达,则返回 null。如果链接可达,并且是合法的图片则编码为 base64 返回,使用这种方法还可以正确渲染 SVG 图片。
Vercel 是一个 Serverless 平台,并不支持常驻服务,所以需要使用 Vercel 提供的 API 路由来部署。
开始之前,我们想要了解 Vercel API 是如何工作的。
当项目中存在 api
目录,Vercel 在构建项目时会自动识别并编译 api
目录中的文件,然后挂载到 /api
路由下。
在项目构建时,Vercel 会优先执行你设置的 build 指令,然后再使用 @vercel/node
去编译 api
目录中的文件。
首先我们需要在项目根目录新建文件夹 api
,然后在里面新建 index.ts
文件,写入以下内容:
dist
是项目编译后的文件夹,index.js
是项目入口文件。这个文件在没有编译的情况下是不存在的,所以需要我们手动编译。
安装 tsup
去编译:
写入 tsup 配置:
现在就可以使用 tsup
去编译项目了:
我们可以使用 Vercel CLI 在本地构建看看输出文件,开始之前首先需要在 Vercel 上创建一个项目,并且已经和 Vercel CLI 关联。
在 Vercel 中项目中,build 指令填写:
现在可以使用 vercel build
预览 Vercel 的构建结果了。
在 output/functions
目录下会 API 编译后的文件。
继续追踪 node_modules
目录,会发现缺少了 kose-font
这个依赖。导致 OG 没有找到字体报错。
回想一下,我们是通过 require.resolve
去获取字体文件的,经过 tsup
编译之后变成了 __require.resolve()
, __require
是 tsup
转换后的 require
导致 @vercel/node
在编译的时候没有正确识别这个需要的依赖,从而被舍弃了。
那么,只要我们注入原始的 require.resolve('kose-font')
就可以解决这个问题了。为了保证 tsup 不转换,我们可以提前注入原始代码块。
修改 tsup.config.ts
文件:
现在,项目可以部署到 Vercel 了。
Vercel API 默认都是以 /api
作为前缀,而我们希望以 /og
作为前缀,所以需要重写 API 路由。
根目录建立 vercel.json
文件,写入以下内容:
这样,所有的请求都会被重写到 /api
路由下。
大功告成。
npm i fastify dotenv
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')
})
}
import { resolve } from 'node:path'
import { config } from 'dotenv'
config({
path: resolve(__dirname, '../../.env'),
})
npm i satori @resvg/resvg-js
import { FastifyInstance } from 'fastify'
export const ogRoute = (app: FastifyInstance) => {
app.get('/og', async (req, reply) => {})
}
import { ogRoute } from './routes/og'
ogRoute(app)
npm i kose-font
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
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',
}
}
npm i react
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)
})
}
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
})
}
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,
},
)
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)
}
npm i tsup
import { defineConfig } from 'tsup'
export default defineConfig(() => {
return {
entry: ['index.ts'],
outDir: 'dist/server',
splitting: false,
clean: true,
format: ['cjs'],
treeshake: true,
}
})
tsup
pnpm tsup
$ tree -L 2 .vercel
.vercel
├── README.txt
├── output
│ ├── builds.json
│ ├── config.json
│ ├── functions
│ └── static
└── project.json
$ tree -L 3
.
└── api
└── index.func
├── apps
└── node_modules
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 ++]
}
})
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/api"
}
]
}