如何在Astro v6上实现 动态的 OpenGraph 社交图片生成?运行在边缘计算 使用wasm!支持Cloudflare workers
help wanted good first issue Astro
代码
Details
import type { APIRoute } from "astro";
// @ts-ignore
import { env } from "cloudflare:workers";
import { getLiveEntry } from "astro:content";
import { formatDate } from "@/lib/utils";
import satori, { init } from "satori/standalone";
import { initWasm, Resvg } from "@resvg/resvg-wasm";
// @ts-ignore
import yogaWasm from "satori/yoga.wasm?module";
// @ts-ignore
import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm?module";
// --- 1. 在函数外部定义全局锁和缓存 ---
let wasmInitPromise: Promise<void> | null = null;
let fontCache: ArrayBuffer | null = null;
// 封装一个纯粹的初始化函数
async function initializeWasm(yogaWasm: any, resvgWasm: any) {
// 如果已经有 Promise 在运行或已完成,直接返回它
if (wasmInitPromise) return wasmInitPromise;
wasmInitPromise = (async () => {
try {
await Promise.all([init(yogaWasm), initWasm(resvgWasm)]);
console.log("WASM Initialized successfully");
} catch (e: any) {
// 如果报错内容包含 "already initialized",说明其实已经好了,忽略它
if (
e.message?.includes("already initialized") ||
e.message?.includes("used only once")
) {
return;
}
// 如果是其他致命错误,清空锁以便下次重试
wasmInitPromise = null;
throw e;
}
})();
return wasmInitPromise;
}
export const GET: APIRoute = async ({ params, request }) => {
const { entry } = await getLiveEntry("data", params.id as string);
if (!entry) return new Response("Not Found", { status: 404 });
try {
// --- 2. 这里的调用现在是“线程安全”的 ---
await initializeWasm(yogaWasm, resvgWasm);
// 字体持久化逻辑保持不变
if (!fontCache) {
const fontRes = await env.ASSETS.fetch(
new URL("/fonts/MiSans-Regular.ttf", request.url),
);
fontCache = await fontRes.arrayBuffer();
}
// 3. Satori 渲染 SVG
const svg = await satori(
{
type: "div",
props: {
style: {
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#000",
color: "#fff",
fontFamily: "MiSans",
border: "1px solid #222",
},
children: [
// ... 你的 UI 结构保持不变
{
type: "div",
props: {
style: {
display: "flex",
padding: "30px 40px",
borderBottom: "1px solid #222",
alignItems: "center",
},
children: [
{
type: "div",
props: {
style: {
border: "1px solid #444",
borderRadius: "8px",
padding: "4px 12px",
color: "#888",
fontSize: "24px",
},
children: "#" + entry.id,
},
},
],
},
},
{
type: "div",
props: {
style: {
display: "flex",
flex: 1,
padding: "0 40px",
alignItems: "center",
justifyContent: "center",
},
children: {
type: "h1",
props: {
style: {
fontSize: "85px",
fontWeight: "300",
textAlign: "center",
lineHeight: "1.3",
},
children: entry.data.title,
},
},
},
},
{
type: "div",
props: {
style: {
display: "flex",
justifyContent: "space-between",
padding: "30px 40px",
borderTop: "1px solid #222",
color: "#666",
},
children: [
{
type: "span",
props: { children: formatDate(entry.data.createdAt) },
},
{
type: "span",
props: { children: entry.data.category.name },
},
],
},
},
],
},
},
{
width: 1200,
height: 630,
fonts: [
{
name: "MiSans",
data: fontCache,
weight: 400,
style: "normal",
},
],
},
);
// 4. Resvg 渲染 PNG
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: 1200 },
});
const pngData = resvg.render();
const pngBuffer = pngData.asPng();
return new Response(new Uint8Array(pngBuffer) as any, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (e: any) {
console.error("OG Error:", e.message);
return new Response(`Error generating image: ${e.message}`, {
status: 500,
});
}
};如果你网站部署在提供边缘计算的服务商上,这个方案很合适。无需编译时生成图片耗费时间。
需要
- @resvg/resvg-wasm
- satori
这两个包被打包进workers大小在2.5mb,压缩后 1.4mb 如果你还有其他模块。可能会超过免费额度
_astro/index_bg.Blvrv-U2.wasm │ compiled-wasm │ 2420.51 KiB
_astro/yoga.sbSbVeWy.wasm │ compiled-wasm │ 70.05 KiB
Total Upload: 4498.55 KiB / gzip: 1433.02 KiB
[id].ts
我创建了 src\pages\og\[id].png.ts 文件,访问/og/1.png就可以获取图片
原理
satori
使用字体和实现布局创建svg数据
resvg
将svg数据画成PNG图片
实现与限制
Cloudflare workers不允许实时编译生成wasm。一切都要预载
satori
导入 satori 标准版无yoga
import satori, { init } from "satori/standalone";导入Yoga wasm
// @ts-ignore
import yogaWasm from "satori/yoga.wasm?module";Resvg
import { initWasm, Resvg } from "@resvg/resvg-wasm";
// @ts-ignore
import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm?module";初始化
// --- 1. 在函数外部定义全局锁和缓存 ---
let wasmInitPromise: Promise<void> | null = null;
let fontCache: ArrayBuffer | null = null;
// 封装一个纯粹的初始化函数
async function initializeWasm(yogaWasm: any, resvgWasm: any) {
// 如果已经有 Promise 在运行或已完成,直接返回它
if (wasmInitPromise) return wasmInitPromise;
wasmInitPromise = (async () => {
try {
await Promise.all([init(yogaWasm), initWasm(resvgWasm)]);
console.log("WASM Initialized successfully");
} catch (e: any) {
// 如果报错内容包含 "already initialized",说明其实已经好了,忽略它
if (
e.message?.includes("already initialized") ||
e.message?.includes("used only once")
) {
return;
}
// 如果是其他致命错误,清空锁以便下次重试
wasmInitPromise = null;
throw e;
}
})();
return wasmInitPromise;
}字体
放在了public,并使用Workers assets方式读取
public\fonts\MiSans-Regular.ttf
API代码
使用astro api 获取GET ID后
export const GET: APIRoute = async ({ params, request }) => {
const { entry } = await getLiveEntry("data", params.id as string);
if (!entry) return new Response("Not Found", { status: 404 });
try {
// --- 2. 这里的调用现在是“线程安全”的 ---
await initializeWasm(yogaWasm, resvgWasm);
// 字体持久化逻辑保持不变
if (!fontCache) {
const fontRes = await env.ASSETS.fetch(
new URL("/fonts/MiSans-Regular.ttf", request.url),
);
fontCache = await fontRes.arrayBuffer();
}
// 3. Satori 渲染 SVG
const svg = await satori(
{
type: "div",
props: {
style: {
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#000",
color: "#fff",
fontFamily: "MiSans",
border: "1px solid #222",
},
children: [
// ... 你的 UI 结构保持不变
{
type: "div",
props: {
style: {
display: "flex",
padding: "30px 40px",
borderBottom: "1px solid #222",
alignItems: "center",
},
children: [
{
type: "div",
props: {
style: {
border: "1px solid #444",
borderRadius: "8px",
padding: "4px 12px",
color: "#888",
fontSize: "24px",
},
children: "#" + entry.id,
},
},
],
},
},
{
type: "div",
props: {
style: {
display: "flex",
flex: 1,
padding: "0 40px",
alignItems: "center",
justifyContent: "center",
},
children: {
type: "h1",
props: {
style: {
fontSize: "85px",
fontWeight: "300",
textAlign: "center",
lineHeight: "1.3",
},
children: entry.data.title,
},
},
},
},
{
type: "div",
props: {
style: {
display: "flex",
justifyContent: "space-between",
padding: "30px 40px",
borderTop: "1px solid #222",
color: "#666",
},
children: [
{
type: "span",
props: { children: formatDate(entry.data.createdAt) },
},
{
type: "span",
props: { children: entry.data.category.name },
},
],
},
},
],
},
},
{
width: 1200,
height: 630,
fonts: [
{
name: "MiSans",
data: fontCache,
weight: 400,
style: "normal",
},
],
},
);
// 4. Resvg 渲染 PNG
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: 1200 },
});
const pngData = resvg.render();
const pngBuffer = pngData.asPng();
return new Response(new Uint8Array(pngBuffer) as any, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (e: any) {
console.error("OG Error:", e.message);
return new Response(`Error generating image: ${e.message}`, {
status: 500,
});
}
};