本記事の概要
私が個人会社で開発しているサービスに、AIエージェントが外部から呼び出すツールを1つ追加しました。このツールを呼び出すと、サービスに保存されているPDFの文書から選んだ1ページがJPEGの画像になり、応答の中に画像として入ります。開発を担当したAIは、デプロイの前に型検査と単体テストの全件を通していました。ツールの関数をスクリプトから直接呼び出して、作られた画像も目で確認していました。それでも、検証用の環境にデプロイした直後から、ツールを何度呼び出しても、応答に画像は入りませんでした。
原因はNext.js 16の本番のビルドにありました。Next.js 16では、既定のバンドラーがTurbopackです。require.resolveは、パッケージのファイルの場所を返す関数です。Turbopackのビルドでは、この呼び出しがビルド時に解決され、成果物の中でモジュールIDの整数に置き換わっていました。その整数がpath.dirnameに渡り、TypeErrorが発生していました。型検査、単体テスト、直接の呼び出しの3つでは、どれもこのビルドの工程を通らないので、この置き換えは検出できません。
AIは、1箇所を修正して、この不具合を解消しました。修正の内容は、パッケージの場所を解決する起点を、プロセスの作業ディレクトリに変えることです。この修正はTurbopackでは成り立ちますが、webpackでは成り立ちません。あわせて、この記事のために作った最小の再現では、当時の診断が1か所ずれていると分かりました。当時の記録では、import.meta.urlが数値に置き換わったと説明されていました。しかし再現では、import.meta.urlは文字列のまま残りました。整数になった値は、resolveの戻り値でした。失敗の後、開発側は、本番と同じビルドを起動して外から呼び出す確認を教訓として記録に書きました。10日後には、サービスの運用の文書にも同じ手順を追記しました。
本文では、まず置き換えの仕組みを再現の値で示し、次に4つの実行方式で結果を比べます。そのうえで、2つの修正の方法を、成り立つ条件と一緒に比べ、デプロイ前の確認の手順をまとめます。最後に、AIが通していなかった確認の工程と、デプロイ直後の失敗と当時の診断のずれを説明します。挙動は2026年9月25日に、Next.js 16.3.6と16.2.2、Node 25.7.0で確かめました。
記事から得られること
Next.jsのサーバのコードで、実行時にパッケージの中のファイルを読む実装をしている方に向けて、次の3つを説明します。
- 型検査、単体テスト、直接の呼び出しの3つでバンドラーによる値の置き換えを検出できない理由が分かり、自分の環境でも同じ置き換えが起きるかを確かめられるようになります
- 実行時にパッケージの場所を解決するコードの修正の方法を、作業ディレクトリを起点にする方法とturbopackIgnoreの注釈を付ける方法から、成り立つ条件と一緒に選べるようになります
- 本番と同じビルドを起動して外から呼び出す確認を、デプロイ前の手順に組み込めるようになります
Turbopackがrequire.resolveの呼び出しを整数に置き換える仕組み
この節では、Next.js 16の本番のビルドで、パッケージの場所を解決するコードの値がどう変わるかを説明します。Next.js公式ドキュメントのTurbopackのAPIリファレンスによると、Next.js 16のnext buildは既定でTurbopackを使います。–webpackを付けたときだけ、webpackを使います。同じページには、型検査はTurbopackの処理に含まれないことも書かれています。
再現のために、次の関数を1つ用意し、Next.jsのroute handlerから呼び出しました。この関数の中では、2通りの起点でcreateRequireの関数が作られます。そのうえで、それぞれのresolveでnextのpackage.jsonの場所が解決されます。viaImportMetaは、モジュール自身のURLであるimport.meta.urlを起点にする書き方です。viaCwdは、プロセスの作業ディレクトリの下にある架空のファイルのパスを起点にする書き方です。
import path from 'node:path';
import { createRequire } from 'node:module';
export function describeBase() {
const out: Record<string, unknown> = { importMetaUrl: import.meta.url, cwd: process.cwd() };
try {
const req = createRequire(import.meta.url);
out.viaImportMeta = path.dirname(req.resolve('next/package.json'));
} catch (e) { out.viaImportMetaError = String(e); }
try {
const req = createRequire(path.join(process.cwd(), 'index.js'));
out.viaCwd = path.dirname(req.resolve('next/package.json'));
} catch (e) { out.viaCwdError = String(e); }
return out;
}
Next.js 16.3.6の既定のバンドラーでビルドし、成果物を起動してこの関数を呼び出しました。viaImportMetaは、NodeのTypeErrorで失敗しました。エラーコードはERR_INVALID_ARG_TYPEで、文面はpathという引数に文字列でなく数値が来たという内容です。文面の末尾の括弧には39897が入っていました。viaCwdは、実在するnode_modulesの下のパスを返しました。Next.js 16.2.2でも結果は同じでした。
ビルドの成果物のコードを読むと、置き換えの位置が分かりました。resolve(‘next/package.json’)の呼び出しは、成果物から消えていました。その位置には整数39897が直接書かれ、path.dirname(39897)という式になっていました。39897は、成果物に取り込まれたnextのpackage.jsonのモジュールIDで、Turbopackのビルドで割り当てられた番号です。
一方、import.meta.urlは数値になっていませんでした。Turbopackのビルドでは、import.meta.urlは実行時に関数を呼び出す式に置き換わっていました。その関数の中でデプロイ先のルートのパスからfile URLが組み立てられるので、値は文字列のままでした。viaCwdのresolveも、成果物の中で実行時の呼び出しのまま残っていました。
例外が発生した関数はpath.dirnameです。Node 25.7.0でpath.dirnameに数値を渡すと、ERR_INVALID_ARG_TYPEの例外が発生します。createRequireに数値を渡した場合は、ERR_INVALID_ARG_VALUEという別の例外が発生します。こちらの文面は、filenameの引数が不正だという内容です。
括弧の中の数値はモジュールIDです。Next.js公式ドキュメントのTurbopackのAPIリファレンスにある設定の表によると、本番のビルドではturbopackModuleIdsの既定値がdeterministic、つまり決定的な割り当てです。16.2.2の再現では、resolveの対象を別の公開のパッケージに替えました。そのパッケージは、アプリのバンドルに取り込まない設定に入れてあります。それでも置き換えは止まらず、括弧の中の数値はサービスのログの数値と一致しました。プロジェクトの構成が違う場合にも同じ値になるかは、確かめていません。39897と、webpackでビルドしたときの5607は、別の版で再現すると変わりうる値です。
resolveの型は、文字列を返すと宣言されています。数値に置き換わる現象は、ビルドが終わった後の成果物の中でだけ起きます。そのため、型検査ではこの失敗を検出できません。再現でも、next buildの工程に含まれるTypeScriptの検査は成功し、そのうえで実行時に失敗しました。
resolveの呼び出しをビルド時に解決する処理は、依存するファイルを成果物に取り込むための正当な静的解析です。不具合の原因は、サービスの実装がこの静的解析を前提にせずに書かれていたことにあります。
実行方式ごとのresolveの結果の比較
同じ関数を4つの実行方式で呼び出した結果を、次の表にまとめます。バンドルの列には、アプリのコードを1つの成果物にまとめる工程を通るかどうかを書きました。
| 実行方式 | バンドル | import.meta.urlの値 | viaImportMeta | viaCwd |
|---|---|---|---|---|
| tsxでソースを直接実行 | 通らない | ソースファイルのfile URL | 実在のパス | 実在のパス |
| Vitest 5.0.1のテストの中で実行 | 通らない | ソースファイルのfile URL | 実在のパス、テストは成功 | 実在のパス |
| next build、Turbopack、16.3.6と16.2.2 | 通る | 実行時に組み立てた文字列 | TypeError、16.3.6では数値39897 | 実在のパス |
| next build –webpack、16.3.6 | 通る | ビルドした機械上の絶対パスを指すfile URL | TypeError、数値5607 | ビルド時に警告、実行時にTypeError |
tsxとVitestでは、バンドルの工程を通らないので、resolveの呼び出しがソースのとおり実行時に評価されます。そのため、どちらの書き方も成功します。tsxの公式ドキュメントによると、tsxはesbuildでTypeScriptとESMを変換し、型を検査しません。バンドルについての記述はありません。VitestのCommon Errorsのページによると、ソースのファイルは既定でViteのmodule runnerで実行されます。Vitestの設定リファレンスにあるdepsのページによると、版5.0.1では、node_modulesの中の外部化されたモジュールが既定でNode自身の読み込みで取り込まれます。どちらのページにも、アプリのコードを1つの成果物にまとめる工程の記述はありません。
webpackでもviaImportMetaは数値で失敗しました。したがって、resolveの呼び出しの置き換えはTurbopackだけの挙動ではありません。webpackのビルドでは、ビルドした機械上のソースファイルの絶対パスが、file URLとして成果物に直接書き込まれていました。
2026年6月29日のNext.js 16.3のTurbopackの告知には、互換性の改善が2つ挙げられています。new URLと組み合わせたcreateRequireの扱いの改善と、Windowsでのimport.meta.urlの修正です。それでも、16.3.6でresolveの呼び出しの置き換えは残っていました。この記事に書いた挙動は、記載した版で確かめた範囲のものです。
作業ディレクトリを起点にする修正と、turbopackIgnoreの注釈を使う修正の比較
この節では、実行時にパッケージの場所を解決するコードの修正の方法を2つ比べます。1つは、サービスで採用した作業ディレクトリを起点にする修正です。もう1つは、Next.js公式ドキュメントのTurbopackのAPIリファレンスにある注釈を使う修正です。
同じページの注釈の表によると、webpack互換の注釈はrequire.resolve()の式にも使えます。対象の式は、dynamic import()、require()、require.resolve()、new Worker()の4つです。表では、turbopackIgnore: trueはTurbopackだけで解釈され、その呼び出しはバンドルの対象から外れると説明されています。webpackIgnore: trueは、webpackとTurbopackの両方で解釈されると書かれています。再現では、16.3.6のTurbopackで、import.meta.urlを起点にするviaImportMetaのresolveに渡す引数の直前に/* turbopackIgnore: true */を置きました。すると、import.meta.urlを起点にしたままでも実在のパスが返りました。
2つの修正の方法の結果と条件を、次の表にまとめます。
| 修正の方法 | Turbopack 16.3.6 | webpack 16.3.6 | 成り立つ条件 | 測っていない範囲 |
|---|---|---|---|---|
| 作業ディレクトリを起点にする、viaCwdの形 | 実在のパス | ビルド時に警告、実行時にTypeError | 実行時の作業ディレクトリの下にnode_modulesがあること。resolveの呼び出しがビルド時に解決されずに残ること | Next.jsのstandaloneの出力で配る形 |
| viaImportMetaの形のまま、resolveの引数の直前にturbopackIgnoreの注釈を置く | 実在のパス | 測っていない | 注釈がTurbopackで解釈されること | webpackでの挙動、16.2.2での挙動 |
作業ディレクトリを起点にする修正は、その形がバンドラーに静的に解決されないことに依存しています。webpackでは、同じ形のビルドで「module.createRequire failed parsing argument.」の警告が出ました。実行時には、undefinedのresolveを読もうとするTypeErrorで失敗しました。Next.jsのstandaloneの出力で配る形でこの修正が成り立つかは、測っていません。サービスの設定にはstandaloneの指定がありません。
注釈を使う修正では、import.meta.urlを起点にする書き方を残せます。その代わり、この修正が成り立つかは、注釈がTurbopackで解釈されるかどうかに依存します。どちらの修正を採るかは、配る形と使うバンドラーで決まります。2026年9月25日の時点でも、サービスのmainは作業ディレクトリを起点にする書き方のままです。
本番と同じビルドを起動して外から呼び出す確認の手順
この節では、デプロイ前の確認の手順を説明します。この手順は、最後の節で説明するデプロイ直後の失敗を受けて、開発側が記録に書いたものです。失敗と同じ日に、開発側は個人のメモリとルールの変遷のリポジトリに、次の運用を教訓として書きました。記録が対象にする変更は2種類です。1つは新しいツールの追加です。もう1つは、実行時にファイルを読み込む処理の追加で、読み込む先はパッケージに同梱されたデータファイルかnativeのモジュールです。これらの変更では、関数を直接呼び出すだけでは確認したことにしません。本番と同じビルドを手元で作って別のポートで起動し、外から呼び出して応答まで見てからデプロイします。
同じ記録には、2つの注意も書かれています。1つは、バンドラーが原因の不具合は単体テストでは再現しない、という注意です。もう1つは、実行時にパスを求めるコードを書いたら、バンドルを通った後に壊れうると開発者が疑っておく、という注意です。記録では、この運用は、サービスのルールの文書にこの失敗より前から載っている規律と同じ考えだと整理されています。その規律は、サーバが起動していても最新のコードで動いているとは限らない、というものです。10日後には、開発側がサービスの運用の文書にも同じ手順を追記しました。next buildとnext startでデプロイと同じ成果物を起動し、ツールを呼び出す手順です。
記録と運用の文書にある手順に、修正の後にAIが実際に行った確認を追加して、デプロイ前の手順として次にまとめます。項目の1と2が記録と運用の文書にある手順で、項目の3と4がAIの確認です。
- 本番と同じコマンドのnext buildで、手元のリポジトリに成果物を作る
- 作った成果物をnext startで、空いている別のポートに起動する
- 呼び出し元と同じ経路のAIエージェント向けのエンドポイントを、外からHTTPで呼び出し、応答の中身まで確認する
- 存在しない入力を渡したときに、エラーを伝える応答が返ることも確認する
この手順で確かめる対象は、関数の直接の呼び出しと単体テストでは通らないバンドルの工程です。
AIがデプロイ前に確かめたことと通していなかった工程
この節では、サービスにツールを追加したときに、開発を担当したAIが何を確かめたかを説明します。AIは機能を追加した時点で、デプロイの前に次の3つの確認をしました。
- lintと型検査と単体テストの全件を通しました
- ツールの関数をtsxのスクリプトから直接呼び出し、実際のデータを使って画像を作らせ、その画像を目で見ました
- AIのクライアントと標準入出力で接続する中継を通して、ツールの一覧と応答の形を確かめました
モデルに画像を読ませる4つ目の確認は、デプロイの後に回しました。
描画の単体テストでは、PDFの描画ライブラリを偽物に差し替えていませんでした。このテストでは、手で組んだ小さなPDFを本物の描画ライブラリに渡します。import.meta.urlを起点にするviaImportMetaと同じ書き方の関数も、テストの中で実際に実行され、成功していました。偽物に差し替えていたテストは、ツールの応答を組み立てる部分のテストだけです。実行方式ごとの比較の節の表のとおり、Vitestのテストでは、アプリのコードはバンドルされません。そのため、本物のライブラリを通したテストでも、resolveの呼び出しは置き換わらず、失敗は再現しませんでした。
AIは、デプロイする成果物の形にも配慮していました。同じコミットで、AIはNext.jsの設定に2つの項目を追加しています。1つは、描画ライブラリをアプリのバンドルに取り込まず、node_modulesから読ませる設定です。もう1つは、ファイル追跡の設定です。この設定で、描画ライブラリに同梱されているフォントなどのデータファイルとnativeのバイナリを成果物に含めました。しかし置き換えの仕組みの節で見たとおり、バンドルに取り込まない設定に入れたパッケージでも、resolveの呼び出しは整数に置き換わります。
AIが一度もしていなかった確認は、next buildの成果物を起動し、その外側からツールを呼び出す確認です。AIは、ツールの関数を直接呼び出して作らせた画像を、目で確かめました。しかし、この確認ではバンドルの工程を通らないので、AIはデプロイする成果物の挙動を確かめていませんでした。AIは、後で作った修正のPRの本文に、この確認の抜けを原因の1つとして書いています。テストで確かめた範囲と、実際に動くコードの範囲がずれていた失敗は、以前の記事1,300件規模のテストが通っていたのに、保存に失敗しても保存済みと表示していた画面でも扱いました。
デプロイ直後の失敗と当時の診断のずれ
この節では、機能を追加した日のデプロイの後に起きたことを、時系列で説明します。AIは機能を追加したPRをマージした直後に、検証用の環境にデプロイしてツールを呼び出しました。応答は、PDFのページを画像にできなかったことを伝えるテキストで、何度呼び出しても同じでした。サーバのログには、再現と同じ文面のERR_INVALID_ARG_TYPEのTypeErrorが記録されていました。末尾の括弧には5桁の数値が入っていました。
AIは1箇所を変えました。同梱のデータファイルの置き場を解決する書き方を、import.meta.urlを起点にするviaImportMetaの形から、作業ディレクトリを起点にするviaCwdの形に移しました。AIが挙げた根拠は次の3つです。
- デプロイ先でも、node_modulesはプロセスの作業ディレクトリの下にあります
- 同じリポジトリの別のデータは、すでに作業ディレクトリを起点にして読み込まれています
- 作業ディレクトリの値は、Turbopackの置き換えを受けません
修正した後、AIは修正した手元のリポジトリでnext buildを実行し、next startで空いているポートに起動しました。そのうえで、AIエージェント向けのエンドポイントにHTTPでリクエストを送り、ツールを呼び出しました。応答には文字と画像の2つが入り、画像はページの文字が読めるJPEGでした。存在しないページ番号を渡すと、エラーを伝える応答が返ることも確認しました。修正のPRをマージしてデプロイし直した後、AIはデプロイ先の同じエンドポイントに同じ呼び出しを送りました。AIは画像が返ることを確かめ、その結果をPRのコメントに残しました。
当時の診断は1か所ずれていました。当時の修正のコミットと記録では、原因がこう説明されています。import.meta.urlが数値のIDに置き換わり、その数値がcreateRequireに渡って例外になった、という説明です。この記事のために作った再現では、import.meta.urlは文字列のまま残りました。数値になった値はresolveの戻り値で、例外はpath.dirnameで発生しました。ログのエラーコードも、createRequireのERR_INVALID_ARG_VALUEでなく、path.dirnameのERR_INVALID_ARG_TYPEと一致します。viaCwdのresolveは、成果物の中でも実行時の呼び出しのまま残ります。そのため、診断が1か所ずれていても、修正で不具合は解消しました。
参考にした資料
本文の各所で参照した資料を、ここに再掲します。
- Next.js公式ドキュメントのTurbopackのAPIリファレンス、2026年8月3日更新。既定のバンドラー、型検査をしないこと、turbopackModuleIdsの既定値、注釈の対応の表を確認しました https://nextjs.org/docs/app/api-reference/turbopack
- Next.js 16.3のTurbopackの告知、2026年6月29日。createRequireとimport.meta.urlに関する互換性の改善を確認しました https://nextjs.org/blog/next-16-3-turbopack
- tsx公式ドキュメントのTypeScriptのページ。esbuildで変換することと、型を検査しないことを確認しました https://tsx.hirok.io/typescript
- Vitest公式ドキュメントのCommon Errors。ソースのファイルをViteのmodule runnerで実行することを確認しました https://vitest.dev/guide/common-errors
- Vitest公式ドキュメントのdepsの設定、版5.0.1。外部化されたモジュールをNode自身の読み込みで取り込むことを確認しました https://vitest.dev/config/deps