Skip to content

Commit

Permalink
fix(esm): named import from CommonJS file (#33)
Browse files Browse the repository at this point in the history
fixes #38
  • Loading branch information
privatenumber committed Jun 6, 2024
1 parent e1464cf commit 7c85303
Show file tree
Hide file tree
Showing 8 changed files with 90 additions and 11 deletions.
9 changes: 8 additions & 1 deletion src/@types/module.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +17,8 @@ declare module 'node:module' {
// CommonJS
export const _extensions: NodeJS.RequireExtensions;

export const _cache: NodeJS.Require['cache'];

export type Parent = {

/**
Expand All @@ -32,4 +34,9 @@ declare module 'node:module' {
isMain: boolean,
options?: Record<PropertyKey, unknown>,
): string;

interface LoadFnOutput {
// Added in https://github.com/nodejs/node/pull/43164
responseURL?: string;
}
}
29 changes: 28 additions & 1 deletion src/cjs/api/module-resolve-filename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions src/esm/api/register.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,13 +32,23 @@ export type Register = {
(options?: RegisterOptions): Unregister;
};

let cjsInteropApplied = false;

export const register: Register = (
options,
) => {
if (!module.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);

Expand Down
24 changes: 22 additions & 2 deletions src/esm/hook/load.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 (
Expand Down
6 changes: 6 additions & 0 deletions src/utils/node-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
];
5 changes: 1 addition & 4 deletions src/utils/transform/transform-dynamic-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@ export const version = '2';

const toEsmFunctionString = ((imported: Record<string, unknown>) => {
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]
) {
Expand Down
14 changes: 11 additions & 3 deletions tests/specs/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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"\}/);
Expand Down Expand Up @@ -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"\}/);
Expand Down
3 changes: 3 additions & 0 deletions tests/utils/tsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isFeatureSupported,
moduleRegister,
testRunnerGlob,
esmLoadReadFile,
type Version,
} from '../../src/utils/node-features.js';
import { getNode } from './get-node.js';
Expand Down Expand Up @@ -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';

Expand Down

0 comments on commit 7c85303

Please sign in to comment.