Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/esm/hook/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,34 @@ const addQuery = (
query: string,
) => `${url}${url.includes('?') ? '&' : '?'}${query}`;

// When tsx's CJS resolver (preserve-query.ts) returns a path with a
// `?namespace=<id>` cache-isolation query appended, the CJS loader feeds
// that path through pathToFileURL to dispatch via the module customization
// hooks. pathToFileURL encodes the `?` as `%3F` inside the URL pathname,
// then ESM resolve re-enters with that URL as the specifier. Without tsx's
// own virtual-query marker (`tsx-commonjs-virtual-query=1`) the encoded
// segment is not a tsx ESM URL, just a CJS-bridge artifact. Forwarding it
// to nextResolve makes Node stat a literal `index.cjs?namespace=...` path
// and ENOENT. Strip the encoded segment so resolution falls back to the
// real file on disk; the CJS bridge keeps its own Module._cache namespace
// isolation via the in-memory cache key, so nothing else needs the query
// at this point.
const stripCjsBridgeArtifact = (url: string | undefined) => {
if (!url || !url.startsWith(fileUrlPrefix)) {
return url;
}
const fileUrl = new URL(url);
if (fileUrl.searchParams.has(commonJsVirtualQuerySearchParameter)) {
return url;
}
const queryIndex = fileUrl.pathname.toLowerCase().lastIndexOf('%3f');
if (queryIndex === -1) {
return url;
}
fileUrl.pathname = fileUrl.pathname.slice(0, queryIndex);
return fileUrl.toString();
};

const preserveCommonJsQueryIdentity = (
url: string,
format: string | null | undefined,
Expand Down Expand Up @@ -578,6 +606,12 @@ export const createResolve = (
return nextResolve(specifier, context);
}

specifier = stripCjsBridgeArtifact(specifier) ?? specifier;
const cleanedParentURL = stripCjsBridgeArtifact(context.parentURL);
if (cleanedParentURL !== context.parentURL) {
context.parentURL = cleanedParentURL;
}

let requestNamespace = getNamespace(specifier) ?? (
// Inherit namespace from parent
context.parentURL && getNamespace(context.parentURL)
Expand Down Expand Up @@ -723,6 +757,12 @@ export const createResolveSync = (
return nextResolve(specifier, context);
}

specifier = stripCjsBridgeArtifact(specifier) ?? specifier;
const cleanedParentURL = stripCjsBridgeArtifact(context.parentURL);
if (cleanedParentURL !== context.parentURL) {
context.parentURL = cleanedParentURL;
}

let requestNamespace = getNamespace(specifier) ?? (
// Inherit namespace from parent
context.parentURL && getNamespace(context.parentURL)
Expand Down
40 changes: 40 additions & 0 deletions tests/specs/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,46 @@ export const api = (node: NodeApis) => describe('API', () => {
].join(String.raw`\n`)));
});

test('requires a CJS dependency from CJS-context .ts under namespace', async () => {
// Regression: tsx's CJS resolver appends a
// `?namespace=<id>` cache-isolation query to the resolved
// file path. The CJS loader runs that path through
// pathToFileURL to dispatch via the module customization
// hooks, encoding the `?` as `%3F` inside the URL pathname
// and re-entering ESM resolve with that URL as the
// specifier. Without a guard the encoded segment reached
// Node's default resolver and ENOENT'd with
// "Cannot find module .../index.cjs?namespace=...".
// Reproduces on every supported Node version.
await using fixture = await createFixture({
'package.json': createPackageJson({ type: 'module' }),
'import.mjs': outdent`
import { tsImport } from ${JSON.stringify(tsxEsmApiPath)};
await tsImport('./nested/entry.ts', import.meta.url);
`,
'nested/package.json': createPackageJson({ name: 'nested' }),
'nested/entry.ts': outdent`
const { osType } = require('cjs-dep');
console.log('type:', typeof osType);
`,
'node_modules/cjs-dep/package.json': createPackageJson({
name: 'cjs-dep',
main: './index.cjs',
}),
'node_modules/cjs-dep/index.cjs': outdent`
const os = require('node:os');
module.exports = { osType: os.type() };
`,
});

const { stdout } = await execaNode(fixture.getPath('import.mjs'), [], {
nodePath: node.path,
nodeOptions: [],
});

expect(stdout).toBe('type: string');
});

test('namespace allows async nested calls without cross contamination', async () => {
await using fixture = await createFixture({
'package.json': createPackageJson({ type: 'module' }),
Expand Down