Cloudflare Workers上でOGP画像を動的生成する構成を作りました
実装はTanStack Startで書いてますが、OGP画像生成の部分はフレームワークによらないです
ただ一つ問題が、、Workers無料プランだと1リクエストあたりCPU時間10msしか使えないので、画像生成が間に合わずエラーになりました。。。
有料プラン($5/mo)なら既定30秒で、必要ならさらに上げられるので余裕なんですが、無料プランだとつらい😢
無料プランのままうまく回避する方法を知ってる方がいたらぜひ教えてほしいです!
30秒まとめ
- Workers環境はNode.jsじゃないので、sharp(画像処理)もPuppeteer(ブラウザスクショ)も使えない
- satori(JSX → SVG)と @resvg/resvg-wasm(SVG → PNG)ならWASMだけで完結するのでWorkersでも動く
- ただしWorkers特有のハマりポイントが3つある
-
satori/standaloneを使ってWASMを自分で初期化する - フォント等のアセットは
env.ASSETS.fetch()で読む
-
以下、実装時の流れをなぞっていきます
satori, resvg-wasm セットアップ
TanStack Start のセットアップを終えて、npm run devで動いてる前提で進めます(getting startedは終わってるつもり)
まずは必要なパッケージを入れます
npm install satori @resvg/resvg-wasm
最終的なファイル構成はこんな感じです
src/
routes/
ogp/$slug.ts ← OGP画像を返すサーバールート
lib/
ogp/
generate-ogp.ts ← satori + resvg-wasmで画像生成
assets.ts ← フォント・画像の読み込み
public/
fonts/
Yomogi-Regular.ttf ← 日本語フォント
フォントを用意する
OGPで日本語テキストを描画するなら、フォントのバイナリデータが必要です
satoriはフォントを内蔵してないので、自分で用意します
Google Fontsからttfをダウンロードして public/fonts/ に置くのが手軽です
自分は丸ゴシック系の Yomogi を使いました
だいたい4MBくらいです
WASMファイルを直接importする
@resvg/resvg-wasm や satori の yoga.wasm は、npmパッケージの中にWASMバイナリが含まれてます
なので package exports からそのままimportできます
import yogaWasm from "satori/yoga.wasm";
import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm";
ちなみに tsconfig.json に baseUrl は、いれないほうが良いです
bare import が node_modules より先に baseUrl 基準で解決されて、WASM import でハマることがあります
画像生成ロジックを書く
satori/standaloneを使う
satoriの通常ビルド(import satori from "satori")は、内部でyoga.wasmを動的にロード・インスタンス化しようとします
これがWorkersのランタイム制約に引っかかって動かないです
なので satori/standalone を使って、自分でWASMを渡します
// ❌ これはWorkersで動かない
import satori from "satori";
// ✅ standaloneビルドを使い、自分でWASMを渡す
import satori, { init } from "satori/standalone";
import yogaWasm from "satori/yoga.wasm";
await init(yogaWasm);
前述にもある通りですが、 baseUrl を外せば package からそのままimportできました
satoriでJSX → SVG
satoriはVercel製のライブラリで、ReactNodeライクなオブジェクトをSVGに変換してくれます
const svg = await satori(
{
type: "div",
props: {
style: { display: "flex", /* ...レイアウト省略 */ },
children: {
type: "p",
props: {
style: { fontSize: "40px", /* ...省略 */ },
children: "ここに表示するテキスト",
},
},
},
},
{
width: 1200,
height: 630,
fonts: [{ name: "Yomogi", data: fontData, weight: 400, style: "normal" }],
}
);
ちなみにsatoriはinline styleしか受け付けないです
TailwindもCSS変数も使えないので、スタイルはすべてハードコードします
resvg-wasmでSVG → PNG
satoriが吐いたSVGを、resvg-wasmでPNGに変換します
import { initWasm, Resvg } from "@resvg/resvg-wasm";
await initWasm(resvgWasm);
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: 1200 },
});
const rendered = resvg.render();
const png: Uint8Array = rendered.asPng();
これで1200×630のPNGバイナリが手に入ります
WASMの初期化は1回だけ
satoriもresvg-wasmも、WASMの初期化はWorkerインスタンスの生存期間中に1回やればいいです
リクエストのたびに初期化すると遅くなりますし、resvg-wasmは二重初期化でエラーを投げます
let satoriInitialized = false;
async function ensureSatoriInitialized(wasm: WebAssembly.Module) {
if (satoriInitialized) return;
await init(wasm);
satoriInitialized = true;
}
Workersのインスタンスは再起動時にリセットされるので、グローバル変数で問題ないです
フォントも同じで、4MBのTTFファイルを毎リクエストfetchするのはもったいないので、初回だけ読んでグローバルにキャッシュしてます
サーバールートで画像を返す
フォントの読み込み
Workersの中から自分自身に fetch("/fonts/...") すると、Vite devサーバー環境で HTTPError になったりします
なので env.ASSETS バインディング経由でstatic assetを読みます
import { env } from "cloudflare:workers";
async function loadFont(requestUrl: string): Promise<ArrayBuffer> {
const fontUrl = new URL("/fonts/Yomogi-Regular.ttf", requestUrl);
const res = await env.ASSETS.fetch(fontUrl);
return res.arrayBuffer();
}
env.ASSETS.fetch() は内部的にWorkerのstatic assetsバインディングを使うので、自己HTTPリクエストのような不安定さがないのがよいです👍️
これなら本番でもdevでも安定して動きます!
ルートの実装
TanStack Startでは createFileRoute のserver handlerで生のHTTPレスポンスを返せます
import { createFileRoute } from "@tanstack/react-router";
import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm";
import yogaWasm from "satori/yoga.wasm";
export const Route = createFileRoute("/ogp/$slug")({
server: {
handlers: {
GET: async ({ request, params }) => {
const text = await getTextById(params.slug);
await Promise.all([
ensureSatoriInitialized(yogaWasm),
ensureResvgInitialized(resvgWasm),
]);
const fontData = await loadFont(request.url);
const png = await generateOgpImage({ body: text }, fontData);
return new Response(png.buffer, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400",
},
});
},
},
},
});
メタタグの設定
ページ側のメタタグはこんな感じで設定できます
export const Route = createFileRoute("/posts/$slug")({
head: ({ loaderData }) => ({
meta: [
{ property: "og:image", content: `/ogp/${loaderData.slug}` },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:image", content: `/ogp/${loaderData.slug}` },
],
}),
});
SNSのクローラーが /ogp/xxx にアクセスすると、Workerがその場でPNGを生成して返します
キャッシュヘッダーを付けてるので、2回目以降はCDNから配信されます
締めの言葉
workers paid $5 払うと問題ないんですが、、、たかが $5 ではあるんですが、cloudflareは支出上限がないのでDDoSで知らないうちに課金増えてないかが怖いんですよね😭
ここだけなんとかしてくれないかなあと常々思っております!
どうか!!
cloudflareの中の人!