Skip to content

[vitest-pool-workers] Fix require() resolving to ESM via "import" export condition - #13062

Closed
petebacondarwin wants to merge 3 commits into
mainfrom
fix/vitest-pool-workers-require-cjs-resolution
Closed

petebacondarwin wants to merge 3 commits into
mainfrom
fix/vitest-pool-workers-require-cjs-resolution

Conversation

@petebacondarwin

@petebacondarwin petebacondarwin commented Mar 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #12984, #13037.

When Vite 8 switched to a rolldown-based resolver, require() calls in the module-fallback service started resolving to the "import" export condition of dual-format packages (e.g. pg-protocol, pg-connection-string, mimetext) instead of "require"/"default". This caused workerd to receive an ESM file as a CommonJS module, producing SyntaxError: Cannot use import statement outside a module.

Root Cause (Vite 8 architecture)

Vite 8's main resolve plugin is vite:resolve-builtin — a native Rust builtin backed by viteResolvePlugin from rolldown/experimental. Its isRequire flag is a static value set at construction time (undefined in all normal configurations), so it always appends "import" to the conditions list regardless of what kind is passed to the resolveId hook.

  • Vite ≤7: the rollup-based resolve plugin read isRequire from custom["node-resolve"] (a private internal convention we were relying on).
  • Vite 8: custom["node-resolve"] is silently ignored. The kind: "require-call" field is read by vite:resolve-dev (the optimizer-path plugin) and sets isRequire dynamically — but only for packages that have been pre-bundled by the dep optimizer. For non-pre-bundled packages, vite:resolve-dev falls through to vite:resolve-builtin, which ignores kind. This appears to be a current limitation of the architecture rather than an intentional API, since the native plugin's interface (BindingViteResolvePluginResolveOptions) has isRequire as a static field with no per-call override.

Fix

Three complementary layers:

  1. custom["node-resolve"].isRequire — kept for Vite ≤7 compatibility (rollup-based resolver reads this).

  2. kind: "require-call" — added for Vite 8+ as a forward-looking hint. Works today for pre-bundled deps via vite:resolve-dev. If Vite fixes vite:resolve-builtin to read kind per-call, this will start working for the general case automatically.

  3. findCjsAlternative() — the reliable fallback for the general case in Vite 8. After pluginContainer.resolveId() returns, if the resolved path appears as an "import" condition value in the package's exports map, the "require"/"default" CJS alternative is returned instead. This is an explicit post-resolution fixup rather than a resolver-level fix, but it is correct and necessary given the current Vite 8 architecture.

The exports-map walk handles:

  • Standard subpath exports ({ ".": { "import": "...", "require": "..." } })
  • Sugar conditional exports ({ "import": "...", "require": "..." })
  • Nested "import" conditions (TypeScript-style: { "import": { "types": "...", "default": "..." }, "require": "..." })

Verified against both issues


Why the module-fallback approach is the right one

A reviewer might ask: "Can't we just do what vite-plugin-cloudflare does and avoid the module-fallback service altogether?"

vite-plugin-cloudflare uses the Vite Module Runner pattern: all deps pass through Vite's pre-bundler with noExternal: true, CJS packages are interop-wrapped into ESM at that stage, and workerd executes Vite-transformed code strings via an UnsafeEval binding (runInlinedModule). The unsafeModuleFallbackService is only used there for binary module types (Wasm, Data, Text) — never for JavaScript.

vitest-pool-workers intentionally takes the opposite approach, and for good reason: the entire point of this package is to run tests in the actual workerd runtime with production-equivalent module semantics.

The Module Runner (runInlinedModule) wraps each module's code in an async function body, not a real ES module. This changes semantics in ways that matter for test correctness: import.meta, live bindings, TDZ, module caching, and circular dependency handling all behave differently from how workerd's native ESM loader would handle them. A test suite that passes under runInlinedModule may not accurately reflect what happens in production.

By contrast, vitest-pool-workers' module-fallback approach lets workerd use its own native ESM loader for all user code. Files are served lazily from disk one at a time, with only minimal shimming (CJS→ESM name-export wrapping via cjs-module-lexer). workerd then loads them exactly as it would in production.

Pre-bundling everything upfront isn't a practical alternative either: test files are discovered dynamically at runtime (you can't know at startup which files will be run), Vitest's own internals are CJS-heavy and would require substantial interop work, and lazy per-file loading preserves module identity (two packages that both require('lodash') share a single copy, as in production).

So: the findCjsAlternative workaround added in this PR is the correct narrow fix for the Vite 8 regression. The right place to fix the symptom is in viteResolve(), precisely because module-fallback.ts is the component responsible for bridging Vite's resolver into workerd's native module system — and that bridge needs to correctly honour require() vs import semantics across Vite versions.


  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: bug fix restoring previously-working behaviour

…ort condition

When Vite 8's rolldown resolver handles a require() call, it can select
the "import" export condition from a dual-format package instead of
"require"/"default". This causes workerd to receive an ESM file where
it expects CommonJS, producing SyntaxError.

The module-fallback service now detects this case by checking whether
the resolved path appears as the "import" condition value in the
package's exports map, and redirects to the CJS alternative instead.

Fixes #12984, #13037
@petebacondarwin
petebacondarwin requested a review from a team as a code owner March 25, 2026 22:44
@changeset-bot

changeset-bot Bot commented Mar 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 17da622

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@workers-devprod

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • packages/vitest-pool-workers/src/pool/module-fallback.ts: [@cloudflare/wrangler]
  • packages/vitest-pool-workers/test/module-fallback.test.ts: [@cloudflare/wrangler]
  • packages/vitest-pool-workers/vitest.config.mts: [@cloudflare/wrangler]

@github-actions

github-actions Bot commented Mar 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ All changesets look good

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm Bonk, and I've done a quick review of your PR.

This PR adds a post-resolution check in the module-fallback service that detects when Vite 8's rolldown resolver picks an ESM "import" condition for a require() call, and redirects to the CJS "require"/"default" alternative.

  1. findCjsEntryInConditions does not handle nested conditions under "import" — When a package uses nested conditions like { "import": { "types": "./dist/index.d.mts", "default": "./esm/index.mjs" }, "require": "./dist/index.cjs" } (common in TypeScript packages), importEntry is an object, the typeof importEntry === "string" check fails, the recursion loop skips "import"/"require"/"default" keys, and the function silently returns undefined. The workaround won't activate for these packages. This is a real limitation worth documenting, though I acknowledge the targeted packages (pg-protocol, pg-connection-string) use the simpler flat pattern, so this may not be blocking.

requireEntry !== importEntry
) {
return requireEntry;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When importEntry is an object (nested conditions under "import", e.g. { types: "...", default: "./esm/index.mjs" }), this whole block is skipped, and the recursion below also skips "import" at line 420. This means the workaround silently does nothing for packages that nest conditions under "import" — a pattern that is very common in TypeScript packages (e.g. zod, drizzle-orm, etc.).

This could be addressed by also checking when importEntry is an object whether any of its terminal string values match esmRelPath, and if so extracting the corresponding CJS entry from requireEntry. Something like:

Suggested change
}
return requireEntry;
}
// Handle nested conditions under "import" (e.g. { types: "...", default: "./esm/index.mjs" })
// Check if any terminal string in the import entry matches the resolved ESM path
if (
typeof importEntry === "object" &&
importEntry !== null &&
typeof requireEntry === "string"
) {
const importValues = Object.values(
importEntry as Record<string, unknown>
);
if (importValues.some((v) => v === esmRelPath)) {
return requireEntry;
}
}

I acknowledge the targeted packages (pg-protocol, pg-connection-string) use the flat pattern, so this may not be urgent. But given how common this pattern is in the npm ecosystem, it may be worth handling now to avoid future reports.

@ask-bonk

ask-bonk Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

I'm Bonk, and I've done a quick review of your PR.

This PR adds a post-resolution check in the module-fallback service that detects when Vite 8's rolldown resolver picks an ESM "import" condition for a require() call, and redirects to the CJS "require"/"default" alternative. The fix is well-scoped, the helper functions are clean and pure, and the test coverage is thorough for the targeted cases.

I flagged one issue:

  1. findCjsEntryInConditions silently fails for nested conditions under "import" — When a package uses { "import": { "types": "...", "default": "./esm/index.mjs" }, "require": "./dist/index.cjs" } (very common in TypeScript packages), the function returns undefined because importEntry is an object, the string check fails, and the recursion skips "import"/"require"/"default" keys. I posted a suggestion with a concrete fix. This may not be blocking since the targeted packages (pg-protocol, pg-connection-string) use the flat pattern, but it's worth considering to preempt future reports.

github run

@pkg-pr-new

pkg-pr-new Bot commented Mar 25, 2026 •

Copy link
Copy Markdown
create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@13062

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@13062

miniflare

npm i https://pkg.pr.new/miniflare@13062

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@13062

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@13062

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@13062

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@13062

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@13062

wrangler

npm i https://pkg.pr.new/wrangler@13062

commit: 17da622

devin-ai-integration[bot]

This comment was marked as resolved.

@petebacondarwin
petebacondarwin marked this pull request as draft March 26, 2026 10:18
@petebacondarwin

Copy link
Copy Markdown
Contributor Author

Converting to draft while we see if there is a cleaner solution

…ve-builtin limitation

- Handle nested conditions under 'import' (TypeScript-style packages)
- Handle sugar conditional exports (no '.' subpath key)
- Pass kind:'require-call' to pluginContainer.resolveId for Vite 8+
  (works for pre-bundled deps via vite:resolve-dev; does not fix the
  general case because vite:resolve-builtin uses a static isRequire)
- Keep findCjsAlternative as the reliable fallback for the general case
- Expand unit tests to cover all new cases
- Update comments to accurately explain the Vite 8 architecture
@danieltroger

Copy link
Copy Markdown
Contributor

Would be awesome if this could be fixed soon!

@huybuidac

Copy link
Copy Markdown

can claude fable handle this one? too long time to wait

@martijnwalraven

Copy link
Copy Markdown
Contributor

We ran into exactly this issue (require() of a dual-format package resolving to the import-condition ESM entry inside workerd) with @cloudflare/vitest-pool-workers 0.18.0 on Vite 8, in a monorepo using the pool for its Worker tests.

We've been carrying this PR's fix as a pnpm patch (applied to the built dist/pool/index.mjs) for about three weeks now — it resolved the issue and we've seen no regressions in day-to-day use since.

Is this still planned to be merged? Happy to provide more detail on our setup if additional confirmation would help move it along.

@martijnwalraven

Copy link
Copy Markdown
Contributor

We dug into why this PR's kind: "require-call" hunk exists alongside the exports-map rescan, and can now answer the "cleaner solution" question concretely.

The kind hint is currently inert: rolldown's BindingCallableBuiltinPlugin.resolveId — the entry Vite 8 uses to invoke the native vite:resolve-builtin plugin programmatically — hardcodes ImportKind::Import and its options struct has no kind field, so the value never reaches the native resolver (which otherwise supports require conditions per-call). We verified this empirically on Vite 8.1.4 / rolldown 1.1.4: with only the kind hunk applied, the pg failure from #12984 reproduces unchanged; the CJS-alternative scan is the half doing the work. Filed upstream with a minimal repro and a proposed fix: rolldown/rolldown#10403.

Until that lands and rides a Vite release, the scan in this PR is the only working fix — we've been carrying it as a pnpm patch in production monorepo use since mid-June (re-cut cleanly against 0.18.7) with no regressions.

@petebacondarwin

Copy link
Copy Markdown
Contributor Author

I've pinged the rolldown team to see if we can get it fixed there.

@petebacondarwin

Copy link
Copy Markdown
Contributor Author

Closing in favour of #16335

@github-project-automation github-project-automation Bot moved this from Untriaged to Done in workers-sdk Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

5 participants