Skip to content

Commit

Permalink
feat: resolve .js → .ts in package.json exports & main
Browse files Browse the repository at this point in the history
closes #341
  • Loading branch information
privatenumber authored Jun 8, 2024
1 parent c35dbaa commit 4503421
Show file tree
Hide file tree
Showing 8 changed files with 183 additions and 34 deletions.
35 changes: 31 additions & 4 deletions src/cjs/api/module-resolve-filename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,9 @@ export const createResolveFilename = (
}

// If extension exists
const tsFilename = resolveTsFilename(resolve, request, parent);
if (tsFilename) {
return tsFilename + query;
const resolvedTsFilename = resolveTsFilename(resolve, request, parent);
if (resolvedTsFilename) {
return resolvedTsFilename + query;
}

try {
Expand All @@ -185,6 +185,33 @@ export const createResolveFilename = (
// Can be a node core module
return resolved + (path.isAbsolute(resolved) ? query : '');
} catch (error) {
const nodeError = error as NodeError;

// Exports map resolution
if (
nodeError.code === 'MODULE_NOT_FOUND'
&& typeof nodeError.path === 'string'
&& nodeError.path.endsWith('package.json')
) {
const isExportsPath = nodeError.message.match(/^Cannot find module '([^']+)'$/);
if (isExportsPath) {
const exportsPath = isExportsPath[1];
const tsFilename = resolveTsFilename(resolve, exportsPath, parent);
if (tsFilename) {
return tsFilename + query;
}
}

const isMainPath = nodeError.message.match(/^Cannot find module '([^']+)'. Please verify that the package.json has a valid "main" entry$/);
if (isMainPath) {
const mainPath = isMainPath[1];
const tsFilename = resolveTsFilename(resolve, mainPath, parent);
if (tsFilename) {
return tsFilename + query;
}
}
}

const resolved = (
tryExtensions(resolve, request)
// Default resolve handles resovling paths relative to the parent
Expand All @@ -194,6 +221,6 @@ export const createResolveFilename = (
return resolved + query;
}

throw error;
throw nodeError;
}
};
97 changes: 76 additions & 21 deletions src/esm/hook/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type {
ResolveFnOutput,
ResolveHookContext,
} from 'node:module';
import type { PackageJson } from 'type-fest';
import { readJsonFile } from '../../utils/read-json-file.js';
import { resolveTsPath } from '../../utils/resolve-ts-path.js';
import type { NodeError } from '../../types.js';
import { tsconfigPathsMatcher, allowJs } from '../../utils/tsconfig.js';
Expand Down Expand Up @@ -111,6 +113,33 @@ const tryDirectory = async (
}
};

const tryTsPaths = async (
url: string,
context: ResolveHookContext,
nextResolve: NextResolve,
) => {
const tsPaths = resolveTsPath(url);
if (!tsPaths) {
return;
}

for (const tsPath of tsPaths) {
try {
return await resolveMissingFormat(
await nextResolve(tsPath, context),
);
} catch (error) {
const { code } = error as NodeError;
if (
code !== 'ERR_MODULE_NOT_FOUND'
&& code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED'
) {
throw error;
}
}
}
};

export const resolve: resolve = async (
specifier,
context,
Expand Down Expand Up @@ -172,23 +201,9 @@ export const resolve: resolve = async (
)
) {
// TODO: When guessing the .ts extension in a package, should it guess if there's an export map?
const tsPaths = resolveTsPath(specifier);
if (tsPaths) {
for (const tsPath of tsPaths) {
try {
return await resolveMissingFormat(
await nextResolve(tsPath, context),
);
} catch (error) {
const { code } = error as NodeError;
if (
code !== 'ERR_MODULE_NOT_FOUND'
&& code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED'
) {
throw error;
}
}
}
const resolved = await tryTsPaths(specifier, context, nextResolve);
if (resolved) {
return resolved;
}
}

Expand All @@ -212,7 +227,8 @@ export const resolve: resolve = async (
error instanceof Error
&& !recursiveCall
) {
const { code } = error as NodeError;
const nodeError = error as NodeError;
const { code } = nodeError;
if (code === 'ERR_UNSUPPORTED_DIR_IMPORT') {
try {
return await tryDirectory(specifier, context, nextResolve);
Expand All @@ -224,9 +240,48 @@ export const resolve: resolve = async (
}

if (code === 'ERR_MODULE_NOT_FOUND') {
try {
return await tryExtensions(specifier, context, nextResolve);
} catch {}
// Resolving .js -> .ts in exports map
if (nodeError.url) {
const resolved = await tryTsPaths(nodeError.url, context, nextResolve);
if (resolved) {
return resolved;
}
} else {
const isExportPath = error.message.match(/^Cannot find module '([^']+)'/);
if (isExportPath) {
const [, exportPath] = isExportPath;
const resolved = await tryTsPaths(exportPath, context, nextResolve);
if (resolved) {
return resolved;
}
}

const isPackagePath = error.message.match(/^Cannot find package '([^']+)'/);
if (isPackagePath) {
const [, packageJsonPath] = isPackagePath;
const packageJsonUrl = pathToFileURL(packageJsonPath);

if (!packageJsonUrl.pathname.endsWith('/package.json')) {
packageJsonUrl.pathname += '/package.json';
}

const packageJson = await readJsonFile<PackageJson>(packageJsonUrl);
if (packageJson?.main) {
const resolvedMain = new URL(packageJson.main, packageJsonUrl);
const resolved = await tryTsPaths(resolvedMain.toString(), context, nextResolve);
if (resolved) {
return resolved;
}
}
}
}

// If not bare specifier
if (acceptsQuery) {
try {
return await tryExtensions(specifier, context, nextResolve);
} catch {}
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type NodeError = Error & {
code: string;
url?: string;
path?: string;
};

export type RequiredProperty<Type, Keys extends keyof Type> = Type & { [P in Keys]-?: Type[P] };
2 changes: 1 addition & 1 deletion src/utils/read-json-file.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'node:fs';

export const readJsonFile = <JsonType>(
filePath: string,
filePath: string | URL,
) => {
try {
const jsonString = fs.readFileSync(filePath, 'utf8');
Expand Down
13 changes: 13 additions & 0 deletions tests/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,5 +284,18 @@ export const files = {
'ts.ts': `${syntaxLowering}\nexport * from "#empty.js"`,
'empty.ts': 'export {}',
},
'pkg-main': {
'package.json': createPackageJson({
main: './index.js',
}),
'index.ts': syntaxLowering,
},
'pkg-exports': {
'package.json': createPackageJson({
type: 'module',
exports: './index.js',
}),
'index.ts': syntaxLowering,
},
},
};
2 changes: 1 addition & 1 deletion tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import { nodeVersions } from './utils/node-versions';
for (const nodeVersion of nodeVersions) {
const node = await createNode(nodeVersion);
await describe(`Node ${node.version}`, async ({ runTestSuite }) => {
await runTestSuite(import('./specs/smoke'), node);
await runTestSuite(import('./specs/api'), node);
await runTestSuite(import('./specs/cli'), node);
await runTestSuite(import('./specs/watch'), node);
await runTestSuite(import('./specs/loaders'), node);
await runTestSuite(import('./specs/smoke'), node);
await runTestSuite(import('./specs/tsconfig'), node);
});
}
Expand Down
60 changes: 56 additions & 4 deletions tests/specs/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { packageTypes } from '../utils/package-types.js';
const wasmPath = path.resolve('tests/fixtures/test.wasm');
const wasmPathUrl = pathToFileURL(wasmPath).toString();

export default testSuite(async ({ describe }, { tsx, supports }: NodeApis) => {
export default testSuite(async ({ describe }, { tsx, supports, version }: NodeApis) => {
describe('Smoke', ({ describe }) => {
for (const packageType of packageTypes) {
const isCommonJs = packageType === 'commonjs';
Expand Down Expand Up @@ -129,7 +129,7 @@ export default testSuite(async ({ describe }, { tsx, supports }: NodeApis) => {
pkgCommonjs,
pkgModule,
}));
// Could .js import TS files?
// Comment at EOF: could be a sourcemap declaration. Edge case for inserting functions here
Expand Down Expand Up @@ -259,14 +259,14 @@ export default testSuite(async ({ describe }, { tsx, supports }: NodeApis) => {
: ''
}
);
// .ts
import './ts/index.ts';
import './ts/index.js';
import './ts/index.jsx';
import './ts/index';
import './ts/';
// .jsx
import * as jsx from './jsx/index.jsx';
import './jsx/index.js';
Expand Down Expand Up @@ -397,6 +397,58 @@ export default testSuite(async ({ describe }, { tsx, supports }: NodeApis) => {
const coverageSourceMapCache = await hasCoverageSourcesContent(coverageDirectory);
expect(coverageSourceMapCache).toBe(true);
});

test('resolve ts in exports', async () => {
await using fixture = await createFixture({
'package.json': createPackageJson({ type: packageType }),
'index.ts': `
import A from 'pkg'
console.log(A satisfies 2)
`,
'node_modules/pkg': {
'package.json': createPackageJson({
name: 'pkg',
exports: './test.js',
}),
'test.ts': 'export default 1',
},
});

const p = await tsx(['index.ts'], {
cwd: fixture.path,
});
expect(p.failed).toBe(false);
});

/**
* Node v18 has a bug:
* Error [ERR_INTERNAL_ASSERTION]:
* Code: ERR_MODULE_NOT_FOUND; The provided arguments length (2) does
* not match the required ones (3)
*/
if (!version.startsWith('18.')) {
test('resolve ts in main', async () => {
await using fixture = await createFixture({
'package.json': createPackageJson({ type: packageType }),
'index.ts': `
import A from 'pkg'
console.log(A satisfies 2);
`,
'node_modules/pkg': {
'package.json': createPackageJson({
name: 'pkg',
main: './test.js',
}),
'test.ts': 'export default 1',
},
});

const p = await tsx(['index.ts'], {
cwd: fixture.path,
});
expect(p.failed).toBe(false);
});
}
});
}
});
Expand Down
6 changes: 3 additions & 3 deletions tests/utils/node-versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ export const nodeVersions = [
&& process.platform !== 'win32'
)
? [
latestMajor('22.1.0'),
latestMajor('22.2.0'),
'22.0.0',
latestMajor('21.7.3'),
'21.0.0',
latestMajor('20.12.2'),
latestMajor('20.14.0'),
'20.0.0',
latestMajor('18.20.2'),
latestMajor('18.20.3'),
'18.0.0',
] as const
: [] as const
Expand Down

0 comments on commit 4503421

Please sign in to comment.