Next.js v16 Turbopack + Vercel 環境での WASM モジュール読み込みエラーの解決方法
Next.js v16 へアップグレードし、バンドラーを Webpack から Turbopack へ移行しました。しかし、この移行によって、これまで正常に動作していた機能で予期せぬエラーが発生することがあります。
特に WebAssembly(WASM)モジュールを利用している場合は注意が必要です。本記事では、@embedpdf/pdfium という PDF 処理ライブラリで WASM モジュールが読み込めなくなった問題と、その解決策を詳しく解説します。
私たちが開発している Giselle では、PDF からテキストを抽出して Vector Store へ登録する機能に @embedpdf/pdfium パッケージを使用していました。Webpack 環境では問題なく動作していましたが、Turbopack に切り替えたところ、以下のエラーが発生しました。
Error: Cannot find module '@embedpdf/pdfium/pdfium.wasm'
このエラーは、ローカル開発環境(next dev --turbo)と Vercel へのデプロイ後の両方で発生しました。
実際の修正内容は feat(api): Fix pdfium.wasm path resolution with Turbopack · Pull Request #2460 · giselles-ai/giselle にまとまっています。
以下では、問題の原因と解決策を詳しく解説します。
調査を進めると、この問題には 2 つの原因が絡み合っていることがわかりました。
問題のコードは、require.resolve を使って WASM ファイルのパスを解決していました。
// 問題があったコード
import { createRequire } from "node:module";
const requireBaseUrl = new URL(".", import.meta.url);
const moduleRequire = createRequire(requireBaseUrl);
// モジュールのトップレベルで実行されるため、ビルド時に静的解析の対象となる
const PDFIUM_WASM_PATH = moduleRequire.resolve("@embedpdf/pdfium/pdfium.wasm");Webpack はこのコードを問題なく処理できますが、Turbopack はビルド時に createRequire().resolve() を静的解析しようとします。しかし、Turbopack は一部の Node.js API の動的評価にまだ対応しきれていないため、この動的なパス解決を正しく解釈できず、WASM ファイルのパス解決に失敗してビルドエラーや実行時エラーが発生していました。
ローカル開発環境と Vercel のサーバーレス関数環境では、node_modules の配置場所が異なります。
node_modules を直接参照できます。.next/server/node_modules/ のような特殊なパスに依存関係が配置されます。この差異により、単一の静的なパス解決ロジックでは両方の環境に対応できませんでした。
これらの問題を解決するために、3 つのアプローチを組み合わせた堅牢な解決策を実装しました。
まず、Turbopack のビルド時静的解析を回避するため、WASM ファイルのパス解決をモジュールのトップレベルから関数内に移動しました。これにより、実際に WASM が必要になるまでパス解決が実行されなくなり、ビルド時の問題を回避できます。
// 修正前
// const PDFIUM_WASM_PATH = moduleRequire.resolve("@embedpdf/pdfium/pdfium.wasm");
// 修正後
let cachedWasmPath: string | null = null;
function getPdfiumWasmPath(): string {
if (cachedWasmPath !== null) {
// 解決済みのパスをキャッシュして再利用
return cachedWasmPath;
}
// パス解決ロジック(後述)
// ...
}次に、ローカルと Vercel の環境差を吸収するため、複数の候補パスを順番に検索するフォールバック戦略を導入しました。
fs.existsSync() でファイルの存在を確認し、最初に見つかった有効なパスを使用します。
import { createRequire } from "node:module";
import { join } from "node:path";
import { existsSync } from "node:fs";
function getPdfiumWasmPath(): string {
if (cachedWasmPath !== null) {
return cachedWasmPath;
}
const requireBaseUrl = new URL(".", import.meta.url);
const searchedPaths: string[] = [];
// 複数のパス解決関数を定義
const possiblePathFinders = [
// 1. 標準のNode.jsモジュール解決(主にローカル開発向け)
() => {
const moduleRequire = createRequire(requireBaseUrl);
return moduleRequire.resolve("@embedpdf/pdfium/pdfium.wasm");
},
// 2. process.cwd() からの相対パス(Vercel向け)
() => join(process.cwd(), "node_modules/@embedpdf/pdfium/dist/pdfium.wasm"),
// 3. __dirname からの相対パス(フォールバック)
() => join(__dirname, "../node_modules/@embedpdf/pdfium/dist/pdfium.wasm"),
// 4. Vercelサーバーレス関数の特殊な構造に対応
() => join(process.cwd(), ".next/server/node_modules/@embedpdf/pdfium/dist/pdfium.wasm"),
];
for (const findPath of possiblePathFinders) {
try {
const path = findPath();
searchedPaths.push(path);
if (existsSync(path)) {
cachedWasmPath = path; // 見つかったパスをキャッシュ
return path;
}
} catch (e) {
// resolveが失敗した場合などは無視して次へ
}
}
// どのパスでも見つからなかった場合
throw new Error(
`Could not find pdfium.wasm. Searched paths:\n${searchedPaths.map((p) => ` - ${p}`).join("\n")}`
);
}wasmBinary オプションの使用従来は locateFile コールバックで PDFium に WASM ファイルの場所を伝えていましたが、この方法はバンドラーの挙動に依存しがちです。
そこで、fs.readFileSync() で WASM ファイルのバイナリを直接読み込み、initPdfium の wasmBinary オプションで渡すように変更しました。これにより、バンドラーのモジュール解決システムを完全にバイパスし、WASM モジュールの初期化をより直接的に制御できます。
import { readFileSync } from "node:fs";
import initPdfium from "@embedpdf/pdfium";
// ... getPdfiumWasmPath()の実装 ...
// 修正前
/*
pendingModule = initPdfium({
locateFile: (fileName: string, prefix: string) => {
if (fileName === "pdfium.wasm") {
return PDFIUM_WASM_PATH; // 動的に解決したパスを返す
}
return prefix + fileName;
},
});
*/
// 修正後
const wasmBinary = readFileSync(getPdfiumWasmPath());
pendingModule = initPdfium({
wasmBinary, // WASMのバイナリデータを直接渡す
});next.config.ts の設定追加ここまでの修正で WASM の読み込みは成功しますが、Vercel にデプロイする際は WASM ファイルがサーバーレス関数のバンドルに含まれている必要があります。
next.config.ts の outputFileTracingIncludes を使うことで、特定のファイルを明示的にバンドルに含めることができます。
// next.config.ts
const pdfiumWasmInclude = "node_modules/@embedpdf/pdfium/dist/pdfium.wasm";
/** @type {import('next').NextConfig} */
const nextConfig = {
// ...他の設定
experimental: {
outputFileTracingIncludes: {
"/api/vector-stores/document/[documentVectorStoreId]/documents": [
pdfiumWasmInclude,
],
"/api/vector-stores/cron/document/ingest": [pdfiumWasmInclude],
},
},
};
export default nextConfig;今回の対応をまとめると、以下のようになります。
| 課題 | 解決策 |
|---|---|
Turbopack がビルド時に createRequire().resolve() を静的解析する | 遅延評価(関数内でパス解決)でビルド時解析を回避 |
| Vercel 環境とローカル環境でファイル構造が異なる | 複数パスを順番に検索するフォールバック戦略 |
locateFile コールバックがバンドラーに依存する | wasmBinary オプションで直接バイナリを渡す |
| デプロイ環境で WASM ファイルが見つからない | outputFileTracingIncludes でバンドルに含める |
| デバッグが困難 | エラーメッセージに検索した全パスを含める |
Turbopack は非常に強力なツールですが、まだ発展途上な部分もあります。特に WASM のような外部バイナリファイルを扱う場合は、バンドラーの挙動を意識した実装が求められます。
同様の問題に直面した際は、本記事で紹介した以下のアプローチが役立つでしょう。
wasmBinary オプション以上、Next.js v16 + Turbopack + Vercel 環境で Cannot find module ‘xxx.wasm’ エラーを解決した、現場からお送りしました。