Skip to content

Commit 7c85303

Browse files
fix(esm): named import from CommonJS file (#33)
fixes #38
1 parent e1464cf commit 7c85303

File tree

8 files changed

+90
-11
lines changed

8 files changed

+90
-11
lines changed

src/@types/module.d.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ declare global {
88
}
99
}
1010

11-
declare module 'node:module' {
11+
declare module 'module' {
1212
// https://nodejs.org/api/module.html#loadurl-context-nextload
1313
interface LoadHookContext {
1414
importAttributes: ImportAssertions;
@@ -17,6 +17,8 @@ declare module 'node:module' {
1717
// CommonJS
1818
export const _extensions: NodeJS.RequireExtensions;
1919

20+
export const _cache: NodeJS.Require['cache'];
21+
2022
export type Parent = {
2123

2224
/**
@@ -32,4 +34,9 @@ declare module 'node:module' {
3234
isMain: boolean,
3335
options?: Record<PropertyKey, unknown>,
3436
): string;
37+
38+
interface LoadFnOutput {
39+
// Added in https://github.com/nodejs/node/pull/43164
40+
responseURL?: string;
41+
}
3542
}

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

+28-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,34 @@ import type { NodeError } from '../../types.js';
66
import { isRelativePath, fileUrlPrefix, tsExtensionsPattern } from '../../utils/path-utils.js';
77
import { tsconfigPathsMatcher, allowJs } from '../../utils/tsconfig.js';
88

9+
type ResolveFilename = typeof Module._resolveFilename;
10+
911
const nodeModulesPath = `${path.sep}node_modules${path.sep}`;
1012

11-
type ResolveFilename = typeof Module._resolveFilename;
13+
export const interopCjsExports = (
14+
request: string,
15+
) => {
16+
if (!request.startsWith('data:text/javascript,')) {
17+
return request;
18+
}
19+
20+
const queryIndex = request.indexOf('?');
21+
if (queryIndex === -1) {
22+
return request;
23+
}
24+
25+
const searchParams = new URLSearchParams(request.slice(queryIndex + 1));
26+
const realPath = searchParams.get('filePath');
27+
if (realPath) {
28+
// The CJS module cache needs to be updated with the actual path for export parsing to work
29+
// https://github.com/nodejs/node/blob/v22.2.0/lib/internal/modules/esm/translators.js#L338
30+
Module._cache[realPath] = Module._cache[request];
31+
delete Module._cache[request];
32+
request = realPath;
33+
}
34+
35+
return request;
36+
};
1237

1338
/**
1439
* Typescript gives .ts, .cts, or .mts priority over actual .js, .cjs, or .mjs extensions
@@ -60,6 +85,8 @@ export const createResolveFilename = (
6085
isMain,
6186
options,
6287
) => {
88+
request = interopCjsExports(request);
89+
6390
// Strip query string
6491
const queryIndex = request.indexOf('?');
6592
const query = queryIndex === -1 ? '' : request.slice(queryIndex);

src/esm/api/register.ts

+11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import module from 'node:module';
22
import { MessageChannel, type MessagePort } from 'node:worker_threads';
33
import type { Message } from '../types.js';
4+
import { interopCjsExports } from '../../cjs/api/module-resolve-filename.js';
45
import { createScopedImport, type ScopedImport } from './scoped-import.js';
56

67
export type TsconfigOptions = false | string;
@@ -31,13 +32,23 @@ export type Register = {
3132
(options?: RegisterOptions): Unregister;
3233
};
3334

35+
let cjsInteropApplied = false;
36+
3437
export const register: Register = (
3538
options,
3639
) => {
3740
if (!module.register) {
3841
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.`);
3942
}
4043

44+
if (!cjsInteropApplied) {
45+
const { _resolveFilename } = module;
46+
module._resolveFilename = (
47+
request, _parent, _isMain, _options,
48+
) => _resolveFilename(interopCjsExports(request), _parent, _isMain, _options);
49+
cjsInteropApplied = true;
50+
}
51+
4152
const { sourceMapsEnabled } = process;
4253
process.setSourceMapsEnabled(true);
4354

src/esm/hook/load.ts

+22-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { fileURLToPath } from 'node:url';
22
import type { LoadHook } from 'node:module';
3+
import { readFile } from 'node:fs/promises';
34
import type { TransformOptions } from 'esbuild';
45
import { transform } from '../../utils/transform/index.js';
56
import { transformDynamicImport } from '../../utils/transform/transform-dynamic-import.js';
67
import { inlineSourceMap } from '../../source-map.js';
7-
import { isFeatureSupported, importAttributes } from '../../utils/node-features.js';
8+
import { isFeatureSupported, importAttributes, esmLoadReadFile } from '../../utils/node-features.js';
89
import { parent } from '../../utils/ipc/client.js';
910
import type { Message } from '../types.js';
1011
import { fileMatcher } from '../../utils/tsconfig.js';
1112
import { isJsonPattern, tsExtensionsPattern } from '../../utils/path-utils.js';
13+
import { parseEsm } from '../../utils/es-module-lexer.js';
1214
import { getNamespace } from './utils.js';
1315
import { data } from './initialize.js';
1416

@@ -60,13 +62,31 @@ export const load: LoadHook = async (
6062
}
6163

6264
const loaded = await nextLoad(url, context);
65+
const filePath = url.startsWith('file://') ? fileURLToPath(url) : url;
66+
67+
if (
68+
loaded.format === 'commonjs'
69+
&& isFeatureSupported(esmLoadReadFile)
70+
&& loaded.responseURL?.startsWith('file:') // Could be data:
71+
) {
72+
const code = await readFile(new URL(url), 'utf8');
73+
const [, exports] = parseEsm(code);
74+
if (exports.length > 0) {
75+
const cjsExports = `module.exports={${
76+
exports.map(exported => exported.n).filter(name => name !== 'default').join(',')
77+
}}`;
78+
const parameters = new URLSearchParams({ filePath });
79+
loaded.responseURL = `data:text/javascript,${encodeURIComponent(cjsExports)}?${parameters.toString()}`;
80+
}
81+
82+
return loaded;
83+
}
6384

6485
// CommonJS and Internal modules (e.g. node:*)
6586
if (!loaded.source) {
6687
return loaded;
6788
}
6889

69-
const filePath = url.startsWith('file://') ? fileURLToPath(url) : url;
7090
const code = loaded.source.toString();
7191

7292
if (

src/utils/node-features.ts

+6
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,9 @@ export const importAttributes: Version[] = [
5353
export const testRunnerGlob: Version[] = [
5454
[21, 0, 0],
5555
];
56+
57+
// https://github.com/nodejs/node/pull/50825
58+
export const esmLoadReadFile: Version[] = [
59+
[20, 11, 0],
60+
[21, 3, 0],
61+
];

src/utils/transform/transform-dynamic-import.ts

+1-4
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,8 @@ export const version = '2';
66

77
const toEsmFunctionString = ((imported: Record<string, unknown>) => {
88
const d = 'default';
9-
const exports = Object.keys(imported);
109
if (
11-
exports.length === 1
12-
&& exports[0] === d
13-
&& imported[d]
10+
imported[d]
1411
&& typeof imported[d] === 'object'
1512
&& '__esModule' in imported[d]
1613
) {

tests/specs/smoke.ts

+11-3
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 }: NodeApis) => {
15+
export default testSuite(async ({ describe }, { tsx, supports }: NodeApis) => {
1616
describe('Smoke', ({ describe }) => {
1717
for (const packageType of packageTypes) {
1818
const isCommonJs = packageType === 'commonjs';
@@ -151,7 +151,11 @@ export default testSuite(async ({ describe }, { tsx }: NodeApis) => {
151151
expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js","__filename":".+?index\.js"\}/);
152152
expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js\?query=123","__filename":".+?index\.js"\}/);
153153
} else {
154-
expect(p.stdout).toMatch('"pkgCommonjs":{"default":{"default":1,"named":2}}');
154+
expect(p.stdout).toMatch(
155+
supports.cjsInterop
156+
? '"pkgCommonjs":{"default":{"default":1,"named":2},"named":2}'
157+
: '"pkgCommonjs":{"default":{"default":1,"named":2}}',
158+
);
155159

156160
expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js"\}/);
157161
expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js\?query=123"\}/);
@@ -365,7 +369,11 @@ export default testSuite(async ({ describe }, { tsx }: NodeApis) => {
365369
expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js","__filename":".+?index\.js"\}/);
366370
expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js\?query=123","__filename":".+?index\.js"\}/);
367371
} else {
368-
expect(p.stdout).toMatch('"pkgCommonjs":{"default":{"default":1,"named":2}}');
372+
expect(p.stdout).toMatch(
373+
supports.cjsInterop
374+
? '"pkgCommonjs":{"default":{"default":1,"named":2},"named":2}'
375+
: '"pkgCommonjs":{"default":{"default":1,"named":2}}',
376+
);
369377

370378
expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js"\}/);
371379
expect(p.stdout).toMatch(/\{"importMetaUrl":"file:\/\/\/.+?\/js\/index\.js\?query=123"\}/);

tests/utils/tsx.ts

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isFeatureSupported,
55
moduleRegister,
66
testRunnerGlob,
7+
esmLoadReadFile,
78
type Version,
89
} from '../../src/utils/node-features.js';
910
import { getNode } from './get-node.js';
@@ -54,6 +55,8 @@ export const createNode = async (
5455

5556
// https://nodejs.org/docs/latest-v18.x/api/cli.html#--test
5657
cliTestFlag: isFeatureSupported([[18, 1, 0]], versionParsed),
58+
59+
cjsInterop: isFeatureSupported(esmLoadReadFile, versionParsed),
5760
};
5861
const hookFlag = supports.moduleRegister ? '--import' : '--loader';
5962

0 commit comments

Comments
 (0)