Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions lib/esm-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,76 @@ export function rewriteMjsRequirePaths(code: string): string {
);
}

// CommonJS-resolvable conditions, in the order Node's require() resolver
// honours them. If a conditions object exposes none of these, require() cannot
// resolve it (Node throws ERR_PACKAGE_PATH_NOT_EXPORTED).
const CJS_EXPORT_CONDITIONS = ['require', 'node', 'node-addons', 'default'];

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ExportsValue = any;

/**
* Make a package.json "exports" value resolvable through CommonJS `require()`
* after pkg has transformed the package's ESM sources to CJS.
*
* pkg compiles every ESM module to CommonJS and renames the package's `.mjs`
* files to `.js` in the snapshot. The "exports" field, however, is left as the
* package author wrote it, so at runtime Node's CJS resolver (which always
* prefers "exports" over "main") still sees `import`-only conditions and/or
* targets pointing at the now-renamed `.mjs` files. That produces
* ERR_PACKAGE_PATH_NOT_EXPORTED (import-only packages) or MODULE_NOT_FOUND
* (targets pointing at a `.mjs` file that no longer exists).
*
* This rewrites the field to mirror the transformation pkg already applied:
* - every string target has its `.mjs` extension rewritten to `.js`, and
* - any conditions object that lacks a CJS-resolvable condition gets a
* synthetic `require` condition mirroring its `import` (or `default`) target,
* so `require()` resolves to the transformed file.
*
* @param exportsField - The raw "exports" value from package.json
* @returns A new "exports" value resolvable via `require()`
*/
export function normalizeExportsForCJS(
exportsField: ExportsValue,
): ExportsValue {
if (typeof exportsField === 'string') {
return exportsField.replace(/\.mjs$/, '.js');
}

if (Array.isArray(exportsField)) {
return exportsField.map((entry) => normalizeExportsForCJS(entry));
}

if (exportsField && typeof exportsField === 'object') {
const keys = Object.keys(exportsField);
// A conditions object uses condition names as keys (e.g. "import",
// "require"); a subpath map uses "." / "./sub" keys. They never mix, so the
// presence of any condition key means this is a conditions object.
const isConditions = keys.some((key) => !key.startsWith('.'));

const result: ExportsValue = {};
for (const key of keys) {
result[key] = normalizeExportsForCJS(exportsField[key]);
}

if (
isConditions &&
!keys.some((key) => CJS_EXPORT_CONDITIONS.includes(key))
) {
// Import-only conditions object: add a `require` target mirroring the ESM
// one so the transformed file is reachable through require().
const fallback = result.import ?? result.default;
if (fallback !== undefined) {
return { require: fallback, ...result };
}
}

return result;
}

return exportsField;
}

/**
* Transform ESM code to CommonJS using esbuild
* This allows ESM modules to be compiled to bytecode via vm.Script
Expand Down
24 changes: 23 additions & 1 deletion lib/walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import { pc } from './colors';
import { follow } from './follow';
import { log, wasReported } from './log';
import * as detector from './detector';
import { transformESMtoCJS, rewriteMjsRequirePaths } from './esm-transformer';
import {
transformESMtoCJS,
rewriteMjsRequirePaths,
normalizeExportsForCJS,
} from './esm-transformer';
import {
ConfigDictionary,
FileRecord,
Expand Down Expand Up @@ -1081,6 +1085,24 @@ class Walker {
}
}

// If package has an "exports" field, rewrite it so the snapshot is
// resolvable through CommonJS require(). pkg transforms ESM to CJS and
// renames .mjs files to .js, but Node's CJS resolver always prefers
// "exports" over "main"; without this, import-only or .mjs-targeted
// exports fail at runtime (ERR_PACKAGE_PATH_NOT_EXPORTED / MODULE_NOT_FOUND).
if (pkgContent.exports && !this.params.seaMode) {
const normalizedExports = normalizeExportsForCJS(
pkgContent.exports,
);
if (
JSON.stringify(normalizedExports) !==
JSON.stringify(pkgContent.exports)
) {
pkgContent.exports = normalizedExports;
modified = true;
}
}

// If package has "type": "module", we need to change it to "commonjs"
// because we transform all ESM files to CJS before bytecode compilation
if (pkgContent.type === 'module' && !this.params.seaMode) {
Expand Down
3 changes: 3 additions & 0 deletions test/test-54-esm-exports-conditions/app/app-a.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { a } from 'esm-only';

console.log(a);
3 changes: 3 additions & 0 deletions test/test-54-esm-exports-conditions/app/app-b.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { b } from 'req-mjs';

console.log(b);

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions test/test-54-esm-exports-conditions/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env node

'use strict';

// Regression test for https://github.com/yao-pkg/pkg/issues/281
//
// A packaged ESM app crashes at startup when it imports a dependency whose
// package.json "exports" map is valid for `import` but not resolvable through
// CommonJS `require()`. pkg transforms ESM to CJS and renames .mjs -> .js, but
// Node's CJS resolver always prefers "exports" over "main", so without
// rewriting the "exports" field the binary fails at runtime with either:
// - case A (import-only): ERR_PACKAGE_PATH_NOT_EXPORTED
// - case B (.mjs targets): MODULE_NOT_FOUND (.../index.mjs)

const path = require('path');
const assert = require('assert');
const utils = require('../utils.js');

assert(!module.parent);
assert(__dirname === process.cwd());

const target = process.argv[2] || 'host';

const cases = [
{ id: 'a', entry: './app/app-a.mjs', expected: 'esm-only ok' },
{ id: 'b', entry: './app/app-b.mjs', expected: 'req-mjs ok' },
];

for (const testCase of cases) {
console.log(`Testing ESM exports conditions (case ${testCase.id})...`);

const output = `./run-time/test-output-${testCase.id}.exe`;
utils.mkdirp.sync(path.dirname(output));

// Expected output from plain node.
const left = utils.spawn.sync('node', [path.basename(testCase.entry)], {
cwd: path.dirname(testCase.entry),
});
assert.strictEqual(left.trim(), testCase.expected);

// Package with pkg.
utils.pkg.sync(['--target', target, '--output', output, testCase.entry], {
stdio: 'inherit',
});

// The packaged binary must run and produce the same output (no startup crash).
const right = utils.spawn.sync('./' + path.basename(output), [], {
cwd: path.dirname(output),
});
assert.strictEqual(
left.trim(),
right.trim(),
`Outputs should match between node and pkg for case ${testCase.id}`,
);

console.log(`Test passed: case ${testCase.id}`);
}

utils.vacuum.sync('./run-time');
67 changes: 67 additions & 0 deletions test/unit/esm-transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import {
normalizeExportsForCJS,
rewriteMjsRequirePaths,
transformESMtoCJS,
} from '../../lib/esm-transformer';
Expand Down Expand Up @@ -147,3 +148,69 @@ describe('transformESMtoCJS', () => {
assert.equal(res.code, 'let x = ;');
});
});

describe('normalizeExportsForCJS', () => {
it('rewrites a string target .mjs to .js', () => {
assert.equal(normalizeExportsForCJS('./index.mjs'), './index.js');
});

it('adds a require condition for an import-only exports map (#281 case A)', () => {
// esm-only: { exports: { ".": { import: "./index.mjs" } } }
// Without a CJS-resolvable condition, require() throws
// ERR_PACKAGE_PATH_NOT_EXPORTED in the packaged binary.
assert.deepEqual(
normalizeExportsForCJS({ '.': { import: './index.mjs' } }),
{
'.': { require: './index.js', import: './index.js' },
},
);
});

it('rewrites .mjs require targets that no longer exist (#281 case B)', () => {
// req-mjs: { exports: { ".": { require: "./index.mjs", import: "./index.mjs" } } }
// The .mjs file is renamed to .js in the snapshot, so the target must follow.
assert.deepEqual(
normalizeExportsForCJS({
'.': { require: './index.mjs', import: './index.mjs' },
}),
{ '.': { require: './index.js', import: './index.js' } },
);
});

it('falls back to the default condition when there is no import', () => {
assert.deepEqual(
normalizeExportsForCJS({ '.': { default: './index.mjs' } }),
{
'.': { default: './index.js' },
},
);
});

it('does not add require when a CJS-resolvable condition already exists', () => {
// `default` is resolvable by require(), so the map is left as-is (extensions aside).
assert.deepEqual(
normalizeExportsForCJS({ import: './e.mjs', default: './d.mjs' }),
{ import: './e.js', default: './d.js' },
);
});

it('recurses through subpath maps and nested conditions', () => {
assert.deepEqual(
normalizeExportsForCJS({
'.': { import: './i.mjs' },
'./sub': { node: { import: './sub.mjs' } },
}),
{
'.': { require: './i.js', import: './i.js' },
'./sub': { node: { require: './sub.js', import: './sub.js' } },
},
);
});

it('handles array targets', () => {
assert.deepEqual(normalizeExportsForCJS(['./a.mjs', './b.mjs']), [
'./a.js',
'./b.js',
]);
});
});
Loading