筆者は最近以下のウェブサイトを作りました。
全ての色に名前を与えるライブラリ「everycolor」を試せるサイトです。
このサイトは今流行りのNext.jsで作り、Vercelにデプロイしています。各カラーコードに対するページが存在しており、次のようにOGP画像も表示されます。
#8080ff is bluehalfwhite https://t.co/Iof52CR4ev #everycolor
— 🈚️うひょ🤪✒📘 (@uhyo_) February 23, 2021
この記事では、これを実装する際に詰まった点とその解決法をご紹介します。
Next.js + VercelでOGP画像を生成する手段
今回の場合、ページが$256^3 = 16,777,216$個存在しているため、リクエストが来るのに応じてページをSSRすることになります。これはNext.jsなら簡単ですね。また、各ページに異なるOGP画像が必要なので画像もやはり自動生成しなければいけません。そのための方法は状況に応じていくつかあります。次の記事によくまとまっています。
今回採用したのは、上の記事でいう案6です。すなわち、Next.jsのAPIルートとして画像を返すエンドポイントを用意しました。例えば上のツイートの画像は https://everycolor.vercel.app/api/image?code=8080ff です。これはアプリとは別にオブジェクトストレージなどを用意する必要がなくNext.js単体で完結するのも魅力的です。
この記事にもあるように、こうするとアプリサーバーへの負荷が無駄にかかる点が心配です。こういう場合の正攻法は画像生成サーバーの前段にCDNを置いてキャッシュしてもらうことなので、上のURLでも一応 Cache-Control: max-age=3600
を返していますが、APIルートだからということなのか、残念ながらVercel側でキャッシュしてくれるわけではないようです。
とはいえ、今回は目的がOGP画像であり、OGPの画像は普通はサービス側(Twitterとか)でキャッシュされるので今回はあまり気にしないことにします。
さて、Next.jsのAPIルートで画像を生成するには、Node.js上で画像を生成する手段が必要です。そのために使える強力なライブラリが node-canvasです。これはよく知られたHTMLのcanvas要素のAPIをNode.jsで使えるように移植したものです。さらに、サーバーから使いやすいようにフォントファイルからフォントを読み込めるなど独自機能も用意されています。
これを使えばローカルで画像生成の動作を確認するのは意外と簡単でした。しかし、Vercelにいざデプロイしてから結構苦労しました。
libuuid.so.1 が無いと言われる
VercelにデプロイしてAPIルートを叩いてみるとこんなエラーが記録されました。どうやらAPIルートが今流行りのサーバーレスやら何やらでLambdaにデプロイされており、その環境にライブラリが足りないことで起こる問題のようです。
Error: libuuid.so.1: cannot open shared object file: No such file or directory
これについては既存の日本語情報がありますので、これに従えば解消できます。
具体的には、1番目の記事に従って次のようなビルドスクリプトにすればOKでした。ビルド時にyum install
していいんですね。(しかも意外と速い)
yum install libuuid-devel libmount-devel && cp /lib64/{libuuid,libmount,libblkid}.so.1 node_modules/canvas/build/Release/ && next build
フォントが読み込めない
これでとりあえずnode-canvasが動いたのですが、出力される画像が文字化けしていました。おそらくフォントが無いのでしょう。ということで、node-canvasのregisterFont
を用いてフォントを読み込む必要があります。node-canvasのドキュメントによれば、次のようにregisterFont
にファイル名を渡すと読み込まれるようです。
registerFont('comicsans.ttf', { family: 'Comic Sans' })
Vercel上で動くようにするにはこのファイル名をどうすれば良いでしょうか。以下の記事にpath.resolve
を使えば良いという情報があります。
例えば、Next.jsのディレクトリ構成のpublic
(静的ファイルを置くところ)にExo.ttf
を置いた場合、次のようにすればAPIルート(pages/api/image.ts
)から読み込めるはずです。
registerFont(path.resolve('./public/Exo.ttf'), { family: 'Exo' })
しかし、筆者が試してみたところうまくいきませんでした。次のようなエラーメッセージが表示されます。
ENOENT: no such file or directory, lstat '/var/task/public'
つまり、path.resolve('./public/Exo.ttf')
は/var/task/public/Exo.ttf
に解決された模様だが、そもそもpublic
ディレクトリが存在しないようです。
この問題に対する既存の解決策は調べた限り見つからなかったので、2021年版として筆者がとった解決策をメモしておきます。
Webpackでフォントファイルを埋め込む
APIルートのファイルから他のファイルを読み込むのはどうやらハードルが高いようです。そこで、今回はimage.tsにフォントファイルを埋め込むことにしました。
幸いにも、Next.jsではpages/api/image.ts
のようなAPIルートもWebpackでビルドされます。そこで、Webpackの機能を使えばソースコードを汚すことなくフォントファイル埋め込むことができます。
具体的には、next.config.js内でWebpackのconfigを書き換えられる機能を用いて、.ttf
ファイルに対するルールを追加します。
webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
config.module.rules.push({
test: /\.ttf$/,
type: "asset/inline"
});
return config
},
type: "asset/inline"
というのはWebpack5で追加された機能で、このtype
が与えられたファイルをインポートするとData URIが得られます。Webpack 4を使っている場合はurl-loader
を使って同じことができます。
これをpages/api/image.ts
側から読み込みます。コードの全体像が見たい方はGitHubを参照してください。
Webpackの設定を書いたので、APIルート側からは普通に.ttf
ファイルをimport
します。
import fontDataURI from "../../../fonts/Exo.ttf";
これでフォントデータを無事手に入れることができましたが、node-canvasのregisterFont
はファイル名を指定した読み込みしかできないようです。そこで、このデータをファイルに書き込みます。あらかじめ用意したファイルを読み込むのはそのファイルがどこに置かれているのか分からないため諦めましたが、自分で好き勝手なファイルに書き込むならば問題ないようです。
まず、data-uri-to-bufferを用いてdata URIをnode.jsのBuffer
オブジェクトに変換します。
import dataUriToBuffer from "data-uri-to-buffer";
const fontBuf = dataUriToBuffer(fontDataURI);
さらに、それを適当なファイルに書き込んで、そのファイルのパスをregisterFont
に与えます。
import { mkdtempSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import path from "path";
const td = mkdtempSync(path.join(tmpdir(), "everycolor"));
const fontFile = path.join(td, "Exo.ttf");
writeFileSync(fontFile, fontBuf);
registerFont(fontFile, {
family: "Exo",
});
こうすることで、無事にnode-canvasにフォントを読み込ませることに成功しました。
フォントデータがJSファイルに埋め込まれるのがややサイズ的に心配ですが、今回はASCII文字のみ使用するためフォントサイズが大きくなかったのが幸いでした。
まとめ
Vercel上でpath.resolve
を用いてもAPIルートからファイルが読み込めなかった場合は、Webpackにファイルを埋め込んでもらうのも手です。