Skip to content
Merged
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
45 changes: 33 additions & 12 deletions src/esm/hook/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type {
ResolveHook,
ResolveHookContext,
Expand Down Expand Up @@ -96,6 +96,13 @@ const isModuleNotFound = (
|| code === 'MODULE_NOT_FOUND'
);

const isCommonJsRequireContext = (
context: ResolveHookContext,
) => (
context.conditions.includes('require')
&& !context.conditions.includes('import')
);

const resolveExtensions = async (
url: string,
context: ResolveHookContext,
Expand Down Expand Up @@ -387,17 +394,38 @@ const resolveDirectorySync = (
}

if (isDirectoryPattern.test(specifier)) {
// On Node's sync hooks, a CommonJS require() inside a dependency reaches
// this hook. A bare specifier with a trailing slash (e.g. `process/`) is a
// package, not a relative directory, so defer to resolveBaseSync, which
// lets Node resolve the package while retrying TypeScript extensions.
// https://github.com/privatenumber/tsx/issues/800
const isCjsRequire = isCommonJsRequireContext(context);
if (isCjsRequire && !isFilePath(specifier)) {
return resolveBaseSync(specifier, context, nextResolve, hookData);
}

const urlParsed = new URL(specifier, context.parentURL);

// If directory, can be index.js, index.ts, etc.
urlParsed.pathname = path.join(urlParsed.pathname, 'index');

return resolveExtensionsSync(
urlParsed.toString(),
if (!isCjsRequire) {
return resolveExtensionsSync(urlParsed.toString(), context, nextResolve, true)!;
}

// Node's CommonJS resolver rejects file:// URLs, so resolve the implicit
// index from a filesystem path. Fall back to Node's directory resolution
// (package.json "main") via resolveBaseSync when no index file exists.
//
// This prefers the index over "main", matching tsx's CommonJS loader
// (which prioritizes index.ts). Native Node resolves "main" first.
const indexResolved = resolveExtensionsSync(
fileURLToPath(urlParsed),
context,
nextResolve,
true,
)!;
false,
);
return indexResolved ?? resolveBaseSync(specifier, context, nextResolve, hookData);
}

try {
Expand Down Expand Up @@ -519,13 +547,6 @@ const resolveTsPathsSync = (

const tsxProtocol = 'tsx://';

const isCommonJsRequireContext = (
context: ResolveHookContext,
) => (
context.conditions.includes('require')
&& !context.conditions.includes('import')
);

const addQuery = (
url: string,
query: string,
Expand Down
135 changes: 135 additions & 0 deletions tests/specs/version-sensitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,141 @@ export const versionSensitiveTests = (node: NodeApis) => describe('Version-sensi
});
});

// https://github.com/privatenumber/tsx/issues/800
test('sync ESM hook resolves directory requires inside dependencies', async () => {
await using fixture = await createFixture({
'package.json': createPackageJson({ type: 'commonjs' }),
'entry.cjs': 'console.log(JSON.stringify(require("dep")));',
node_modules: {
// Bare dependency required with a trailing slash, like
// readable-stream's `require('process/')`. The trailing slash
// must not be treated as a relative directory.
'bare-dep': {
'package.json': createPackageJson({
type: 'commonjs',
main: './index.js',
}),
'index.js': 'module.exports = "bare-ok";',
},
dep: {
'package.json': createPackageJson({
type: 'commonjs',
main: './lib/sub/entry.js',
}),
lib: {
// `require('..')` from the nested entry resolves here.
'index.js': 'module.exports = { parent: "parent-ok" };',
sub: {
'entry.js': `
const parent = require('..');
const bare = require('bare-dep/');
module.exports = { parent: parent.parent, bare };
`,
},
},
},
},
});

const process = await node.hook(['entry.cjs'], fixture.path);

expect(process.stderr).toBe('');
expect(process.exitCode).toBe(0);
expect(JSON.parse(process.stdout)).toEqual({
parent: 'parent-ok',
bare: 'bare-ok',
});
});

// https://github.com/privatenumber/tsx/issues/800
test('sync ESM hook resolves a dependency directory require to a TypeScript index', async () => {
await using fixture = await createFixture({
'package.json': createPackageJson({ type: 'commonjs' }),
'entry.cjs': 'console.log(require("ts-dep").tag);',
'node_modules/ts-dep': {
'package.json': createPackageJson({
type: 'commonjs',
main: './lib/sub/entry.js',
}),
lib: {
// TypeScript directory index, with no index.js sibling: the
// type annotation proves it is transformed, not found as JS.
'index.ts': 'export const tag: string = "ts-index";',
sub: {
'entry.js': 'module.exports = require("..");',
},
},
},
});

const process = await node.hook(['entry.cjs'], fixture.path);

expect(process.stderr).toBe('');
expect(process.exitCode).toBe(0);
expect(process.stdout).toBe('ts-index');
});

// https://github.com/privatenumber/tsx/issues/800
test('sync ESM hook resolves a dependency directory require via package.json "main"', async () => {
await using fixture = await createFixture({
'package.json': createPackageJson({ type: 'commonjs' }),
'entry.cjs': 'console.log(require("main-dep").from);',
'node_modules/main-dep': {
'package.json': createPackageJson({
type: 'commonjs',
main: './lib/sub/entry.js',
}),
lib: {
// Nested package.json "main" with no index file: require('..')
// must fall back to it rather than a synthesized index.
'package.json': createPackageJson({ main: './real-main.js' }),
'real-main.js': 'module.exports = { from: "real-main" };',
sub: {
'entry.js': 'module.exports = require("..");',
},
},
},
});

const process = await node.hook(['entry.cjs'], fixture.path);

expect(process.stderr).toBe('');
expect(process.exitCode).toBe(0);
expect(process.stdout).toBe('real-main');
});

// https://github.com/privatenumber/tsx/issues/800
test('sync ESM hook resolves a trailing-slash package require to a TypeScript main', async () => {
await using fixture = await createFixture({
'package.json': createPackageJson({ type: 'commonjs' }),
'entry.cjs': 'console.log(require("driver-dep").v);',
node_modules: {
'driver-dep': {
'package.json': createPackageJson({
type: 'commonjs',
main: './entry.js',
}),
// Trailing-slash require of a package whose main is TS-only:
// must still retry TypeScript extensions on the package main.
'entry.js': 'module.exports = require("ts-main-pkg/");',
},
'ts-main-pkg': {
'package.json': createPackageJson({
type: 'commonjs',
main: './main.js',
}),
'main.ts': 'export const v: string = "ts-main";',
},
},
});

const process = await node.hook(['entry.cjs'], fixture.path);

expect(process.stderr).toBe('');
expect(process.exitCode).toBe(0);
expect(process.stdout).toBe('ts-main');
});

await test('watch reruns when imported TypeScript file changes', async () => {
await using fixture = await createFixture({
'package.json': createPackageJson({ type: 'commonjs' }),
Expand Down
Loading