Skip to content

Commit 4503421

Browse files
authoredJun 8, 2024··
feat: resolve .js → .ts in package.json exports & main
closes #341
1 parent c35dbaa commit 4503421

File tree

8 files changed

+183
-34
lines changed

8 files changed

+183
-34
lines changed
 

‎src/cjs/api/module-resolve-filename.ts

+31-4
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,9 @@ export const createResolveFilename = (
174174
}
175175

176176
// If extension exists
177-
const tsFilename = resolveTsFilename(resolve, request, parent);
178-
if (tsFilename) {
179-
return tsFilename + query;
177+
const resolvedTsFilename = resolveTsFilename(resolve, request, parent);
178+
if (resolvedTsFilename) {
179+
return resolvedTsFilename + query;
180180
}
181181

182182
try {
@@ -185,6 +185,33 @@ export const createResolveFilename = (
185185
// Can be a node core module
186186
return resolved + (path.isAbsolute(resolved) ? query : '');
187187
} catch (error) {
188+
const nodeError = error as NodeError;
189+
190+
// Exports map resolution
191+
if (
192+
nodeError.code === 'MODULE_NOT_FOUND'
193+
&& typeof nodeError.path === 'string'
194+
&& nodeError.path.endsWith('package.json')
195+
) {
196+
const isExportsPath = nodeError.message.match(/^Cannot find module '([^']+)'$/);
197+
if (isExportsPath) {
198+
const exportsPath = isExportsPath[1];
199+
const tsFilename = resolveTsFilename(resolve, exportsPath, parent);
200+
if (tsFilename) {
201+
return tsFilename + query;
202+
}
203+
}
204+
205+
const isMainPath = nodeError.message.match(/^Cannot find module '([^']+)'. Please verify that the package.json has a valid "main" entry$/);
206+
if (isMainPath) {
207+
const mainPath = isMainPath[1];
208+
const tsFilename = resolveTsFilename(resolve, mainPath, parent);
209+
if (tsFilename) {
210+
return tsFilename + query;
211+
}
212+
}
213+
}
214+
188215
const resolved = (
189216
tryExtensions(resolve, request)
190217
// Default resolve handles resovling paths relative to the parent
@@ -194,6 +221,6 @@ export const createResolveFilename = (
194221
return resolved + query;
195222
}
196223

197-
throw error;
224+
throw nodeError;
198225
}
199226
};

‎src/esm/hook/resolve.ts

+76-21
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type {
44
ResolveFnOutput,
55
ResolveHookContext,
66
} from 'node:module';
7+
import type { PackageJson } from 'type-fest';
8+
import { readJsonFile } from '../../utils/read-json-file.js';
79
import { resolveTsPath } from '../../utils/resolve-ts-path.js';
810
import type { NodeError } from '../../types.js';
911
import { tsconfigPathsMatcher, allowJs } from '../../utils/tsconfig.js';
@@ -111,6 +113,33 @@ const tryDirectory = async (
111113
}
112114
};
113115

116+
const tryTsPaths = async (
117+
url: string,
118+
context: ResolveHookContext,
119+
nextResolve: NextResolve,
120+
) => {
121+
const tsPaths = resolveTsPath(url);
122+
if (!tsPaths) {
123+
return;
124+
}
125+
126+
for (const tsPath of tsPaths) {
127+
try {
128+
return await resolveMissingFormat(
129+
await nextResolve(tsPath, context),
130+
);
131+
} catch (error) {
132+
const { code } = error as NodeError;
133+
if (
134+
code !== 'ERR_MODULE_NOT_FOUND'
135+
&& code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED'
136+
) {
137+
throw error;
138+
}
139+
}
140+
}
141+
};
142+
114143
export const resolve: resolve = async (
115144
specifier,
116145
context,
@@ -172,23 +201,9 @@ export const resolve: resolve = async (
172201
)
173202
) {
174203
// TODO: When guessing the .ts extension in a package, should it guess if there's an export map?
175-
const tsPaths = resolveTsPath(specifier);
176-
if (tsPaths) {
177-
for (const tsPath of tsPaths) {
178-
try {
179-
return await resolveMissingFormat(
180-
await nextResolve(tsPath, context),
181-
);
182-
} catch (error) {
183-
const { code } = error as NodeError;
184-
if (
185-
code !== 'ERR_MODULE_NOT_FOUND'
186-
&& code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED'
187-
) {
188-
throw error;
189-
}
190-
}
191-
}
204+
const resolved = await tryTsPaths(specifier, context, nextResolve);
205+
if (resolved) {
206+
return resolved;
192207
}
193208
}
194209

@@ -212,7 +227,8 @@ export const resolve: resolve = async (
212227
error instanceof Error
213228
&& !recursiveCall
214229
) {
215-
const { code } = error as NodeError;
230+
const nodeError = error as NodeError;
231+
const { code } = nodeError;
216232
if (code === 'ERR_UNSUPPORTED_DIR_IMPORT') {
217233
try {
218234
return await tryDirectory(specifier, context, nextResolve);
@@ -224,9 +240,48 @@ export const resolve: resolve = async (
224240
}
225241

226242
if (code === 'ERR_MODULE_NOT_FOUND') {
227-
try {
228-
return await tryExtensions(specifier, context, nextResolve);
229-
} catch {}
243+
// Resolving .js -> .ts in exports map
244+
if (nodeError.url) {
245+
const resolved = await tryTsPaths(nodeError.url, context, nextResolve);
246+
if (resolved) {
247+
return resolved;
248+
}
249+
} else {
250+
const isExportPath = error.message.match(/^Cannot find module '([^']+)'/);
251+
if (isExportPath) {
252+
const [, exportPath] = isExportPath;
253+
const resolved = await tryTsPaths(exportPath, context, nextResolve);
254+
if (resolved) {
255+
return resolved;
256+
}
257+
}
258+
259+
const isPackagePath = error.message.match(/^Cannot find package '([^']+)'/);
260+
if (isPackagePath) {
261+
const [, packageJsonPath] = isPackagePath;
262+
const packageJsonUrl = pathToFileURL(packageJsonPath);
263+
264+
if (!packageJsonUrl.pathname.endsWith('/package.json')) {
265+
packageJsonUrl.pathname += '/package.json';
266+
}
267+
268+
const packageJson = await readJsonFile<PackageJson>(packageJsonUrl);
269+
if (packageJson?.main) {
270+
const resolvedMain = new URL(packageJson.main, packageJsonUrl);
271+
const resolved = await tryTsPaths(resolvedMain.toString(), context, nextResolve);
272+
if (resolved) {
273+
return resolved;
274+
}
275+
}
276+
}
277+
}
278+
279+
// If not bare specifier
280+
if (acceptsQuery) {
281+
try {
282+
return await tryExtensions(specifier, context, nextResolve);
283+
} catch {}
284+
}
230285
}
231286
}
232287

‎src/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export type NodeError = Error & {
22
code: string;
3+
url?: string;
4+
path?: string;
35
};
46

57
export type RequiredProperty<Type, Keys extends keyof Type> = Type & { [P in Keys]-?: Type[P] };

‎src/utils/read-json-file.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'node:fs';
22

33
export const readJsonFile = <JsonType>(
4-
filePath: string,
4+
filePath: string | URL,
55
) => {
66
try {
77
const jsonString = fs.readFileSync(filePath, 'utf8');

‎tests/fixtures.ts

+13
Original file line numberDiff line numberDiff line change
@@ -284,5 +284,18 @@ export const files = {
284284
'ts.ts': `${syntaxLowering}\nexport * from "#empty.js"`,
285285
'empty.ts': 'export {}',
286286
},
287+
'pkg-main': {
288+
'package.json': createPackageJson({
289+
main: './index.js',
290+
}),
291+
'index.ts': syntaxLowering,
292+
},
293+
'pkg-exports': {
294+
'package.json': createPackageJson({
295+
type: 'module',
296+
exports: './index.js',
297+
}),
298+
'index.ts': syntaxLowering,
299+
},
287300
},
288301
};

‎tests/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import { nodeVersions } from './utils/node-versions';
1010
for (const nodeVersion of nodeVersions) {
1111
const node = await createNode(nodeVersion);
1212
await describe(`Node ${node.version}`, async ({ runTestSuite }) => {
13+
await runTestSuite(import('./specs/smoke'), node);
1314
await runTestSuite(import('./specs/api'), node);
1415
await runTestSuite(import('./specs/cli'), node);
1516
await runTestSuite(import('./specs/watch'), node);
1617
await runTestSuite(import('./specs/loaders'), node);
17-
await runTestSuite(import('./specs/smoke'), node);
1818
await runTestSuite(import('./specs/tsconfig'), node);
1919
});
2020
}

‎tests/specs/smoke.ts

+56-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { packageTypes } from '../utils/package-types.js';
1212
const wasmPath = path.resolve('tests/fixtures/test.wasm');
1313
const wasmPathUrl = pathToFileURL(wasmPath).toString();
1414

15-
export default testSuite(async ({ describe }, { tsx, supports }: NodeApis) => {
15+
export default testSuite(async ({ describe }, { tsx, supports, version }: NodeApis) => {
1616
describe('Smoke', ({ describe }) => {
1717
for (const packageType of packageTypes) {
1818
const isCommonJs = packageType === 'commonjs';
@@ -129,7 +129,7 @@ export default testSuite(async ({ describe }, { tsx, supports }: NodeApis) => {
129129
pkgCommonjs,
130130
pkgModule,
131131
}));
132-
132+
133133
// Could .js import TS files?
134134
135135
// Comment at EOF: could be a sourcemap declaration. Edge case for inserting functions here
@@ -259,14 +259,14 @@ export default testSuite(async ({ describe }, { tsx, supports }: NodeApis) => {
259259
: ''
260260
}
261261
);
262-
262+
263263
// .ts
264264
import './ts/index.ts';
265265
import './ts/index.js';
266266
import './ts/index.jsx';
267267
import './ts/index';
268268
import './ts/';
269-
269+
270270
// .jsx
271271
import * as jsx from './jsx/index.jsx';
272272
import './jsx/index.js';
@@ -397,6 +397,58 @@ export default testSuite(async ({ describe }, { tsx, supports }: NodeApis) => {
397397
const coverageSourceMapCache = await hasCoverageSourcesContent(coverageDirectory);
398398
expect(coverageSourceMapCache).toBe(true);
399399
});
400+
401+
test('resolve ts in exports', async () => {
402+
await using fixture = await createFixture({
403+
'package.json': createPackageJson({ type: packageType }),
404+
'index.ts': `
405+
import A from 'pkg'
406+
console.log(A satisfies 2)
407+
`,
408+
'node_modules/pkg': {
409+
'package.json': createPackageJson({
410+
name: 'pkg',
411+
exports: './test.js',
412+
}),
413+
'test.ts': 'export default 1',
414+
},
415+
});
416+
417+
const p = await tsx(['index.ts'], {
418+
cwd: fixture.path,
419+
});
420+
expect(p.failed).toBe(false);
421+
});
422+
423+
/**
424+
* Node v18 has a bug:
425+
* Error [ERR_INTERNAL_ASSERTION]:
426+
* Code: ERR_MODULE_NOT_FOUND; The provided arguments length (2) does
427+
* not match the required ones (3)
428+
*/
429+
if (!version.startsWith('18.')) {
430+
test('resolve ts in main', async () => {
431+
await using fixture = await createFixture({
432+
'package.json': createPackageJson({ type: packageType }),
433+
'index.ts': `
434+
import A from 'pkg'
435+
console.log(A satisfies 2);
436+
`,
437+
'node_modules/pkg': {
438+
'package.json': createPackageJson({
439+
name: 'pkg',
440+
main: './test.js',
441+
}),
442+
'test.ts': 'export default 1',
443+
},
444+
});
445+
446+
const p = await tsx(['index.ts'], {
447+
cwd: fixture.path,
448+
});
449+
expect(p.failed).toBe(false);
450+
});
451+
}
400452
});
401453
}
402454
});

‎tests/utils/node-versions.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ export const nodeVersions = [
1313
&& process.platform !== 'win32'
1414
)
1515
? [
16-
latestMajor('22.1.0'),
16+
latestMajor('22.2.0'),
1717
'22.0.0',
1818
latestMajor('21.7.3'),
1919
'21.0.0',
20-
latestMajor('20.12.2'),
20+
latestMajor('20.14.0'),
2121
'20.0.0',
22-
latestMajor('18.20.2'),
22+
latestMajor('18.20.3'),
2323
'18.0.0',
2424
] as const
2525
: [] as const

0 commit comments

Comments
 (0)
Please sign in to comment.