Skip to content

fix: externalize CJS-only npm packages in SSR build#3765

Open
bartlomieju wants to merge 3 commits into
mainfrom
fix/ssr-externalize-cjs
Open

fix: externalize CJS-only npm packages in SSR build#3765
bartlomieju wants to merge 3 commits into
mainfrom
fix/ssr-externalize-cjs

Conversation

@bartlomieju
Copy link
Copy Markdown
Contributor

Summary

CJS dependencies (Sharp, ioredis, MongoDB, etc.) cause fatal TDZ ReferenceError when building for production, even though they work fine in dev mode. The root cause is that Fresh's CJS-to-ESM babel transform hoists require() calls to import declarations, and Rollup can reorder these const bindings in the bundled output, causing references before initialization.

Fix: Instead of transforming CJS modules, externalize them in the SSR build. They're loaded at runtime by Deno's Node compat layer, which handles CJS natively.

How it works

The SSR Rollup config now has an external function that checks each imported package:

  1. Reads the package's package.json from node_modules
  2. If the package has "type": "module", a "module" field, or "import" conditions in "exports"keep bundled (ESM, no issues)
  3. If the package is CJS-only (none of the above) → externalize (skip CJS transform, load at runtime)

Framework packages (preact, @preact/signals, fresh) are always bundled regardless, to prevent duplicate module instances.

Impact

Should fix the entire class of "CJS dependency breaks production build" issues:

Closes #3653

Test plan

  • All 28 existing build tests pass (2 pre-existing pwa failures unrelated)
  • Clone the reproduction repo and verify deno task build && deno task start works
  • Test with ioredis, MongoDB, and other CJS deps
  • Verify preact/signals still work correctly (no duplicate module issues)

🤖 Generated with Claude Code

bartlomieju and others added 3 commits April 9, 2026 13:14
CJS dependencies like Sharp, ioredis, and MongoDB cause TDZ errors
when Rollup bundles the SSR output, because the CJS-to-ESM transform
hoists require() to import declarations that Rollup can reorder.

Instead of transforming CJS modules, externalize them in the SSR
build so they're loaded at runtime by Deno's Node compat layer.
A package is externalized only if it has no ESM entry point (no
"type": "module", no "module" field, no "import" condition in
"exports"). Framework packages (preact, fresh) are always bundled
to avoid duplicate module instances.

This should also fix #3673 (ioredis), #3505 (mongoose), #3478
(mongodb), and #3449 (supabase/postgres-js).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a test fixture with a CJS-only npm package and verifies:
1. The build succeeds (no TDZ errors)
2. The CJS module is externalized (not inlined in the bundle)
3. The production server works with the externalized dependency

Also fixes the externalization approach to use Rollup's external
option (which receives bare specifiers) instead of Vite's
resolve.external (which doesn't work with noExternal: true).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@lunadogbot lunadogbot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is red on every test job because packages/plugin-vite/tests/fixtures/cjs_dependency/routes/index.tsx:3 imports cjs-test-module but the fixture has no deno.json (or package.json) declaring the import, so deno task check:types fails with TS2307: Import "cjs-test-module" not a dependency and not in import map. The integration test in build_test.ts:689 also expects node_modules/cjs-test-module/ to exist for the symlink and prod launch, but no such fixture is committed. Add a deno.json to the fixture and commit (or generate during test setup) a minimal node_modules/cjs-test-module/{package.json,index.js}.

  • nit: isCjsOnly (packages/plugin-vite/src/mod.ts:106) checks for the import condition via JSON.stringify(pkg.exports).includes('"import"') — substring-matches any key or value containing the literal "import" (e.g. a custom "importmap" condition or a path with import in its name). Walking the exports object and looking for an exact import key is more robust.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CJS dependencies cause fatal issues when building/running that go unnoticed in dev mode

2 participants