diff --git a/src/@types/module.d.ts b/src/@types/module.d.ts index b508c5f55..60fe8132b 100644 --- a/src/@types/module.d.ts +++ b/src/@types/module.d.ts @@ -8,7 +8,7 @@ declare global { } } -declare module 'node:module' { +declare module 'module' { // https://nodejs.org/api/module.html#loadurl-context-nextload interface LoadHookContext { importAttributes: ImportAssertions; @@ -17,6 +17,8 @@ declare module 'node:module' { // CommonJS export const _extensions: NodeJS.RequireExtensions; + export const _cache: NodeJS.Require['cache']; + export type Parent = { /** @@ -32,4 +34,9 @@ declare module 'node:module' { isMain: boolean, options?: Record, ): string; + + interface LoadFnOutput { + // Added in https://github.com/nodejs/node/pull/43164 + responseURL?: string; + } } diff --git a/src/cjs/api/module-resolve-filename.ts b/src/cjs/api/module-resolve-filename.ts index 437c21e7c..cbc06d43f 100644 --- a/src/cjs/api/module-resolve-filename.ts +++ b/src/cjs/api/module-resolve-filename.ts @@ -6,9 +6,34 @@ import type { NodeError } from '../../types.js'; import { isRelativePath, fileUrlPrefix, tsExtensionsPattern } from '../../utils/path-utils.js'; import { tsconfigPathsMatcher, allowJs } from '../../utils/tsconfig.js'; +type ResolveFilename = typeof Module._resolveFilename; + const nodeModulesPath = `${path.sep}node_modules${path.sep}`; -type ResolveFilename = typeof Module._resolveFilename; +export const interopCjsExports = ( + request: string, +) => { + if (!request.startsWith('data:text/javascript,')) { + return request; + } + + const queryIndex = request.indexOf('?'); + if (queryIndex === -1) { + return request; + } + + const searchParams = new URLSearchParams(request.slice(queryIndex + 1)); + const realPath = searchParams.get('filePath'); + if (realPath) { + // 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[realPath] = Module._cache[request]; + delete Module._cache[request]; + request = realPath; + } + + return request; +}; /** * Typescript gives .ts, .cts, or .mts priority over actual .js, .cjs, or .mjs extensions @@ -60,6 +85,8 @@ export const createResolveFilename = ( isMain, options, ) => { + request = interopCjsExports(request); + // Strip query string const queryIndex = request.indexOf('?'); const query = queryIndex === -1 ? '' : request.slice(queryIndex); diff --git a/src/esm/api/register.ts b/src/esm/api/register.ts index 6f3499c78..fe7233f4d 100644 --- a/src/esm/api/register.ts +++ b/src/esm/api/register.ts @@ -1,6 +1,7 @@ import module from 'node:module'; import { MessageChannel, type MessagePort } from 'node:worker_threads'; import type { Message } from '../types.js'; +import { interopCjsExports } from '../../cjs/api/module-resolve-filename.js'; import { createScopedImport, type ScopedImport } from './scoped-import.js'; export type TsconfigOptions = false | string; @@ -31,6 +32,8 @@ export type Register = { (options?: RegisterOptions): Unregister; }; +let cjsInteropApplied = false; + export const register: Register = ( options, ) => { @@ -38,6 +41,14 @@ export const register: Register = ( throw new Error(`This version of Node.js (${process.version}) does not support module.register(). Please upgrade to Node v18.9 or v20.6 and above.`); } + if (!cjsInteropApplied) { + const { _resolveFilename } = module; + module._resolveFilename = ( + request, _parent, _isMain, _options, + ) => _resolveFilename(interopCjsExports(request), _parent, _isMain, _options); + cjsInteropApplied = true; + } + const { sourceMapsEnabled } = process; process.setSourceMapsEnabled(true); diff --git a/src/esm/hook/load.ts b/src/esm/hook/load.ts index de8427c46..82f4d16de 100644 --- a/src/esm/hook/load.ts +++ b/src/esm/hook/load.ts @@ -1,14 +1,16 @@ import { fileURLToPath } from 'node:url'; import type { LoadHook } from 'node:module'; +import { readFile } from 'node:fs/promises'; import type { TransformOptions } from 'esbuild'; import { transform } from '../../utils/transform/index.js'; import { transformDynamicImport } from '../../utils/transform/transform-dynamic-import.js'; import { inlineSourceMap } from '../../source-map.js'; -import { isFeatureSupported, importAttributes } from '../../utils/node-features.js'; +import { isFeatureSupported, importAttributes, esmLoadReadFile } from '../../utils/node-features.js'; import { parent } from '../../utils/ipc/client.js'; import type { Message } from '../types.js'; import { fileMatcher } from '../../utils/tsconfig.js'; import { isJsonPattern, tsExtensionsPattern } from '../../utils/path-utils.js'; +import { parseEsm } from '../../utils/es-module-lexer.js'; import { getNamespace } from './utils.js'; import { data } from './initialize.js'; @@ -60,13 +62,31 @@ export const load: LoadHook = async ( } const loaded = await nextLoad(url, context); + const filePath = url.startsWith('file://') ? fileURLToPath(url) : url; + + if ( + loaded.format === 'commonjs' + && isFeatureSupported(esmLoadReadFile) + && loaded.responseURL?.startsWith('file:') // Could be data: + ) { + const code = await readFile(new URL(url), 'utf8'); + const [, exports] = parseEsm(code); + if (exports.length > 0) { + const cjsExports = `module.exports={${ + exports.map(exported => exported.n).filter(name => name !== 'default').join(',') + }}`; + const parameters = new URLSearchParams({ filePath }); + loaded.responseURL = `data:text/javascript,${encodeURIComponent(cjsExports)}?${parameters.toString()}`; + } + + return loaded; + } // CommonJS and Internal modules (e.g. node:*) if (!loaded.source) { return loaded; } - const filePath = url.startsWith('file://') ? fileURLToPath(url) : url; const code = loaded.source.toString(); if ( diff --git a/src/utils/node-features.ts b/src/utils/node-features.ts index ebc907c40..1c7d188c0 100644 --- a/src/utils/node-features.ts +++ b/src/utils/node-features.ts @@ -53,3 +53,9 @@ export const importAttributes: Version[] = [ export const testRunnerGlob: Version[] = [ [21, 0, 0], ]; + +// https://github.com/nodejs/node/pull/50825 +export const esmLoadReadFile: Version[] = [ + [20, 11, 0], + [21, 3, 0], +]; diff --git a/src/utils/transform/transform-dynamic-import.ts b/src/utils/transform/transform-dynamic-import.ts index 6a17b5424..6212a0b22 100644 --- a/src/utils/transform/transform-dynamic-import.ts +++ b/src/utils/transform/transform-dynamic-import.ts @@ -6,11 +6,8 @@ export const version = '2'; const toEsmFunctionString = ((imported: Record) => { const d = 'default'; - const exports = Object.keys(imported); if ( - exports.length === 1 - && exports[0] === d - && imported[d] + imported[d] && typeof imported[d] === 'object' && '__esModule' in imported[d] ) { diff --git a/tests/specs/smoke.ts b/tests/specs/smoke.ts index 59858eda2..5c7a3c900 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 }: NodeApis) => { +export default testSuite(async ({ describe }, { tsx, supports }: NodeApis) => { describe('Smoke', ({ describe }) => { for (const packageType of packageTypes) { const isCommonJs = packageType === 'commonjs'; @@ -151,7 +151,11 @@ export default testSuite(async ({ describe }, { tsx }: NodeApis) => { expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js","__filename":".+?index\.js"\}/); expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js\?query=123","__filename":".+?index\.js"\}/); } else { - expect(p.stdout).toMatch('"pkgCommonjs":{"default":{"default":1,"named":2}}'); + expect(p.stdout).toMatch( + supports.cjsInterop + ? '"pkgCommonjs":{"default":{"default":1,"named":2},"named":2}' + : '"pkgCommonjs":{"default":{"default":1,"named":2}}', + ); expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js"\}/); expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js\?query=123"\}/); @@ -365,7 +369,11 @@ export default testSuite(async ({ describe }, { tsx }: NodeApis) => { expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js","__filename":".+?index\.js"\}/); expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js\?query=123","__filename":".+?index\.js"\}/); } else { - expect(p.stdout).toMatch('"pkgCommonjs":{"default":{"default":1,"named":2}}'); + expect(p.stdout).toMatch( + supports.cjsInterop + ? '"pkgCommonjs":{"default":{"default":1,"named":2},"named":2}' + : '"pkgCommonjs":{"default":{"default":1,"named":2}}', + ); expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js"\}/); expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js\?query=123"\}/); diff --git a/tests/utils/tsx.ts b/tests/utils/tsx.ts index f10f3ee40..4a7f9ad35 100644 --- a/tests/utils/tsx.ts +++ b/tests/utils/tsx.ts @@ -4,6 +4,7 @@ import { isFeatureSupported, moduleRegister, testRunnerGlob, + esmLoadReadFile, type Version, } from '../../src/utils/node-features.js'; import { getNode } from './get-node.js'; @@ -54,6 +55,8 @@ export const createNode = async ( // https://nodejs.org/docs/latest-v18.x/api/cli.html#--test cliTestFlag: isFeatureSupported([[18, 1, 0]], versionParsed), + + cjsInterop: isFeatureSupported(esmLoadReadFile, versionParsed), }; const hookFlag = supports.moduleRegister ? '--import' : '--loader';