Skip to content

A server bundle built on a hoisted install layout crashes at boot #11

Description

@davidvornholt

Nothing deployed is broken today and CI's artifact is clean — this is a trap waiting for the first deployment built anywhere other than CI. If the production server bundle is ever built on a machine whose node_modules uses the flat "hoisted" layout, the artifact carries up to 47 imports for packages the app never declared, and it crashes at startup (for example Cannot find package 'h3-v2') when run anywhere those packages do not happen to sit. The cost of leaving it alone is zero until the deployment phase. The fix is a small guard test plus three cleanups, best landed with the deployment work.

Background

The web app's server is bundled by Vite. For every package a server file imports by name, Vite either copies that package's code into the bundle or leaves the import to be resolved at runtime — and it decides per import, by whether the name resolves from the app's own node_modules. Bun can lay out node_modules two ways. "Isolated" (the default here) gives each package only its declared dependencies. "Hoisted" puts everything in one flat folder. Under a hoisted layout every transitive package resolves from the app folder, so Vite leaves dozens of imports external that the app never declared; that bundle only runs on machines with an equally permissive layout. Under an isolated layout those names do not resolve, get copied in, and the artifact is portable.

Verified facts

  • CI (bun 1.3.14, bun install --frozen-lockfile, no bunfig.toml anywhere) installs isolated. Probed with the exact bun 1.3.14 binary in a fresh worktree at the PR feat: add two-theme design comparison pages for the design pick #10 merge head.
  • A forced rebuild in that probe tree emits exactly 12 bare import specifiers across all dist/server chunks, every one a declared dependency of apps/web.
  • A hoisted install (bun install --linker hoisted) at the same code produced 47 undeclared bare specifiers, and that artifact failed to boot under an isolated runtime layout. The isolated-built artifact boots under both layouts.
  • Turbo replays dist from cache across working trees ("FULL TURBO") and the install layout is not a cache input, so a hoisted-built artifact can silently reappear in a clean isolated tree. This is what originally turned the PR feat: add two-theme design comparison pages for the design pick #10 quality gate red.

Suggested fix, at deployment time

  1. Add a colocated test that scans every dist/server chunk's real import specifiers (new Bun.Transpiler({ loader: "js" }).scanImports(...)) and fails if any bare specifier is not declared by apps/web. About 20 lines; it fails today under a hoisted build and turns this whole class into a gate.
  2. Correct the comment in apps/web/vite.config.ts. It says inlining @postlude/db would re-resolve that package's dependencies against the app's node_modules; Vite actually resolves inlined imports from the importing file, which works fine. The real hazard was the hoisted layout leaving undeclared imports external. The externalization itself remains right — it pins the boundary regardless of layout — but for this reason, not the stated one. (Commit 240bc16's message tells the same story backwards; the squash-merge message on main is accurate.)
  3. Tighten the external predicate to id === '@postlude/db' || id.startsWith('@postlude/db/') so an unrelated package whose name merely starts with that prefix is never externalized by accident.
  4. Document in apps/web/README.md that the built server is not self-contained: it needs the workspace's installed node_modules beside it at runtime (including @postlude/db, which resolves TypeScript source), which constrains how the container image must be assembled.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    deferred-findingDeferred review finding filed by a review-fix cycle

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions