Cloudflare PagesでOGP画像を動的生成する際のWASM制限とその解決策

Cloudflare PagesでOGP画像を動的生成する際のWASM制限とその解決策

#Tech

はじめに

Astro + Notion + Cloudflare Pages構成でブログを運用していた際、OGP画像の動的生成がローカル開発環境では動作するのに、本番環境では全く動かないという問題に遭遇しました。

本記事では、この問題の原因と解決策を共有します。

問題の発生

構成

  • フレームワーク: Astro (Hybrid mode)
  • ホスティング: Cloudflare Pages
  • OGP生成: satori + @resvg/resvg-wasm(SSRで動的生成)

エラー内容

本番環境でOGP画像にアクセスすると、以下のエラーが発生:

WebAssembly.instantiate(): Wasm code generation disallowed by embedder

原因

Cloudflare Pages Functionsには、WASMの実行に制限があることが原因でした。

具体的には、Cloudflare Workers/Pages Functionsでは、セキュリティ上の理由からWebAssembly.instantiate()の使用が制限されています。resvg-wasmはSVGをPNGに変換するためにWASMを使用しており、この制限に抵触していました。

なぜローカルでは動いたのか

ローカル開発環境(Node.js)にはこの制限がないため、問題なく動作していました。Cloudflare特有の制限であるため、デプロイして初めて発覚する厄介な問題です。

解決策

アプローチ: SSR → SSG への変更

ランタイムでのOGP生成を諦め、ビルド時に静的ファイルとして生成する方式に変更しました。

typescript
// src/pages/og/[slug].png.ts

import type { APIRoute, GetStaticPaths } from 'astro';
import satori from 'satori';
import { Resvg, initWasm } from '@resvg/resvg-wasm';
import { getPosts } from '@/lib/notion/client';

// ビルド時に静的生成(SSG)
export const prerender = true;

// WASM初期化フラグ
let wasmInitialized = false;

async function ensureWasmInitialized(): Promise<void> {
  if (wasmInitialized) return;

  const fs = await import('node:fs/promises');
  const path = await import('node:path');
  const wasmPath = path.resolve(
    process.cwd(),
    'node_modules/@resvg/resvg-wasm/index_bg.wasm'
  );
  const wasmBuffer = await fs.readFile(wasmPath);
  await initWasm(wasmBuffer);
  wasmInitialized = true;
}

export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await getPosts();
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { title: post.title },
  }));
};

export const GET: APIRoute = async ({ props }) => {
  const { title } = props as { title: string };

  await ensureWasmInitialized();

  // satoriでSVGを生成
  const svg = await satori(
    // React-likeなJSXオブジェクト
    {
      type: 'div',
      props: {
        style: { /* スタイル */ },
        children: title,
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [{ name: 'Noto Sans JP', data: fontData }],
    }
  );

  // resvgでPNGに変換
  const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } });
  const pngData = resvg.render();
  const pngBuffer = pngData.asPng();

  return new Response(pngBuffer, {
    headers: { 'Content-Type': 'image/png' },
  });
};

ポイント

  1. export const prerender = true;: これによりビルド時に静的ファイルとして生成される
  2. getStaticPathsで全記事のslugを列挙: ビルド時に必要なパスを事前に定義
  3. WASMはNode.js環境で実行: ビルドはNode.jsで行われるため、WASM制限なし

追加で遭遇した問題

フォント形式の問題

satoriはWOFF2フォーマットに対応しておらず、以下のエラーが発生:

Unsupported OpenType signature wOF2

解決策: WOFF形式のフォントを使用

const fontUrl = 'https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-jp@5.0.19/files/noto-sans-jp-japanese-700-normal.woff';

OGPパスの変更

SSR時代は /api/og/[slug].png だったパスを、SSG化に伴い /og/[slug].png に変更。SEOコンポーネントでのパス参照も更新が必要でした。

メリット・デメリット

SSG方式のメリット

  • Cloudflare Freeプランで動作
  • エッジでの処理不要、CDNキャッシュが効く
  • レスポンス速度が高速

SSG方式のデメリット

  • 記事追加時に再ビルドが必要
  • ビルド時間が増加(記事数に比例)
  • 動的なパラメータ対応が困難

まとめ

Cloudflare PagesでWASMを使用する際は、ランタイム制限を理解し、必要に応じてSSG方式を検討することをおすすめします。