如何在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,
    });
  }
};