DEV Community

cometa-kaito
cometa-kaito

Posted on Originally published at tech.rebounder.jp

Only Our Vercel API Functions Threw Cannot Find Module

Conclusion

Local type-checking and the build don't know that api/ runs unbundled in production. tsconfig.json's moduleResolution: "Bundler" type-checks an extensionless relative import (from "./_lib") as valid, and vite build only bundles the frontend (src/). Both pass — yet the api/ functions, which @vercel/node deploys and runs individually, crashed in production with Cannot find module.

The fix: add the post-build .js extension to every relative import under api/.

Symptom

During an end-to-end check of Kyoshu (a free sponsor-funded polling/Q&A board, its first shipped version) in production, all five API Functions were down: /api/ad (counts sponsor-card views/clicks), /api/cleanup (closes a room), /api/host (host actions), /api/rooms (joining a room), and /api/sponsor (sponsor display). The same code worked fine under local vite dev.

Cause

package.json has "type": "module", so Node.js interprets the code as ESM at runtime.

tsconfig.json sets moduleResolution to "Bundler":

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

This mode relaxes type-checking on the assumption that some bundler will resolve the import in the end, so an extensionless relative import (from "./_lib") type-checks as fine. For the frontend (src/), that assumption holds — Vite really does bundle everything at build time.

But api/ is deployed differently. Per vercel.json, @vercel/node deploys each file under api/ as its own serverless function:

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

This path skips Vite's bundling. The TypeScript is transpiled close to 1:1, and the plain Node.js ESM loader resolves the relative import at runtime — and that loader does not allow omitting the extension. Line 3 of ad.ts read:

// before
import { db, isRoomId, today } from "./_lib";
Enter fullscreen mode Exit fullscreen mode

Neither tsc --noEmit nor vite build can catch this line. The former judges it type-valid under moduleResolution: Bundler; the latter never looks at api/ in the first place. The build stays green every time — the failure only surfaces when /api/* is actually hit in production.

Fix

All five files with a relative import from ./_lib — ad.ts, cleanup.ts, host.ts, rooms.ts, sponsor.ts — got the .js extension added to the import:

// after (api/ad.ts)
import { db, isRoomId, today } from "./_lib.js";
Enter fullscreen mode Exit fullscreen mode

The source file stays .ts. TypeScript allows writing the post-build extension (.js) in a relative import under both moduleResolution: Bundler and NodeNext. Since what actually runs in production is the compiled .js, that's the only extension the Node.js ESM loader can resolve _lib with.

Why it went unnoticed

moduleResolution: "Bundler" is written for the frontend build and local development (vite dev) — both of which really do get bundled by Vite, so the extension never matters there.

But api/ is deployed separately, one function at a time, by @vercel/node, bypassing Vite's bundling entirely. tsconfig.json's include does list api, but moduleResolution's own behavior has no concept of "api/ alone runs on a different runtime." Both type-checking and the build stayed green; the mismatch only showed up as a production runtime error.


Originally published at tech.rebounder.jp.

Top comments (0)