Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

本番のVercel API FunctionsだけCannot find moduleで落ちた

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

moduleResolution: Bundler の下でtscとViteのビルドは拡張子なしの相対importを素通りさせるが、@vercel/nodeの本番ランタイムはバンドルせず生のNode.js ESMとして実行するため、同じコードが本番のapi/配下だけでCannot find moduleになる。

結論

ローカルの型チェックとビルドは、api/ 配下が本番でバンドルされずに動くことを知りません。 tsconfig.json の moduleResolution: "Bundler" は拡張子なしの相対import(from "./_lib")を正常として型チェックし、vite build はフロントエンド(src/)だけをバンドルします。どちらも通るのに、@vercel/node が個別の関数として実行する api/ 配下だけが本番で Cannot find module で落ちました。

直し方は、api/ 配下の相対importに出力後の拡張子 .js を明示することです。

症状

挙手(協賛カードで運営する投票・質問ボードの最初の版)の本番で一通り動作確認したところ、協賛カードの表示/クリックを数える /api/ad、ルーム終了処理の /api/cleanup、ホスト操作の /api/host、入室の /api/rooms、協賛表示の /api/sponsor の5本の API Functions が全部落ちていました。ローカルの vite dev では同じコードが問題なく動いていました。

原因

package.json は "type": "module" で、Node.js は実行時にこれを ESM として解釈します。

tsconfig.json の moduleResolution は "Bundler" です。

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    ...
  }
}

このモードは「最終的に何らかのバンドラが解決する」ことを前提に型チェックを緩め、相対importの拡張子省略(from "./_lib")を正常として扱います。フロントエンド(src/)は実際にViteがビルド時に全部バンドルするため、この前提は成立します。

ところが api/ 配下は vercel.json の設定どおり @vercel/node が個別のサーバーレス関数としてデプロイします。

{
  "regions": ["hnd1"],
  "crons": [{ "path": "/api/cleanup", "schedule": "0 18 * * *" }],
  ...
}

ここはViteのバンドルを経由せず、TypeScriptをほぼそのまま変換した上で、生のNode.js ESMローダーが相対importを解決します。Node.js の ESM ローダーは拡張子の省略を許しません。ad.ts の1行目はこうなっていました。

// 変更前
import { db, isRoomId, today } from "./_lib";

tsc --noEmit も vite build もこの1行を問題として検出できません。前者は moduleResolution: Bundler の下で型として妥当と判定し、後者は api/ 配下をそもそもバンドル対象にしていないからです。結果、ビルドは常に緑のまま、本番の /api/* を実際に叩いたときだけ Cannot find module で落ちます。

直し方

./_lib からの相対importを持つ5ファイル(ad.ts・cleanup.ts・host.ts・rooms.ts・sponsor.ts)すべてで、import先に .js 拡張子を明示しました。

// 変更後(api/ad.ts)
import { db, isRoomId, today } from "./_lib.js";

ソースファイルは .ts のままで構いません。TypeScript は moduleResolution が Bundler でも NodeNext でも、相対importに出力後の拡張子(.js)を書くことを許容します。本番で実際に動くのは変換後の .js なので、Node.js の ESM ローダーはこの拡張子でだけ _lib を解決できます。

気づけなかった理由

moduleResolution: "Bundler" は、フロントエンドのビルドとローカル開発(vite dev)の両方を前提にした設定です。この2つは実際にViteが全部バンドルするため、拡張子の有無は問題になりません。

しかし api/ 配下だけは @vercel/node が個別にデプロイする対象で、Viteのバンドルを経由しません。tsconfig.json は include で api を含めていますが、moduleResolution の挙動自体は「api/ だけ別のランタイムで動く」ことを知らないため、型チェックとビルドのどちらも常に緑のまま、本番の実行時エラーとしてだけ露出しました。

よくある質問

Q1ビルドコマンドはなぜこの不整合を検出しなかったのですか?

tsc --noEmit はtsconfig.jsonのmoduleResolution: Bundlerに従って拡張子なしの相対importを正常なものとして型チェックし、vite build はフロントエンド(src/)だけをバンドルの対象にしていて api/ 配下のVercel Functionsは対象外です。どちらの工程も拡張子の省略に気づけません。

Q2api/配下のファイルすべてが同じ問題を持っていましたか?

はい。ad.ts・cleanup.ts・host.ts・rooms.ts・sponsor.tsの5本すべてが1行目付近で ./_lib からの相対importを持っており、全部が同じ拡張子の省略をしていました。

Q3直し方は拡張子を付けるだけですか?

はい。ソースファイルは.tsのままで、importの指定だけを ./_lib.js のように出力後の拡張子で書きます。moduleResolutionがBundlerでもNodeNextでも、この書き方はコンパイルエラーになりません。

確認した環境

  • @vercel/node ^5.0.0、package.json "type": "module"(kyoshu、2026-10-08時点)
  • tsconfig.json moduleResolution: "Bundler"(kyoshu、2026-10-08時点)

この記事の根拠

  • JSONファイル 4〜4行目コミット ae05340
  • JSONファイル 6〜6行目コミット ae05340
  • JSONファイル 1〜4行目コミット ae05340
  • TypeScriptファイル 3〜3行目コミット 09f157d
  • TypeScriptファイル 3〜3行目コミット 09f157d
  • TypeScriptファイル 3〜3行目コミット 09f157d
  • TypeScriptファイル 3〜3行目コミット 09f157d
  • TypeScriptファイル 3〜3行目コミット 09f157d

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。