From be2719d4c4c322720f2db2103771fb2b8fc03612 Mon Sep 17 00:00:00 2001 From: Ian Hou Date: Mon, 20 Apr 2026 18:49:38 +0000 Subject: [PATCH] fix: include and transform .mjs files from module-sync exports Two related fixes for ERR_MODULE_NOT_FOUND when packaging projects that depend on packages using the module-sync export condition (e.g., async-function used by get-intrinsic v1.3.1): 1. Discover alternate export entry points: When resolving a package, also include .mjs files referenced by module-sync and import export conditions. These files may be loaded by Node.js at runtime instead of the default/require entry. 2. Transform .mjs STORE_CONTENT files to CJS: Extend the ESM-to-CJS transformation to also apply to .mjs files stored as STORE_CONTENT (from dependencies), not just STORE_BLOB. This ensures they get transformed and renamed to .js, preventing Node from loading them as ESM in the snapshot. Add test-54-esm-mjs-imports-js to verify the fix. Fixes https://github.com/yao-pkg/pkg/issues/195 --- lib/walker.ts | 111 +++++++++++++++++- test/test-54-esm-mjs-imports-js/main.js | 40 +++++++ .../node_modules/esm-module/index.js | 5 + .../node_modules/esm-module/package.json | 15 +++ .../node_modules/esm-module/require.mjs | 3 + .../test-x-index.js | 5 + 6 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 test/test-54-esm-mjs-imports-js/main.js create mode 100644 test/test-54-esm-mjs-imports-js/node_modules/esm-module/index.js create mode 100644 test/test-54-esm-mjs-imports-js/node_modules/esm-module/package.json create mode 100644 test/test-54-esm-mjs-imports-js/node_modules/esm-module/require.mjs create mode 100644 test/test-54-esm-mjs-imports-js/test-x-index.js diff --git a/lib/walker.ts b/lib/walker.ts index 4f68def1..d14fcf0a 100644 --- a/lib/walker.ts +++ b/lib/walker.ts @@ -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 + ? 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; + + const pkgExports = (marker.config as Record).exports; + if (!pkgExports) return; + + const pkgDir = path.dirname(marker.configPath); + const alternateFiles = this.collectAlternateExportFiles(pkgExports); + + for (const relFile of alternateFiles) { + 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, + store: STORE_CONTENT, + reason, + }); + } + } catch { + // 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 = new Set(), + ): Set { + if (typeof exports === 'string') { + if (exports.endsWith('.mjs')) files.add(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') { + 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( @@ -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 = + 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) { @@ -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) && !this.params.seaMode && record.body && (isDotJS(record.file) || record.file.endsWith('.mjs')) @@ -1145,6 +1246,14 @@ class Walker { ); } } + + // Also rewrite .mjs require paths for STORE_CONTENT files that were transformed + if (needsMjsTransform && record.wasTransformed && record.body) { + record.body = Buffer.from( + rewriteMjsRequirePaths(record.body.toString('utf8')), + 'utf8', + ); + } } record[store] = true; diff --git a/test/test-54-esm-mjs-imports-js/main.js b/test/test-54-esm-mjs-imports-js/main.js new file mode 100644 index 00000000..343a8f40 --- /dev/null +++ b/test/test-54-esm-mjs-imports-js/main.js @@ -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'); + +console.log('Test passed: .mjs importing .js works correctly'); + +utils.vacuum.sync(path.dirname(output)); diff --git a/test/test-54-esm-mjs-imports-js/node_modules/esm-module/index.js b/test/test-54-esm-mjs-imports-js/node_modules/esm-module/index.js new file mode 100644 index 00000000..0421f208 --- /dev/null +++ b/test/test-54-esm-mjs-imports-js/node_modules/esm-module/index.js @@ -0,0 +1,5 @@ +'use strict'; + +const cached = (async function () {}).constructor; + +module.exports = () => cached; diff --git a/test/test-54-esm-mjs-imports-js/node_modules/esm-module/package.json b/test/test-54-esm-mjs-imports-js/node_modules/esm-module/package.json new file mode 100644 index 00000000..fcebea51 --- /dev/null +++ b/test/test-54-esm-mjs-imports-js/node_modules/esm-module/package.json @@ -0,0 +1,15 @@ +{ + "name": "esm-module", + "version": "1.0.0", + "main": "./index.js", + "exports": { + ".": [ + { + "module-sync": "./require.mjs", + "import": "./index.mjs", + "default": "./index.js" + }, + "./index.js" + ] + } +} diff --git a/test/test-54-esm-mjs-imports-js/node_modules/esm-module/require.mjs b/test/test-54-esm-mjs-imports-js/node_modules/esm-module/require.mjs new file mode 100644 index 00000000..cda9e49b --- /dev/null +++ b/test/test-54-esm-mjs-imports-js/node_modules/esm-module/require.mjs @@ -0,0 +1,3 @@ +import getAsyncFunction from './index.js'; +export default getAsyncFunction; +export { getAsyncFunction as 'module.exports' }; diff --git a/test/test-54-esm-mjs-imports-js/test-x-index.js b/test/test-54-esm-mjs-imports-js/test-x-index.js new file mode 100644 index 00000000..52300ccc --- /dev/null +++ b/test/test-54-esm-mjs-imports-js/test-x-index.js @@ -0,0 +1,5 @@ +'use strict'; + +const getAsyncFunction = require('esm-module'); +const AsyncFunction = getAsyncFunction(); +console.log(typeof AsyncFunction === 'function' ? 'ok' : 'fail');