1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloudflare WorkersでOGP画像を動的生成する! satori/standalone + resvg-wasm で

1
Last updated at Posted at 2026-03-22

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.jsonbaseUrl は、いれないほうが良いです
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の中の人!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?