Next.js Vercel node-canvas registerFont 2021

筆者は最近以下のウェブサイトを作りました。

![everycolor.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233667/31d4a9a6-1129-4bec-5f2b-95679ee25fe7.png)

https://everycolor.vercel.app/

全ての色に名前を与えるライブラリ「[everycolor](https://www.npmjs.com/package/everycolor)」を試せるサイトです。

このサイトは今流行りのNext.jsで作り、Vercelにデプロイしています。各カラーコードに対するページが存在しており、次のようにOGP画像も表示されます。

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">#​8080ff is bluehalfwhite <a href="https://t.co/Iof52CR4ev">https://t.co/Iof52CR4ev</a> <a href="https://twitter.com/hashtag/everycolor?src=hash&amp;ref_src=twsrc%5Etfw">#everycolor</a></p>&mdash; 🈚️うひょ🤪✒📘 (@uhyo_) <a href="https://twitter.com/uhyo_/status/1364086681834459141?ref_src=twsrc%5Etfw">February 23, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

この記事では、これを実装する際に詰まった点とその解決法をご紹介します。

## Next.js + VercelでOGP画像を生成する手段

今回の場合、ページが$256^3 = 16,777,216$個存在しているため、リクエストが来るのに応じてページをSSRすることになります。これはNext.jsなら簡単ですね。また、各ページに異なるOGP画像が必要なので画像もやはり自動生成しなければいけません。そのための方法は状況に応じていくつかあります。次の記事によくまとまっています。

- [絶対にお金を払いたくない精神での動的OGP生成は辛い](https://blog.ojisan.io/dynamic-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](https://github.com/Automattic/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
```

これについては既存の日本語情報がありますので、これに従えば解消できます。

- [Vercel Now（旧ZEIT Now）上でnode-canvasを動かす](https://blanktar.jp/blog/2020/05/node-canvas-on-vercel-now)
- [Vercel（元ZeitのNow）にてNext.jsのAPI Routesでnode-canvasを使う](https://crieit.net/posts/Vercel-Zeit-Now-Next-js-API-Routes-node-canvas)

具体的には、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のドキュメント](https://github.com/Automattic/node-canvas#registerfont)によれば、次のように`registerFont`にファイル名を渡すと読み込まれるようです。

```js
registerFont('comicsans.ttf', { family: 'Comic Sans' })
```

Vercel上で動くようにするにはこのファイル名をどうすれば良いでしょうか。以下の記事に`path.resolve`を使えば良いという情報があります。

- [VercelにてNext.jsのAPI RoutesでOGPを作成する 2020年版](https://crieit.net/posts/Vercel-API-Routes-OGP-2020)

例えば、Next.jsのディレクトリ構成の`public`（静的ファイルを置くところ）に`Exo.ttf`を置いた場合、次のようにすればAPIルート（`pages/api/image.ts`）から読み込めるはずです。

```js
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`ファイルに対するルールを追加します。

```js
  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](https://github.com/uhyo/everycolor-website/blob/main/src/pages/api/image.ts)を参照してください。

Webpackの設定を書いたので、APIルート側からは普通に`.ttf`ファイルを`import`します。

```ts
import fontDataURI from "../../../fonts/Exo.ttf";
```

これでフォントデータを無事手に入れることができましたが、node-canvasの`registerFont`はファイル名を指定した読み込みしかできないようです。そこで、このデータをファイルに書き込みます。あらかじめ用意したファイルを読み込むのはそのファイルがどこに置かれているのか分からないため諦めましたが、自分で好き勝手なファイルに書き込むならば問題ないようです。

まず、[data-uri-to-buffer](https://www.npmjs.com/package/data-uri-to-buffer)を用いてdata URIをnode.jsの`Buffer`オブジェクトに変換します。

```ts
import dataUriToBuffer from "data-uri-to-buffer";

const fontBuf = dataUriToBuffer(fontDataURI);
```

さらに、それを適当なファイルに書き込んで、そのファイルのパスを`registerFont`に与えます。

```ts
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にファイルを埋め込んでもらうのも手です。