Next.js が wasm パッケージをバンドルすると本番だけ壊れる
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
WASM を要求する npm パッケージは、Next.js の標準バンドルに含めると内部の相対パス解決が壊れるため、serverExternalPackages で明示的に外部化し、node_modules から素の require で読み込ませる必要がある。
結論
wasm ファイルを読み込む npm パッケージは、Next.js の標準バンドルに含めると壊れます。 パッケージ内部が wasm を相対パスで読みに行く実装になっている場合、バンドル後はその相対パスの基準が変わってしまい、実行時に型エラーで落ちます。
直し方は serverExternalPackages でそのパッケージをバンドル対象から外し、outputFileTracingIncludes で wasm 本体をデプロイ対象に明示的に含める、2つを両方指定する形です。
症状
- 帳票PDFを発行するリクエストが本番でだけ502になる
- ローカルの
next build && next start(本番ビルドモード)でも同じく落ちる - ログには次の型エラーが出る
TypeError: The "path" argument must be of type string … Received type number
path という文字列を渡しているはずの箇所で、型エラーが出ています。呼び出し元のコードを見直しても、渡している値は文字列です。
原因
PDF生成では、CJKフォントのグリフ欠落を避けるために HarfBuzz(subset-font)で使用文字だけに縮小したフォントを作っています。この subset-font が内部で .wasm ファイルを読み込む際、自身のモジュールからの相対パスで解決しています。
Next.js のビルドはデフォルトで、依存パッケージのコードも含めて1つの実行バンドルに詰め込みます。バンドルされると、モジュールの物理的な場所が変わり、wasm を読みに行くための相対パスの基準がずれます。パッケージ側は文字列のパスを渡しているつもりでも、バンドラーが書き換えた実行時の値は文字列ではなくなり、path の型エラーとして表面化します。
この壊れ方はバンドルという工程そのものが原因です。outputFileTracingIncludes と serverExternalPackages は、どちらも Next.js のビルド・デプロイパッケージングにだけ存在する仕組みで、コードを実行するだけの確認では触れません。
直す
2つの設定を両方入れます。片方だけでは直りません。
1. バンドル対象から外す
// next.config.ts
const nextConfig: NextConfig = {
serverExternalPackages: ["subset-font", "harfbuzzjs", "fontverter"],
};
これで対象パッケージは webpack/turbopack のバンドルを通らず、実行時に node_modules から素の require で読み込まれます。パッケージ内部の相対パス解決が、開発時と同じ形のまま保たれます。
2. デプロイ対象に含める
const nextConfig: NextConfig = {
outputFileTracingIncludes: {
"/**": ["./src/assets/fonts/**", "./node_modules/harfbuzzjs/**"],
},
};
Next.js のデプロイパッケージングは、コードの静的な import からファイルの依存関係をトレースします。しかし fs.readFileSync のような実行時の動的な読み込みはトレースされないため、フォントファイルや wasm 本体を明示的に含めないと、デプロイされた関数の中にファイルそのものが存在しません。
serverExternalPackages だけを指定すると、パッケージのコードはバンドルされなくなりますが、**依存する wasm ファイル自体がデプロイパッケージに含まれるとは限りません。**両方揃って初めて、コードのパス解決とファイルの実在の両方が満たされます。
気づけなかった理由
この2つの設定は、どちらも「バンドル・トレースという工程が存在すること」を前提にしています。outputFileTracingIncludes はデプロイ対象をトレースで決める仕組みの一部であり、serverExternalPackages はそのバンドル処理からパッケージを外す仕組みです。この工程自体を経ないと踏まない不具合だったため、コードを普段どおり動かして確認するだけでは気づけませんでした。
next.config.ts の該当箇所には、症状(型エラーの文言)と原因(wasm のパス解決が壊れる)をコメントとして直接残しています。次に wasm を要求するパッケージを追加する人が、同じ壊れ方を踏む前にここを読める場所です。
よくある質問
Q1本番で具体的にどんなエラーが出ましたか?
実行時に 'path' argument must be string … Received type number という TypeError が出ました。帳票PDFを生成するリクエストがこのエラーで失敗し、ユーザーには502として表面化しました。
Q2なぜ outputFileTracingIncludes と serverExternalPackages の両方が要るのですか?
役割が別です。outputFileTracingIncludes は、実行時に fs.readFileSync 等で動的に読むファイル(wasm 本体)が自動トレースの対象外なので、デプロイされる関数バンドルに明示的に含めるための設定です。serverExternalPackages は、wasm を読み込む側の JS コード自体を webpack/turbopack にバンドルさせず、node_modules 内の実ファイルへの相対パス解決をそのまま保つための設定です。片方だけでは、ファイルはあるのに読みに行くコードのパスが壊れている、または逆の状態になります。
Q3なぜ HarfBuzz(wasm) を使ってフォントをサブセットしているのですか?
PDF生成ライブラリの標準機能(embedFont の subset:true)は CJK フォントでグリフが欠落する既知の不具合があるため、フルフォントで描画して使用文字を収集し、HarfBuzz(subset-font) で使用文字だけに縮小した TTF を作って再埋め込みする2パス構成にしています。HarfBuzz 自体が wasm 実装のため、この経路で wasm バンドル問題を踏みました。
Q4この設定は他の wasm を使う npm パッケージにも当てはまりますか?
はい。wasm ファイルを require や動的 import で読み込み、その解決が相対パスに依存しているパッケージであれば、同じ壊れ方をします。Next.js の serverExternalPackages は、まさにそうしたパッケージをバンドル対象から外すための機能です。