og画像をvercel上で生成してみる

TypeScript, puppeteer, Lambda, vercel
2020-09-12

ブログの記事などでOG画像を動的に作りたい場合、サーバーサイドを持つサービスなら、 ImageMagicknode-canvas を使い構築することができますが、サーバーサイドを持たない静的なサイトの場合、 vercel等のServerless Functionsを使い、画像を生成するマイクロサービスを作成し運用することができます。

og-imageについて

og-image はvercel社が作成・メンテナンスをしているOG画像を動的に生成する方法で、仕組みとしては入力されたデータでhtmlを出力し、その後ヘッドレスChromeである puppeteer を利用しキャプチャを生成するというもので、htmlで生成することによって座標の計算等ややこしい部分を柔軟に対応できる。

試してみる

og画像を生成する仕組みを作り、deployする流れを簡単に作成してみます。

まずはpackage.jsonと、TypeScriptを利用するため、以下のコマンドで生成・インストールします。

npm init
npm i typescript@3.9 -D

次に、vercel.jsonを追加し、以下のように設定します。

apiのメモリ 1024MB はvercelの無料枠であるHobbyのギリギリのラインです。

rewritesはすべてのリクエストをapi/indexに向けます。これによりURLのドメイン以下のパスにテキストを入れてもindexに到達するようになります。

{
  "regions": ["hnd"],
  "functions": {
    "api/**": {
      "memory": 1024
    }
  },
  "rewrites": [{ "source": "/(.+)", "destination": "/api" }]
}

次にapi/tsconfig.jsonを作成します。お好みで設定してください。

{
  "compilerOptions": {
      "outDir": "dist",
      "module": "commonjs",
      "target": "esnext",
      "moduleResolution": "node",
      "jsx": "react",
      "sourceMap": true,
      "strict": true,
      "noFallthroughCasesInSwitch": true,
      "noImplicitReturns": true,
      "noEmitOnError": true,
      "noUnusedLocals": true,
      "noUnusedParameters": true,
      "removeComments": true,
      "preserveConstEnums": true,
      "esModuleInterop": true
  },
  "include": [ "./" ]
}

Serverless Function(api/index.tsx)を作成し、ひとまずはhtmlを返す設定にします。

import { IncomingMessage, ServerResponse } from 'http';

export default async function handler(
  _req: IncomingMessage,
  res: ServerResponse
) {
  try {
    res.setHeader("Content-Type", "text/html");
    res.end("<h1>Hello</h1>");
  } catch (e) {
    res.statusCode = 500;
    res.setHeader("Content-Type", "text/html");
    res.end("<h1>Internal Error</h1><p>Sorry, there was a problem</p>");
    console.error(e);
  }
}

次にvercelの設定を行い、設定をします。

$ vercel
Vercel CLI 19.1.2
? Set up and deploy “~/workmy/og-image-blog”? [Y/n] y
? Which scope do you want to deploy to? 
? Link to existing project? [y/N] n
? What’s your project’s name? og-image-blog
? In which directory is your code located? ./
No framework detected. Default Project Settings:
- Build Command: `npm run vercel-build` or `npm run build`
- Output Directory: `public` if it exists, or `.`
- Development Command: None
? Want to override the settings? [y/N] n
...

vercelに一度デプロイされますが、一旦それは無視してローカルで作業します

$ vercel dev

localhost:3000にアクセスするとディレクトリのリストが見えますが、一旦そちらは無視し、localhost:3000/demo にアクセスします。

vercel.jsonのrewriteでインデックスページ以外のパスはすべてapi/indexに転送されるように設定しているので、アクセスすると <h1>でくくられた Hello の文字が表示されます。

URLに渡された文字列を取得し表示

http://localhost:3000/foo-bar のようにURLで渡された文字列をパースし、画面に表示します。

import { IncomingMessage, ServerResponse } from 'http';
import { parse } from 'url';

export default async function handler(
  req: IncomingMessage,
  res: ServerResponse
) {
  try {
    res.setHeader("Content-Type", "text/html");
    const { pathname } = parse(req.url || "/", true);
    const path = (pathname || "/").slice(1);
    res.end(`<h1>${path}</h1>`);
  } catch (e) {
    //...
  }
}

puppeteerを起動し、画像を取得

puppeteerをインストールします。

npm i puppeteer-core -S
npm i @types/puppeteer @types/puppeteer-core -D

api/index.tsを以下のように修正します

exePathにはローカルにあるchromeのアプリケーションへのパスを指定します。windows、mac、linuxで変わるのでそれぞれ指定します。

import { IncomingMessage, ServerResponse } from 'http';
import { launch, Page } from 'puppeteer-core';
import { parse } from 'url';

const exePath =
  process.platform === "win32"
    ? "C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe"
    : process.platform === "linux"
    ? "/usr/bin/google-chrome"
    : "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";

export default async function handler(
  req: IncomingMessage,
  res: ServerResponse
) {
  try {
    const { pathname } = parse(req.url || "/", true);
    const path = (pathname || "/").slice(1);

    // ブラウザを起動
    const browser = await launch({
      args: [],
      executablePath: exePath,
      headless: true,
    });
    const page = await browser.newPage();
    await page.setViewport({ width: 2048, height: 1170 });
    await page.setContent(`<h1>${path}</h1>`);
    const file = await page.screenshot({ type: "png" });

    res.setHeader("Content-Type", `image/png`);
    res.end(file);
  } catch (e) {
    // ..
  }
}

これで localhost:3000/test 等にアクセスするとパスで指定した文字列が画像で表示されます。

デプロイ

vercel上で動かすためにはサーバレス関数上で起動するChromeを用意しないといけないため、 chrome-aws-lambdaを使用します。このパッケージはChromeのバイナリを保有し、AWS lambdaやGoogle Cloud Functionsでpuppetterを利用できるようにします。vercelのServerless Functionはlambdaで動作しているためこのライブラリが利用できます。

npm i chrome-aws-lambda -S

importし、puppeteerの設定に追加します。

import chrome from 'chrome-aws-lambda';
import { IncomingMessage, ServerResponse } from 'http';
import { launch } from 'puppeteer-core';
import { parse } from 'url';

let page: any;

const exePath = //..

export default async function handler(
  req: IncomingMessage,
  res: ServerResponse
) {
  try {
    const { pathname } = parse(req.url || "/", true);
    const path = (pathname || "/").slice(1);

    // AWS_REGIONが存在するかどうかでローカル・vercel環境かを判定
    const isDev = !process.env.AWS_REGION;
    // ブラウザを起動
    const browser = await launch(
      isDev
        ? {
            args: [],
            executablePath: exePath,
            headless: true,
          }
        : {
            args: chrome.args,
            executablePath: await chrome.executablePath,
            headless: chrome.headless,
          }
    );
    page = await browser.newPage();
    await page.setViewport({ width: 2048, height: 1170 });
    await page.setContent(`<h1>${path}</h1>`);
    const file = await page.screenshot({ type: "png" });

    res.setHeader("Content-Type", `image/png`);
    // og画像はそう変わることがないため、キャッシュを長めに設定します
    res.setHeader(
      "Cache-Control",
      `public, immutable, no-transform, s-maxage=31536000, max-age=31536000`
    );
    res.end(file);
  } catch (e) {
    // ...
  }
}

vercelにデプロイします。

vercel --prod

デプロイが成功すると、https://[yourdomain].vercel.app/hello で画像が表示されるようになりました。

An image from Notion

(↑htmlに見えますが画像です。。😅)

まとめ

このようにpuppetterを介して画像を生成するマイクロサービスが作成できました。 あとは表示するhtmlを装飾することで(CSSで表現できるものやweb fontを利用したり等で)好みの画像を生成することができます。

問題点

  • vercelのhobby(フリープラン)の最大メモリが1024MBとなり、処理にもよりますが若干処理が重い(遅い)です。遅いことによってtwitterなどの表示では画像が表示されないことがあります。。なので、productionで検討するなら有償のプランを検討したほうがいいですね。
Written by Kyohei Tsukuda who lives and works in Tokyo 🇯🇵 , building useful things 🔧.