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
111 changes: 110 additions & 1 deletion lib/walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,97 @@ class Walker {
store: STORE_BLOB,
reason: record.file,
});

// Also include files from other export conditions (e.g., module-sync, import)
// that Node.js may resolve to at runtime instead of the default/require entry.
// Without this, .mjs files referenced by module-sync would be missing from the snapshot.
const effectiveMarker = newPackageForNewRecords
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] · Correctness

effectiveMarker = newPackageForNewRecords ? newPackageForNewRecords.marker : marker. In stepDerivatives_ALIAS_AS_RESOLVABLE, newPackageForNewRecords is often undefined (only set when double-resolution flips the resolved file). The fallback then resolves ./require.mjs against the caller's package root, not the dep's.

Why: Wrong absolute paths either get silently ENOENT-swallowed by the bare catch (line 988), or — worse — pick up a same-named file from the caller's tree.

Fix: Derive the package from newPackages (prefer the one whose dir is an ancestor of newFile), not from newPackageForNewRecords.

? newPackageForNewRecords.marker
: marker;
if (effectiveMarker?.configPath) {
await this.includeAlternateExportEntries(
effectiveMarker,
newFile,
record.file,
);
}
}

/**
* Include alternate export entry points (module-sync, import) from a package's
* exports field. These files may be loaded by Node.js at runtime instead of the
* default/require entry, so they must be in the snapshot.
*/
private async includeAlternateExportEntries(
marker: Marker | undefined,
resolvedFile: string,
reason: string,
) {
if (!marker?.configPath || !marker.config) return;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] · Readability

Redundant with the caller's if (effectiveMarker?.configPath) check at line 946. The marker.config half is the only meaningful addition — and if config can actually be undefined here, the caller should check it too.

Also: naming. effectiveMarker reads as "the effective one" without saying effective vs what — resolvedMarker or targetMarker communicates the intent (marker of the newly-resolved dependency) better.


const pkgExports = (marker.config as Record<string, unknown>).exports;
if (!pkgExports) return;

const pkgDir = path.dirname(marker.configPath);
const alternateFiles = this.collectAlternateExportFiles(pkgExports);

for (const relFile of alternateFiles) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] · Performance

for..of with await fs.stat is serial. A package with ~10 subpath exports × 2 alternate conditions = 20 serial RTTs per invocation. Combined with the per-call-site redundancy (no cache keyed on marker.configPath), this scales poorly on monorepos.

Fix: Promise.all(...) the stat-and-append — the Set already deduplicates — and add a per-configPath visited guard on the Walker instance so the scan runs once per package, not once per call-site.

const absFile = normalizePath(path.resolve(pkgDir, relFile));
// Skip the file we already resolved
if (absFile === resolvedFile) continue;

try {
const stat = await fs.stat(absFile);
if (stat.isFile()) {
await this.appendBlobOrContent({
file: absFile,
marker,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] · Design

This is the load-bearing architectural choice. STORE_CONTENT means raw bytes + no derivative walking. It pairs with the Blocker at line 1100 — the async-function fixture only works because require.mjs re-imports a file already captured via default. A real module-sync target with its own transitive graph will silently drop files and fail at runtime.

Fix: Use STORE_BLOB for alternate entries so stepDetect / stepDerivatives recurse. Drops the needsMjsTransform fork naturally.

store: STORE_CONTENT,
reason,
});
}
} catch {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] · Operability / Correctness

Bare catch {} swallows every fs.stat error (EPERM, EIO, ENOTDIR, ELOOP, …) as if the file simply did not exist. A user with a broken symlink or wrong permissions on an alternate export gets a silent skip at build time and an opaque ERR_MODULE_NOT_FOUND inside the snapshot at runtime — hiding the other Tests finding too.

Fix: Narrow to ENOENT only — if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; — matching the pattern at step_STORE_STAT (lib/walker.ts:1200-1203).

// File doesn't exist, skip
}
}
}

/**
* Collect file paths from export conditions that Node.js may use at runtime.
* Specifically targets module-sync and import conditions.
*/
private collectAlternateExportFiles(
exports: unknown,
files: Set<string> = new Set(),
): Set<string> {
if (typeof exports === 'string') {
if (exports.endsWith('.mjs')) files.add(exports);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocker] · Correctness / Tests

Asymmetric filter: this top-level string branch filters to .endsWith('.mjs'), but the module-sync/import condition-match branch below (line 1017) adds values unconditionally. Meanwhile needsMjsTransform (line 1100) is .mjs-extension-gated.

Why: A module-sync: "./entry.js" under a "type":"module" parent is collected as STORE_CONTENT but never transformed → Node loads raw ESM inside the snapshot → the very ERR_MODULE_NOT_FOUND this PR aims to fix.

Fix: Drive needsMjsTransform off isESMFile(record.file) (which already handles .js + "type":"module"), not the .mjs extension — and make the leaf filter consistent (or drop it entirely and collect every terminal string under exports).

return files;
}

if (Array.isArray(exports)) {
for (const item of exports) {
this.collectAlternateExportFiles(item, files);
}
return files;
}

if (exports && typeof exports === 'object') {
for (const [key, value] of Object.entries(exports)) {
// Include files from conditions that Node.js may use at runtime
if (key === 'module-sync' || key === 'import') {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] · Design / DRY

Hardcoding 'module-sync' | 'import' is the wrong shape. Node's resolver walks an arbitrary condition stack: require, node, node-addons, default, --conditions, community keys like workerd/deno/bun. The symptom today is module-sync; tomorrow it will be another key and the same class of bug reopens.

lib/resolver.ts:33 (resolveWithExports) already encodes a different condition set — two sources of truth.

Fix: Collect every terminal string-valued target in exports (filtered to paths under pkgDir), regardless of condition key. Share a single RUNTIME_CONDITIONS constant with resolver.ts, update resolver.ts fallback to match.

if (typeof value === 'string') {
files.add(value);
}
}
// Recurse into nested conditions and subpath patterns
if (typeof value === 'object' || typeof value === 'string') {
this.collectAlternateExportFiles(value, files);
}
}
}

return files;
}

async stepDerivatives(
Expand Down Expand Up @@ -1005,10 +1096,18 @@ class Walker {

const needsSeaRead = this.needsSeaRead(record);

// Also read .mjs STORE_CONTENT files so they can be transformed to CJS
const needsMjsTransform =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocker] · Correctness / Design

STORE_CONTENT .mjs files reaching this needsMjsTransform path never have their import/require statements walked — stepDetect / stepDerivatives are gated on store === STORE_BLOB || needsSeaRead at the block below, and needsMjsTransform is not included there.

Why: Any import inside an alternate-entry .mjs that isn't also reachable via another condition will silently drop out of the snapshot → ERR_MODULE_NOT_FOUND at runtime. The current test passes only because require.mjs imports ./index.js, which is also in default.

Fix: Route alternate entries through the same STORE_BLOB + stepDetect pipeline used for primary resolved files, or at minimum extend the gate to include needsMjsTransform so derivatives are walked.

store === STORE_CONTENT &&
!this.params.seaMode &&
record.file.endsWith('.mjs') &&
isESMFile(record.file);

if (
store === STORE_BLOB ||
needsSeaRead ||
(store === STORE_CONTENT && isPackageJson(record.file)) ||
needsMjsTransform ||
this.hasPatch(record)
) {
if (!record.body) {
Expand Down Expand Up @@ -1101,8 +1200,10 @@ class Walker {

// Transform ESM to CJS before bytecode compilation
// Check all JS-like files (.js, .mjs, .cjs) but only transform ESM ones
// Also transform .mjs files stored as STORE_CONTENT (e.g., from dependencies)
// to prevent Node.js from loading them as ESM at runtime
if (
store === STORE_BLOB &&
(store === STORE_BLOB || needsMjsTransform) &&
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] · Design / DRY

Two parallel ESM→CJS pipelines now exist: the STORE_BLOB branch (body read → transform → rewrite paths inside if (STORE_BLOB || needsSeaRead)) and the new needsMjsTransform one (body read → transform here → separate rewrite block at line 1251).

Why: Future changes to the transform (sourcemaps, new file types, bug fixes) will be applied to one branch and forgotten on the other.

Fix: Unify on a single predicate !seaMode && record.body && isESMFile(record.file). Let the body-read gate and derivative walking piggy-back on it. Hoist rewriteMjsRequirePaths below both branches so it runs once.

!this.params.seaMode &&
record.body &&
(isDotJS(record.file) || record.file.endsWith('.mjs'))
Expand Down Expand Up @@ -1145,6 +1246,14 @@ class Walker {
);
}
}

// Also rewrite .mjs require paths for STORE_CONTENT files that were transformed
if (needsMjsTransform && record.wasTransformed && record.body) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] · DRY / Operability

This rewriteMjsRequirePaths call duplicates the one in the STORE_BLOB tail at line 1244. It also runs outside any try/catch — if it throws (malformed buffer, regex engine), the build crashes with a bare stack trace and no file name.

Fix (combined): Drop this block, widen the outer gate to store === STORE_BLOB || needsSeaRead || needsMjsTransform so the existing rewrite at line 1244 covers both paths, and wrap it with the same descriptive Failed to rewrite .mjs require paths for file "${record.file}": ${message} pattern used around transformESMtoCJS.

record.body = Buffer.from(
rewriteMjsRequirePaths(record.body.toString('utf8')),
'utf8',
);
}
}

record[store] = true;
Expand Down
40 changes: 40 additions & 0 deletions test/test-54-esm-mjs-imports-js/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env node

'use strict';

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 input = './test-x-index.js';
const output = './run-time/test-output';

console.log('Testing .mjs importing .js (module-sync pattern)...');

let left, right;
utils.mkdirp.sync(path.dirname(output));

// Run with node first to get expected output
left = utils.spawn.sync('node', [input]);
console.log('Node output:', left.trim());

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

// Run packaged version
right = utils.spawn.sync('./' + path.basename(output), [], {
cwd: path.dirname(output),
});
console.log('Packaged output:', right.trim());

assert.strictEqual(left.trim(), right.trim(), 'Outputs should match');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] · Tests

assert.strictEqual(left.trim(), right.trim()) passes whenever both sides produce the same output — including both failing identically (e.g., both crash with the same stack). The test would not catch a regression where the transform silently breaks and Node also fails to load require.mjs the same way.

Fix: Assert left.trim() === 'ok' explicitly so any failure on either side fails the test.


console.log('Test passed: .mjs importing .js works correctly');

utils.vacuum.sync(path.dirname(output));

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.

5 changes: 5 additions & 0 deletions test/test-54-esm-mjs-imports-js/test-x-index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

const getAsyncFunction = require('esm-module');
const AsyncFunction = getAsyncFunction();
console.log(typeof AsyncFunction === 'function' ? 'ok' : 'fail');