🍊

Hono + Cloudflare Workers でOG画像を動的に生成する

に公開

最近リリースした cinefil.me はユーザーが映画に特化した個別のプロフィールを作成できるwebアプリケーションで、ユーザーの個別ページではOG画像を動的に作成する必要がありました。

https://cinefil.me/

実現したいことは、og画像生成用のapiエンドポイントを作成し、ユーザーのプロフィールページで以下のようにuserIdを渡してアクセスして動的にOG画像を生成することです。

<meta property="og:image" content="https://api.cinefil.me/api/og-image?userId=123" />

背景

バックエンドのAPIは Hono で構築していて、デプロイ先は Cloudflare Workers という構成でした。

OG画像の動的生成といえば @vercel/og ですが、V8 isolateという異なるランタイム環境の Cloudflare Workers では Node.js 依存のパッケージが動作しないため、内部に依存パッケージを使用している @vercel/og は動作せず。

Cloudflare Workers環境でも動作するように調整された @cloudflare/pages-plugin-vercel-og もありますが、公式ドキュメントにある Pages Functions の middleware としての使用方法は Hono との互換性がないためこちらもうまくいきませんでした。

いろいろ調べるうちにVinh Pham氏のブログで @cloudflare/pages-plugin-vercel-og から ImageResponse を直接インポートして使用する方法を知りました。これを基に Hono との統合とユーザー毎の動的生成という今回の要件を満たす実装が実現できました。

前提

  • バックエンドAPIを Hono (^4.9.6) で構築
  • バックエンドを Cloudflare Workers にデプロイ

実装

1. プラグインをインストール

npm i @cloudflare/pages-plugin-vercel-og

2. Cloudflare Workers ランタイム互換性機能を有効にする

wrangler.toml
  name = "cinefil-api"
  main = "server/index.ts"
  compatibility_date = "2024-12-01"
+ compatibility_flags = ["nodejs_compat"]

  ...

3. 基本のコード

JSXでOG画像のテンプレートを作成して、 ImageResponse でJSXから画像に変換します。

og.tsx
import { ImageResponse } from "@cloudflare/pages-plugin-vercel-og/api";
import type { Context } from "hono";

export const getOgImageHandler = async (c: Context<{ Bindings: Bindings }>) => {
  const options = {
    width: 1200,
    height: 630,
  };

  // ImageResponseでJSXから画像を生成
  return new ImageResponse(<div>...</div>, options)
};

4. ユーザーデータをOG画像に反映する

OG画像にはユーザー固有のアバター画像やユーザー名を反映したいので、クエリから userId を取得してデータベースからユーザー情報を取得します。

og.tsx
// userId をクエリから取得
const userId = c.req.query("userId");

// OG画像に表示したいデータをDBから取得
const result = await c.env.YOUR_DB_BINDING
  .prepare(`SELECT avatar, display_name FROM profiles WHERE user_id = ?`)
  .bind(userId)
  .first();

// JSXにユーザーデータを反映
return new ImageResponse(
  (
    <div>
      <img src={avatar as string} width="80" height="80" alt={display_name as string} />
      <div>{display_name as string}</div>
    </div>
  ),
  options
)

5. フォントを指定する

OG画像内のテキストを特定のフォントにしたい場合はフォントファイルを読み込むことで実装可能です。

Workers環境では fs.readFile が使用できないため fetch() でフォントファイルを取得して arrayBuffer() で読み込む必要があります。

og.tsx
// /public/fonts/ フォルダからフォントファイルを読み込む
// woff はエラーになるため ttf を使用
  const fontData = await fetch(
    new URL(`<SITE_URL>/fonts/RobotoMono-SemiBold.ttf`, import.meta.url)
  ).then((res) => res.arrayBuffer());

// ImageResponse のオプションに設定
  const options = {
    width: 1200,
    height: 630,
    fonts: [
      name: "RobotoMono",
      data: fontData,
      style: "normal",
      weight: 500,
    ],
  };

// JSX 内でフォントを指定
return new ImageResponse(
  (
    <div style={{ fontFamily: "Roboto Mono" }}>
      <img src={avatar as string} width="80" height="80" alt={display_name as string} />
      <div>{display_name as string}</div>
    </div>
  ),
  options
)

6. 表示結果

アプリケーションに実装した実際のOG画像の表示結果です。
https://cinefil.me/yuri5

エラーハンドリングやスタイリングを含む完成版のコードは GitHub にて公開しています。
server/routes/og.tsx
https://github.com/chocolat5/cinefil

参照

vercel/og · Cloudflare Pages docs
Dynamic OG images and beyond with Cloudflare Workers

Discussion