[vitest-pool-workers] Fix module fallback resolving bare specifiers to wrong subpath export#12615
Conversation
🦋 Changeset detectedLatest commit: 699e85c 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 |
fa9f199 to
fee25ec
Compare
fee25ec to
8942d63
Compare
…o wrong subpath export When a dependency has both an npm dependency and a subpath export with the same name (e.g. dependency "some-lib" and subpath export "./some-lib"), the module fallback service incorrectly resolves the bare specifier to the subpath export file instead of the actual npm package. This is triggered by pnpm's symlinked node_modules structure, which causes workerd to join the bare specifier with the referrer directory, accidentally matching the subpath export file. The fix detects this collision by checking whether a bare specifier resolved to a file within the same package as the referrer, and if so, walks the node_modules tree to find and resolve the actual npm package.
8942d63 to
36da767
Compare
|
Codeowners approval required for this PR:
Show detailed file reviewers
|
create-cloudflare
@cloudflare/kv-asset-handler
miniflare
@cloudflare/pages-shared
@cloudflare/unenv-preset
@cloudflare/vite-plugin
@cloudflare/vitest-pool-workers
@cloudflare/workers-editor-shared
wrangler
commit: |
|
Important Note to self and to anyone who reviews this PR, I think that the bug is only present in pnpm, npm is not effected (and I assume neither other PMs are), see: https://github.com/marshallswain/vitest-pool-workers-subpath-export-bug/blob/76551ccfdc412f65ef90470515f5681fe04c66da/README.md?plain=1#L38-L40 |
|
Codeowners approval required for this PR:
Show detailed file reviewers
|
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Codeowners approval required for this PR:
Show detailed file reviewers
|
|
Codeowners approval required for this PR:
Show detailed file reviewers
|
| if (!exports) { | ||
| const main = pkgJson.module ?? pkgJson.main ?? "index.js"; | ||
| const resolved = posixPath.join(pkgDir, main); | ||
| return maybeGetTargetFilePath(resolved) ?? resolved; |
There was a problem hiding this comment.
🟡 Missing isRequire argument in maybeGetTargetFilePath call skips extension probing
On line 448, maybeGetTargetFilePath(resolved) is called with only one argument, but the function signature at packages/vitest-pool-workers/src/pool/module-fallback.ts:193-196 requires two: (target: string, isRequire: boolean). Since the build uses tsdown/esbuild (which doesn't type-check), at runtime isRequire will be undefined (falsy), so the if (isRequire) branch at line 202 is skipped and extension probing (.js, .mjs, .cjs, .json) never happens.
This means if the resolved npm package has a main/module field without a file extension (e.g. "main": "lib/index"), maybeGetTargetFilePath returns undefined, and the ?? resolved fallback returns a non-existent path (<pkgDir>/lib/index). That path then propagates back to resolve() → load(), which will fail trying to read the file. The correct fix is to pass an appropriate boolean (likely true to match how require extension probing works in the rest of the file).
| return maybeGetTargetFilePath(resolved) ?? resolved; | |
| return maybeGetTargetFilePath(resolved, /* isRequire */ true) ?? resolved; |
Was this helpful? React with 👍 or 👎 to provide feedback.
Reproduction here: https://github.com/marshallswain/vitest-pool-workers-subpath-export-bug
Fixes a bug where the module fallback service resolves bare specifiers to wrong subpath exports.
When a dependency has both an npm dependency and a subpath export with the same name (e.g., dependency
"some-lib"and subpath export"./some-lib"), the module fallback service could resolve the bare specifier to the subpath export file instead of the actual npm package. This is particularly triggered when using pnpm, whose symlinkednode_modulesstructure causes workerd to join the bare specifier with the referrer directory, andmaybeGetTargetFilePathfinds the subpath export file before any resolution logic runs.pnpm layout that triggers the bug
Root cause
workerd sends a module fallback request with the bare specifier
"some-lib"and the referrer path (e.g.,.../my-adapter/dist/index.js). The fallback handler joins these to produce a target path like.../my-adapter/dist/some-lib. ThemaybeGetTargetFilePath()function then tries file extensions and finds.../my-adapter/dist/some-lib.js— which is the subpath export file, not the npm package.Fix
Added a
maybeCorrectSubpathCollision()check that detects when a resolved path lands inside the same package as the referrer (indicating a likely subpath export collision). When detected, it walksnode_modulesto find the actual npm package, reads itspackage.json, and resolves the correct entry point through theexportsmap,module, ormainfields.The check is applied at both resolution paths:
maybeGetTargetFilePath()returns early (the primary bug path)viteResolve()returns (a secondary path where Vite's resolver could also match the subpath export)References
This fix aligns the module fallback behavior with Node's ESM resolution algorithm specification. Per the spec, when code inside
my-adaptercontains the bare specifierimport { createApp } from "some-lib", Node resolves it through two stages:PACKAGE_SELF_RESOLVE— checks if the specifier matches the containing package's ownnamefield. Sincepjson.nameis"my-adapter", not"some-lib", self-resolution returnsundefinedand does not consult theexportsmap at all.PACKAGE_RESOLVEstep 10 — walks up throughnode_modules/directories to locate a directory namedsome-lib, then reads that package'spackage.jsonand resolves through itsexports/main.The bug was that both
maybeGetTargetFilePath()and Vite's resolver were effectively skipping thenode_moduleswalk and matching the specifier"some-lib"against the referrer package's own subpath exports (i.e., the"./some-lib"entry inmy-adapter'sexportsmap). Per the spec, a package'sexportsmap is only consulted for a bare specifier whenpjson.name === packageName(self-resolve) or after the package is located via thenode_moduleswalk — neither of which should matchmy-adapter's exports when resolving the specifier"some-lib".Note on scope: The
resolveExportsEntry()helper in this fix is a pragmatic approximation of the spec'sPACKAGE_EXPORTS_RESOLVE, not a full implementation. It hardcodes condition preference asimport > default > requireand does not handle wildcard/pattern exports (e.g.,"./*": "./dist/*.js") or additional conditions like"node"or"worker". This is a reasonable trade-off for a fallback handler — the fix only needs to correctly locate the entry point of the actual npm package once the collision is detected, and the common cases (exportswith".",module,main) are covered.