ブログの記事などでOG画像を動的に作りたい場合、サーバーサイドを持つサービスなら、 ImageMagick や node-canvas を使い構築することができますが、サーバーサイドを持たない静的なサイトの場合、 vercel等のServerless Functionsを使い、画像を生成するマイクロサービスを作成し運用することができます。
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 の文字が表示されます。
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をインストールします。
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 で画像が表示されるようになりました。
(↑htmlに見えますが画像です。。😅)
このようにpuppetterを介して画像を生成するマイクロサービスが作成できました。 あとは表示するhtmlを装飾することで(CSSで表現できるものやweb fontを利用したり等で)好みの画像を生成することができます。