導入
最近、AIエージェントに何でも任せられるようになった、みたいな景気のいい話をよく
目にします。一方で私が最近つまずいたのは、そういう華やかな話とは真逆の、地味な
import文の書き方でした。世の中どんどん便利になっていくのに、自分は../shared/types
の後ろに.jsをつけるかどうかで丸一日悩んでいたわけです。
目的
個人開発しているニュース収集アプリLoki-Huginnには、毎日自動でRSSフィードを
取りに行って記事を整形・保存する処理があります。ユーザーがアプリを開いたときでは
なく、誰も見ていなくても毎日決まった時間に動く必要があったので、Vercel Functionsで
Node.jsのサーバーサイドコードとして書き、Vercelの定期実行(Cron)で1日1回叩く
構成にしました。集めた記事はVercel Blobに保存し、フロントエンドはそれを読みに
行くだけの薄いつくりです。
実装
RSS取得・タグ付け・重複除去のロジックを書き、ローカルで何度も動作確認しました。
型チェックもビルドも通っていたので、そのままデプロイしました。
ところが本番環境で該当の処理を呼び出すと、こういうエラーで落ちていました。
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../shared/types' imported from '.../server/collect.js'
ローカルでは再現しません。ビルドも型チェックも通っています。それなのに本番では
毎回落ちます。この時点では、何が原因か見当がつきませんでした。
環境変数の設定漏れかと思って確認、違いました。依存パッケージのバージョン差かと
思って確認、これも違いました。Node.jsのバージョンを揃えても変わりません。
手がかりはVercelのランタイムログでした。ERR_MODULE_NOT_FOUNDというエラー
コードと、見つからなかったファイルパスがそのまま書いてあります。ここで
「ビルドやテストでは検出できない、実行時特有の何かが起きている」と気づきました。
コードでは、こういう拡張子なしの相対importを書いていました。
// server/collect.ts
import { Article } from '../shared/types';
型チェックもViteの開発サーバーも、これで普通に通っていました。私はこのとき
初めて、「型チェックが通る」ことと「実際に実行できる」ことはイコールではない、
という話を実感することになりました。
tsconfig.jsonではmoduleResolution: "bundler"という設定を使っています。この
設定だと拡張子なしの相対importでも型チェックが通ります。ローカルの開発サーバーは
Viteが担っていて、Viteはバンドル時に拡張子の省略をよしなに解決してくれます。
つまり「型チェックが通る」も「ローカルで動く」も、Viteが裏で肩代わりしてくれて
いただけで、実行時のモジュール解決が正しいかどうかとは関係ありませんでした。
Vercel Functionsの実行環境は素のNode.js(ESM)です。Node.jsのESMローダーは
CommonJSのrequireと違い、相対importの拡張子省略を許していません。.jsを
読み込むつもりなら、ソースが.tsでも、ビルド後の.jsという拡張子を書く
必要があります。
api/・server/・shared/配下の相対importすべてに.js拡張子を付けました。
// 修正後
import { Article } from '../shared/types.js';
ソースが.tsなのに.jsと書くのは最初違和感がありましたが、「ビルド後に
実際に存在するファイル名」だと思えば納得できます。フロントエンド側(src/
配下)はViteがビルド・バンドルを両方担うので対象外です。影響が出るのは、
Node.jsが素のESMとして直接実行するapi/・server/配下だけでした。
反省・教訓
「ローカルで動く」「型チェックが通る」を、本番でもそのまま動く保証だと思い
込んでいたのが今回の反省点です。ビルドツールとランタイムでモジュール解決の
ルールが違う、というだけの地味な話でしたが、似たようなことは他にもありそうだ
なと思っています。
あとはやっぱり、エラーメッセージは最後まで読んだほうがいいなと。ERR_MODULE_ NOT_FOUNDという文字列とファイルパスさえ見えていれば、もう少し早く気づけた
はずです。
このルールはプロジェクトの開発ガイドラインに書いておきました。次に同じ構成の
コードを書くときの自分向けのメモです。