diff --git a/src/cjs/api/module-resolve-filename.ts b/src/cjs/api/module-resolve-filename.ts deleted file mode 100644 index 46e895eea..000000000 --- a/src/cjs/api/module-resolve-filename.ts +++ /dev/null @@ -1,278 +0,0 @@ -import path from 'node:path'; -import Module from 'node:module'; -import { fileURLToPath } from 'node:url'; -import { mapTsExtensions } from '../../utils/map-ts-extensions.js'; -import type { NodeError } from '../../types.js'; -import { - isRelativePath, - isFilePath, - fileUrlPrefix, - tsExtensionsPattern, - isDirectoryPattern, - nodeModulesPath, -} from '../../utils/path-utils.js'; -import { tsconfigPathsMatcher, allowJs } from '../../utils/tsconfig.js'; -import { urlSearchParamsStringify } from '../../utils/url-search-params-stringify.js'; -import type { ResolveFilename, SimpleResolve, LoaderState } from './types.js'; -import { createImplicitResolver } from './resolve-implicit-extensions.js'; - -const getOriginalFilePath = ( - request: string, -) => { - if (!request.startsWith('data:text/javascript,')) { - return; - } - - const queryIndex = request.indexOf('?'); - if (queryIndex === -1) { - return; - } - - const searchParams = new URLSearchParams(request.slice(queryIndex + 1)); - const filePath = searchParams.get('filePath'); - if (filePath) { - return filePath; - } -}; - -export const interopCjsExports = ( - request: string, -) => { - const filePath = getOriginalFilePath(request); - if (filePath) { - // The CJS module cache needs to be updated with the actual path for export parsing to work - // https://github.com/nodejs/node/blob/v22.2.0/lib/internal/modules/esm/translators.js#L338 - Module._cache[filePath] = Module._cache[request]; - delete Module._cache[request]; - request = filePath; - } - return request; -}; - -/** - * Typescript gives .ts, .cts, or .mts priority over actual .js, .cjs, or .mjs extensions - */ -const resolveTsFilename = ( - resolve: SimpleResolve, - request: string, - parent: Module.Parent | undefined, -) => { - if ( - isDirectoryPattern.test(request) - || ( - !(parent?.filename && tsExtensionsPattern.test(parent.filename)) - && !allowJs - ) - ) { - return; - } - - const tsPath = mapTsExtensions(request); - if (!tsPath) { - return; - } - - for (const tryTsPath of tsPath) { - try { - return resolve(tryTsPath); - } catch (error) { - const { code } = error as NodeError; - if ( - code !== 'MODULE_NOT_FOUND' - && code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED' - ) { - throw error; - } - } - } -}; - -const resolveRequest = ( - request: string, - parent: Module.Parent | undefined, - nextResolve: SimpleResolve, -) => { - // Support file protocol - if (request.startsWith(fileUrlPrefix)) { - request = fileURLToPath(request); - } - - // Resolve TS path alias - if ( - tsconfigPathsMatcher - - // bare specifier - && !isRelativePath(request) - - // Dependency paths should not be resolved using tsconfig.json - && !parent?.filename?.includes(nodeModulesPath) - ) { - const possiblePaths = tsconfigPathsMatcher(request); - - for (const possiblePath of possiblePaths) { - const tsFilename = resolveTsFilename(nextResolve, possiblePath, parent); - if (tsFilename) { - return tsFilename; - } - - try { - return nextResolve(possiblePath); - } catch {} - } - } - - // It should only try to resolve TS extensions first if it's a local file (non dependency) - if (isFilePath(request)) { - const resolvedTsFilename = resolveTsFilename(nextResolve, request, parent); - if (resolvedTsFilename) { - return resolvedTsFilename; - } - } - - try { - return nextResolve(request); - } catch (error) { - const nodeError = error as NodeError; - - // Exports map resolution - if ( - nodeError.code === 'MODULE_NOT_FOUND' - && typeof nodeError.path === 'string' - && nodeError.path.endsWith(`${path.sep}package.json`) - ) { - const isExportsPath = nodeError.message.match(/^Cannot find module '([^']+)'$/); - if (isExportsPath) { - const exportsPath = isExportsPath[1]; - const tsFilename = resolveTsFilename(nextResolve, exportsPath, parent); - if (tsFilename) { - return tsFilename; - } - } - - 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(nextResolve, mainPath, parent); - if (tsFilename) { - return tsFilename; - } - } - } - - throw nodeError; - } -}; - -const cjsPreparseCall = 'at cjsPreparseModuleExports (node:internal'; -const fromCjsLexer = ( - error: Error, -) => { - const stack = error.stack!.split('\n').slice(1); - return ( - stack[1].includes(cjsPreparseCall) - || stack[2].includes(cjsPreparseCall) - ); -}; - -export const createResolveFilename = ( - state: LoaderState, - nextResolve: ResolveFilename, - namespace?: string, -): ResolveFilename => ( - request, - parent, - ...restOfArgs -) => { - if (state.enabled === false) { - return nextResolve(request, parent, ...restOfArgs); - } - - request = interopCjsExports(request); - - // Strip query string - const requestAndQuery = request.split('?'); - const searchParams = new URLSearchParams(requestAndQuery[1]); - - if (parent?.filename) { - const filePath = getOriginalFilePath(parent.filename); - let query: string | undefined; - if (filePath) { - const pathAndQuery = filePath.split('?'); - const newFilename = pathAndQuery[0]; - query = pathAndQuery[1]; - - /** - * Can't delete the old cache entry because there's an assertion - * https://github.com/nodejs/node/blob/v20.15.0/lib/internal/modules/esm/translators.js#L347 - */ - // delete Module._cache[parent.filename]; - - parent.filename = newFilename; - parent.path = path.dirname(newFilename); - // https://github.com/nodejs/node/blob/v20.15.0/lib/internal/modules/esm/translators.js#L383 - parent.paths = Module._nodeModulePaths(parent.path); - - Module._cache[newFilename] = parent as NodeModule; - } - - if (!query) { - query = parent.filename.split('?')[1]; - } - - // Inherit parent namespace if it exists - const parentQuery = new URLSearchParams(query); - const parentNamespace = parentQuery.get('namespace'); - if (parentNamespace) { - searchParams.append('namespace', parentNamespace); - } - } - - // If request namespace doesnt match the namespace, ignore - if ((searchParams.get('namespace') ?? undefined) !== namespace) { - return nextResolve(request, parent, ...restOfArgs); - } - - /** - * Custom implicit resolver to resolve .ts over .js extensions - * - * To support implicit extensions, we need to enhance the resolver with our own - * re-implementation of the implicit extension resolution - * - * Also, when namespaced, the loaders are registered to the extensions in a hidden way - * so Node's built-in resolver will not try those extensions - */ - const _nextResolve = createImplicitResolver(nextResolve); - - const resolve: SimpleResolve = request_ => _nextResolve( - request_, - parent, - ...restOfArgs, - ); - - let resolved = resolveRequest(requestAndQuery[0], parent, resolve); - - // Only add query back if it's a file path (not a core Node module) - if ( - path.isAbsolute(resolved) - - // These two have native loaders which don't support queries - && !resolved.endsWith('.json') - && !resolved.endsWith('.node') - - /** - * Detect if this is called by the CJS lexer, the resolved path is directly passed into - * readFile to parse the exports - */ - && !( - // Only the CJS lexer doesn't pass in the rest of the arguments - // https://github.com/nodejs/node/blob/v20.15.0/lib/internal/modules/esm/translators.js#L415 - restOfArgs.length === 0 - // eslint-disable-next-line unicorn/error-message - && fromCjsLexer(new Error()) - ) - ) { - resolved += urlSearchParamsStringify(searchParams); - } - - return resolved; -}; diff --git a/src/cjs/api/module-resolve-filename/index.ts b/src/cjs/api/module-resolve-filename/index.ts new file mode 100644 index 000000000..9e88026a2 --- /dev/null +++ b/src/cjs/api/module-resolve-filename/index.ts @@ -0,0 +1,92 @@ +import Module from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { + isFilePath, + fileUrlPrefix, + tsExtensionsPattern, + nodeModulesPath, +} from '../../../utils/path-utils.js'; +import { tsconfigPathsMatcher } from '../../../utils/tsconfig.js'; +import type { ResolveFilename, SimpleResolve, LoaderState } from '../types.js'; +import { createImplicitResolver } from './resolve-implicit-extensions.js'; +import { interopCjsExports } from './interop-cjs-exports.js'; +import { createTsExtensionResolver } from './resolve-ts-extensions.js'; +import { preserveQuery } from './preserve-query.js'; + +const resolveTsPaths = ( + request: string, + parent: Module.Parent | undefined, + nextResolve: SimpleResolve, +) => { + // Support file protocol + if (request.startsWith(fileUrlPrefix)) { + request = fileURLToPath(request); + } + + // Resolve TS path alias + if ( + tsconfigPathsMatcher + + // bare specifier + && !isFilePath(request) + + // Dependency paths should not be resolved using tsconfig.json + && !parent?.filename?.includes(nodeModulesPath) + ) { + const possiblePaths = tsconfigPathsMatcher(request); + for (const possiblePath of possiblePaths) { + try { + return nextResolve(possiblePath); + } catch {} + } + } + + return nextResolve(request); +}; + +export const createResolveFilename = ( + state: LoaderState, + nextResolve: ResolveFilename, + namespace?: string, +): ResolveFilename => ( + request, + parent, + ...restOfArgs +) => { + if (state.enabled === false) { + return nextResolve(request, parent, ...restOfArgs); + } + + request = interopCjsExports(request); + + const [ + cleanRequest, + searchParams, + appendQuery, + ] = preserveQuery(request, parent); + + // If request namespace doesnt match the namespace, ignore + if ((searchParams.get('namespace') ?? undefined) !== namespace) { + return nextResolve(request, parent, ...restOfArgs); + } + + let nextResolveSimple: SimpleResolve = request_ => nextResolve( + request_, + parent, + ...restOfArgs, + ); + + nextResolveSimple = createTsExtensionResolver( + nextResolveSimple, + Boolean(parent?.filename && tsExtensionsPattern.test(parent.filename)), + ); + + nextResolveSimple = createImplicitResolver(nextResolveSimple); + + const resolved = resolveTsPaths(cleanRequest, parent, nextResolveSimple); + + return appendQuery( + resolved, + restOfArgs.length, + ); +}; diff --git a/src/cjs/api/module-resolve-filename/interop-cjs-exports.ts b/src/cjs/api/module-resolve-filename/interop-cjs-exports.ts new file mode 100644 index 000000000..2d6a5ebdc --- /dev/null +++ b/src/cjs/api/module-resolve-filename/interop-cjs-exports.ts @@ -0,0 +1,34 @@ +import Module from 'node:module'; + +export const getOriginalFilePath = ( + request: string, +) => { + if (!request.startsWith('data:text/javascript,')) { + return; + } + + const queryIndex = request.indexOf('?'); + if (queryIndex === -1) { + return; + } + + const searchParams = new URLSearchParams(request.slice(queryIndex + 1)); + const filePath = searchParams.get('filePath'); + if (filePath) { + return filePath; + } +}; + +export const interopCjsExports = ( + request: string, +) => { + const filePath = getOriginalFilePath(request); + if (filePath) { + // The CJS module cache needs to be updated with the actual path for export parsing to work + // https://github.com/nodejs/node/blob/v22.2.0/lib/internal/modules/esm/translators.js#L338 + Module._cache[filePath] = Module._cache[request]; + delete Module._cache[request]; + request = filePath; + } + return request; +}; diff --git a/src/cjs/api/module-resolve-filename/is-from-cjs-lexer.ts b/src/cjs/api/module-resolve-filename/is-from-cjs-lexer.ts new file mode 100644 index 000000000..8948eaa8d --- /dev/null +++ b/src/cjs/api/module-resolve-filename/is-from-cjs-lexer.ts @@ -0,0 +1,11 @@ +const cjsPreparseCall = 'at cjsPreparseModuleExports (node:internal'; + +export const isFromCjsLexer = ( + error: Error, +) => { + const stack = error.stack!.split('\n').slice(1); + return ( + stack[1].includes(cjsPreparseCall) + || stack[2].includes(cjsPreparseCall) + ); +}; diff --git a/src/cjs/api/module-resolve-filename/preserve-query.ts b/src/cjs/api/module-resolve-filename/preserve-query.ts new file mode 100644 index 000000000..2af3d8414 --- /dev/null +++ b/src/cjs/api/module-resolve-filename/preserve-query.ts @@ -0,0 +1,82 @@ +import path from 'node:path'; +import Module from 'node:module'; +import { urlSearchParamsStringify } from '../../../utils/url-search-params-stringify.js'; +import { isFromCjsLexer } from './is-from-cjs-lexer.js'; +import { getOriginalFilePath } from './interop-cjs-exports.js'; + +export const preserveQuery = ( + request: string, + parent?: Module.Parent, +) => { + // Strip query string + const requestAndQuery = request.split('?'); + const searchParams = new URLSearchParams(requestAndQuery[1]); + + if (parent?.filename) { + const filePath = getOriginalFilePath(parent.filename); + let query: string | undefined; + if (filePath) { + const pathAndQuery = filePath.split('?'); + const newFilename = pathAndQuery[0]; + query = pathAndQuery[1]; + + /** + * Can't delete the old cache entry because there's an assertion + * https://github.com/nodejs/node/blob/v20.15.0/lib/internal/modules/esm/translators.js#L347 + */ + // delete Module._cache[parent.filename]; + + parent.filename = newFilename; + parent.path = path.dirname(newFilename); + // https://github.com/nodejs/node/blob/v20.15.0/lib/internal/modules/esm/translators.js#L383 + parent.paths = Module._nodeModulePaths(parent.path); + + Module._cache[newFilename] = parent as NodeModule; + } + + if (!query) { + query = parent.filename.split('?')[1]; + } + + // Inherit parent namespace if it exists + const parentQuery = new URLSearchParams(query); + const parentNamespace = parentQuery.get('namespace'); + if (parentNamespace) { + searchParams.append('namespace', parentNamespace); + } + } + + return [ + requestAndQuery[0], + searchParams, + ( + resolved: string, + restOfArgsLength: number, + ) => { + // Only add query back if it's a file path (not a core Node module) + if ( + path.isAbsolute(resolved) + + // These two have native loaders which don't support queries + && !resolved.endsWith('.json') + && !resolved.endsWith('.node') + + /** + * Detect if this is called by the CJS lexer, the resolved path is directly passed into + * readFile to parse the exports + */ + && !( + // Only the CJS lexer doesn't pass in the rest of the arguments + // https://github.com/nodejs/node/blob/v20.15.0/lib/internal/modules/esm/translators.js#L415 + restOfArgsLength === 0 + // eslint-disable-next-line unicorn/error-message + && isFromCjsLexer(new Error()) + ) + ) { + resolved += urlSearchParamsStringify(searchParams); + } + + return resolved; + }, + ] as const; +}; diff --git a/src/cjs/api/module-resolve-filename/resolve-implicit-extensions.ts b/src/cjs/api/module-resolve-filename/resolve-implicit-extensions.ts new file mode 100644 index 000000000..7dac3b6ec --- /dev/null +++ b/src/cjs/api/module-resolve-filename/resolve-implicit-extensions.ts @@ -0,0 +1,58 @@ +import path from 'node:path'; +import type { NodeError } from '../../../types.js'; +import { isDirectoryPattern } from '../../../utils/path-utils.js'; +import type { SimpleResolve } from '../types.js'; + +/** + * Custom re-implementation of the CommonJS implicit resolver + * + * - Resolves .ts over .js extensions + * - When namespaced, the loaders are registered to the extensions in a hidden way + * so Node's built-in implicit resolver will not try those extensions + */ +export const createImplicitResolver = ( + nextResolve: SimpleResolve, +): SimpleResolve => ( + request, +) => { + if (request === '.') { + request = './'; + } + + /** + * Currently, there's an edge case where it doesn't resolve index.ts over index.js + * if the request doesn't end with a slash. e.g. `import './dir'` + * Doesn't handle '.' either + */ + if (isDirectoryPattern.test(request)) { + // If directory, can be index.js, index.ts, etc. + let joinedPath = path.join(request, 'index.js'); + + /** + * path.join will remove the './' prefix if it exists + * but it should only be added back if it was there before + * (e.g. not package directory imports) + */ + if (request.startsWith('./')) { + joinedPath = `./${joinedPath}`; + } + + try { + return nextResolve(joinedPath); + } catch {} + } + + try { + return nextResolve(request); + } catch (_error) { + const nodeError = _error as NodeError; + + if (nodeError.code === 'MODULE_NOT_FOUND') { + try { + return nextResolve(`${request}${path.sep}index.js`); + } catch {} + } + + throw nodeError; + } +}; diff --git a/src/cjs/api/module-resolve-filename/resolve-ts-extensions.ts b/src/cjs/api/module-resolve-filename/resolve-ts-extensions.ts new file mode 100644 index 000000000..258ad2e1d --- /dev/null +++ b/src/cjs/api/module-resolve-filename/resolve-ts-extensions.ts @@ -0,0 +1,98 @@ +import path from 'node:path'; +import { mapTsExtensions } from '../../../utils/map-ts-extensions.js'; +import type { NodeError } from '../../../types.js'; +import { + isFilePath, + isDirectoryPattern, +} from '../../../utils/path-utils.js'; +import { allowJs } from '../../../utils/tsconfig.js'; +import type { SimpleResolve } from '../types.js'; + +/** + * Typescript gives .ts, .cts, or .mts priority over actual .js, .cjs, or .mjs extensions + */ +const resolveTsFilename = ( + resolve: SimpleResolve, + request: string, + isTsParent: boolean, +) => { + if ( + isDirectoryPattern.test(request) + || (!isTsParent && !allowJs) + ) { + return; + } + + const tsPath = mapTsExtensions(request); + if (!tsPath) { + return; + } + + for (const tryTsPath of tsPath) { + try { + return resolve(tryTsPath); + } catch (error) { + const { code } = error as NodeError; + if ( + code !== 'MODULE_NOT_FOUND' + && code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED' + ) { + throw error; + } + } + } +}; + +export const createTsExtensionResolver = ( + nextResolve: SimpleResolve, + isTsParent: boolean, +): SimpleResolve => ( + request, +) => { + // It should only try to resolve TS extensions first if it's a local file (non dependency) + if (isFilePath(request)) { + const resolvedTsFilename = resolveTsFilename(nextResolve, request, isTsParent); + if (resolvedTsFilename) { + return resolvedTsFilename; + } + } + + try { + return nextResolve(request); + } catch (error) { + const nodeError = error as NodeError; + + if (nodeError.code === 'MODULE_NOT_FOUND') { + // Exports map resolution + if ( + typeof nodeError.path === 'string' + && nodeError.path.endsWith(`${path.sep}package.json`) + ) { + const isExportsPath = nodeError.message.match(/^Cannot find module '([^']+)'$/); + if (isExportsPath) { + const exportsPath = isExportsPath[1]; + const tsFilename = resolveTsFilename(nextResolve, exportsPath, isTsParent); + if (tsFilename) { + return tsFilename; + } + } + + 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(nextResolve, mainPath, isTsParent); + if (tsFilename) { + return tsFilename; + } + } + } + + const resolvedTsFilename = resolveTsFilename(nextResolve, request, isTsParent); + if (resolvedTsFilename) { + return resolvedTsFilename; + } + } + + throw nodeError; + } +}; diff --git a/src/cjs/api/register.ts b/src/cjs/api/register.ts index e4d30e7d7..577cd2450 100644 --- a/src/cjs/api/register.ts +++ b/src/cjs/api/register.ts @@ -7,7 +7,7 @@ import { urlSearchParamsStringify } from '../../utils/url-search-params-stringif import { fileUrlPrefix } from '../../utils/path-utils.js'; import type { LoaderState } from './types.js'; import { createExtensions } from './module-extensions.js'; -import { createResolveFilename } from './module-resolve-filename.js'; +import { createResolveFilename } from './module-resolve-filename/index.js'; const resolveContext = ( id: string, diff --git a/src/cjs/api/resolve-implicit-extensions.ts b/src/cjs/api/resolve-implicit-extensions.ts deleted file mode 100644 index b3df6c8cc..000000000 --- a/src/cjs/api/resolve-implicit-extensions.ts +++ /dev/null @@ -1,76 +0,0 @@ -import path from 'node:path'; -import type { NodeError } from '../../types.js'; -import { isDirectoryPattern } from '../../utils/path-utils.js'; -import { mapTsExtensions } from '../../utils/map-ts-extensions.js'; -import type { ResolveFilename } from './types.js'; - -const tryExtensions = ( - resolve: ResolveFilename, - ...args: Parameters -) => { - const tryPaths = mapTsExtensions(args[0]); - for (const tryPath of tryPaths) { - const newArgs = args.slice() as Parameters; - newArgs[0] = tryPath; - - try { - return resolve(...newArgs); - } catch {} - } -}; - -export const createImplicitResolver = ( - resolve: ResolveFilename, -): ResolveFilename => ( - request, - ...args -) => { - if (request === '.') { - request = './'; - } - - /** - * Currently, there's an edge case where it doesn't resolve index.ts over index.js - * if the request doesn't end with a slash. e.g. `import './dir'` - * Doesn't handle '.' either - */ - if (isDirectoryPattern.test(request)) { - // If directory, can be index.js, index.ts, etc. - let joinedPath = path.join(request, 'index'); - - /** - * path.join will remove the './' prefix if it exists - * but it should only be added back if it was there before - * (e.g. not package directory imports) - */ - if (request.startsWith('./')) { - joinedPath = `./${joinedPath}`; - } - - const resolved = tryExtensions(resolve, joinedPath, ...args); - if (resolved) { - return resolved; - } - } - - try { - return resolve(request, ...args); - } catch (_error) { - const nodeError = _error as NodeError; - if ( - nodeError.code === 'MODULE_NOT_FOUND' - ) { - const resolved = ( - tryExtensions(resolve, request, ...args) - - // Default resolve handles resovling paths relative to the parent - || tryExtensions(resolve, `${request}${path.sep}index`, ...args) - ); - if (resolved) { - return resolved; - } - } - - throw nodeError; - } -}; diff --git a/src/esm/api/register.ts b/src/esm/api/register.ts index a6294ea8e..c1b504c2d 100644 --- a/src/esm/api/register.ts +++ b/src/esm/api/register.ts @@ -2,7 +2,7 @@ import module from 'node:module'; import { MessageChannel, type MessagePort } from 'node:worker_threads'; import type { Message } from '../types.js'; import type { RequiredProperty } from '../../types.js'; -import { interopCjsExports } from '../../cjs/api/module-resolve-filename.js'; +import { interopCjsExports } from '../../cjs/api/module-resolve-filename/interop-cjs-exports.js'; import { createScopedImport, type ScopedImport } from './scoped-import.js'; export type TsconfigOptions = false | string; diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 29e2ee2a3..aa9ce82f9 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -138,7 +138,7 @@ export const files = { export const cjsContext = ${cjsContextCheck}; // Implicit directory import works outside of immedaite CWD child - import '../ts/' + import '../json/' `, 'json/index.json': JSON.stringify({ loaded: 'json' }),