From 4503421e601b05078e1adb13b86a1a4619c9805d Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sat, 8 Jun 2024 20:42:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20resolve=20.js=20=E2=86=92=20.ts=20in=20?= =?UTF-8?q?`package.json`=20exports=20&=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://github.com/privatenumber/tsx/issues/341 --- src/cjs/api/module-resolve-filename.ts | 35 ++++++++-- src/esm/hook/resolve.ts | 97 ++++++++++++++++++++------ src/types.ts | 2 + src/utils/read-json-file.ts | 2 +- tests/fixtures.ts | 13 ++++ tests/index.ts | 2 +- tests/specs/smoke.ts | 60 ++++++++++++++-- tests/utils/node-versions.ts | 6 +- 8 files changed, 183 insertions(+), 34 deletions(-) diff --git a/src/cjs/api/module-resolve-filename.ts b/src/cjs/api/module-resolve-filename.ts index 5ac896b5d..0c60cbe96 100644 --- a/src/cjs/api/module-resolve-filename.ts +++ b/src/cjs/api/module-resolve-filename.ts @@ -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 { @@ -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 @@ -194,6 +221,6 @@ export const createResolveFilename = ( return resolved + query; } - throw error; + throw nodeError; } }; diff --git a/src/esm/hook/resolve.ts b/src/esm/hook/resolve.ts index 2f6ca4ee1..a3112b453 100644 --- a/src/esm/hook/resolve.ts +++ b/src/esm/hook/resolve.ts @@ -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'; @@ -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, @@ -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; } } @@ -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); @@ -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(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 {} + } } } diff --git a/src/types.ts b/src/types.ts index 1b300a8c3..9ecd88ffb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,7 @@ export type NodeError = Error & { code: string; + url?: string; + path?: string; }; export type RequiredProperty = Type & { [P in Keys]-?: Type[P] }; diff --git a/src/utils/read-json-file.ts b/src/utils/read-json-file.ts index 1a4e50675..49f1c2150 100644 --- a/src/utils/read-json-file.ts +++ b/src/utils/read-json-file.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; export const readJsonFile = ( - filePath: string, + filePath: string | URL, ) => { try { const jsonString = fs.readFileSync(filePath, 'utf8'); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index a99f0e37d..3c7f37b5c 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -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, + }, }, }; diff --git a/tests/index.ts b/tests/index.ts index 0d4177c25..6661521a8 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -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); }); } diff --git a/tests/specs/smoke.ts b/tests/specs/smoke.ts index e84b6c95a..9809c5346 100644 --- a/tests/specs/smoke.ts +++ b/tests/specs/smoke.ts @@ -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'; @@ -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 @@ -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'; @@ -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); + }); + } }); } }); diff --git a/tests/utils/node-versions.ts b/tests/utils/node-versions.ts index b9a8442e6..77e3a7eaa 100644 --- a/tests/utils/node-versions.ts +++ b/tests/utils/node-versions.ts @@ -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