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
1 change: 1 addition & 0 deletions packages/commonjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
},
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"cjs-module-lexer": "^2.2.0",
"commondir": "^1.0.1",
"estree-walker": "^2.0.2",
"fdir": "^6.2.0",
Expand Down
80 changes: 80 additions & 0 deletions packages/commonjs/src/analyze-exports-lexer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { init, parse } from 'cjs-module-lexer';

let initialized = false;

/**
* Ensure cjs-module-lexer WASM is initialized.
* Safe to call multiple times — will only init once.
*/
export async function ensureInit() {
if (!initialized) {
await init();
initialized = true;
}
Comment on lines +4 to +13
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

ensureInit is not concurrency-safe: if two async callers hit it before initialized flips to true, init() can run multiple times. Consider caching the in-flight init promise (e.g. let initPromise) or setting the flag before awaiting to ensure only one initialization occurs per process.

Suggested change
/**
* Ensure cjs-module-lexer WASM is initialized.
* Safe to call multiple times will only init once.
*/
export async function ensureInit() {
if (!initialized) {
await init();
initialized = true;
}
let initPromise = null;
/**
* Ensure cjs-module-lexer WASM is initialized.
* Safe to call multiple times will only init once.
*/
export async function ensureInit() {
if (initialized) return;
if (!initPromise) {
initPromise = (async () => {
await init();
initialized = true;
initPromise = null;
})();
}
await initPromise;

Copilot uses AI. Check for mistakes.
}

/**
* Analyze a CommonJS module source to detect named exports.
*
* @param {string} code — The raw CJS source code.
* @param {string} id — The module ID (for error reporting).
* @returns {Promise<{
* exports: string[]
* reexports: string[]
* hasDefaultExport: boolean
* }>}
*/
export async function analyzeExports(code, id) {
await ensureInit();
try {
Comment on lines +28 to +29
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

analyzeExports calls await ensureInit() outside the try block, so any failure to initialize the lexer (e.g. WASM load/init errors) will reject and bypass the intended graceful fallback. Wrap initialization in the same try/catch (or have ensureInit return a cached init promise and handle errors) so lexer failures don’t hard-fail the build.

Suggested change
await ensureInit();
try {
try {
await ensureInit();

Copilot uses AI. Check for mistakes.
const result = parse(code);
// Deduplicate and filter out "default" — handled separately
const namedExports = [...new Set(result.exports)].filter(e => e !== 'default');
const reexports = [...new Set(result.reexports)];
return {
exports: namedExports,
reexports,
hasDefaultExport: result.exports.includes('default'),
};
} catch (err) {
// If lexer fails (e.g. WASM issue), fall back gracefully
this.warn(
`[commonjs] cjs-module-lexer failed for ${id}: ${err.message}. ` +
'Falling back to no named exports.'
);

return { exports: [], reexports: [], hasDefaultExport: true };
Comment on lines +40 to +46
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

analyzeExports uses this.warn(...), but this function is not called with a Rollup plugin context (it’s invoked as analyzeExports(code, id)), so this will be undefined and this path will throw. Pass a warn callback/context into analyzeExports (or move the warning to the caller) and ensure the fallback return does not incorrectly set hasDefaultExport: true when lexing fails.

Copilot uses AI. Check for mistakes.
}
}

/**
* Given a list of reexport sources, recursively resolve
* their named exports using the provided resolver.
*
* @param {string[]} reexportSources
* @param {(source: string) => Promise<string|null>} resolve
* @param {(id: string) => Promise<string>} loadCode
* @param {Set<string>} [seen]
* @returns {Promise<string[]>}
*/
export async function resolveReexports(reexportSources, resolve, loadCode, seen = new Set()) {
const allExports = [];
for (const source of reexportSources) {
const resolved = await resolve(source);
if (!resolved || seen.has(resolved)) continue;
seen.add(resolved);
try {
const code = await loadCode(resolved);
const { exports: childExports, reexports: childReexports } = await analyzeExports(code, resolved);
allExports.push(...childExports);
if (childReexports.length > 0) {
const nested = await resolveReexports(childReexports, resolve, loadCode, seen);
allExports.push(...nested);
}
} catch {
// skip unresolvable reexports
}
}

return [...new Set(allExports)];
}
24 changes: 19 additions & 5 deletions packages/commonjs/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createFilter } from '@rollup/pluginutils';

import { peerDependencies, version } from '../package.json';

import { analyzeExports, ensureInit as ensureLexerInit } from './analyze-exports-lexer';
import analyzeTopLevelStatements from './analyze-top-level-statements';
import { getDynamicModuleRegistry, getDynamicRequireModules } from './dynamic-modules';

Expand Down Expand Up @@ -113,7 +114,7 @@ export default function commonjs(options = {}) {
// Initialized in buildStart
let requireResolver;

function transformAndCheckExports(code, id) {
async function transformAndCheckExports(code, id) {
const normalizedId = normalizePathSlashes(id);
const { isEsModule, hasDefaultExport, hasNamedExports, ast } = analyzeTopLevelStatements(
this.parse,
Expand All @@ -138,6 +139,16 @@ export default function commonjs(options = {}) {
return { meta: { commonjs: commonjsMeta } };
}

// Use cjs-module-lexer for named export detection on CJS modules
if (!isEsModule) {
const lexerResult = await analyzeExports(code, id);
commonjsMeta.lexerExports = lexerResult.exports;
commonjsMeta.lexerReexports = lexerResult.reexports;
if (lexerResult.hasDefaultExport && !commonjsMeta.hasDefaultExport) {
commonjsMeta.hasDefaultExport = true;
}
}

const needsRequireWrapper =
!isEsModule && (dynamicRequireModules.has(normalizedId) || strictRequiresFilter(id));

Expand Down Expand Up @@ -202,8 +213,9 @@ export default function commonjs(options = {}) {
return { ...rawOptions, plugins };
},

buildStart({ plugins }) {
async buildStart({ plugins }) {
validateVersion(this.meta.rollupVersion, peerDependencies.rollup, 'rollup');
await ensureLexerInit();
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

buildStart now unconditionally awaits ensureLexerInit(). If cjs-module-lexer initialization fails in an environment (WASM load issues, restricted FS, etc.), the entire plugin will hard-fail before any fallback logic can run. Consider catching initialization errors here (and warning) so the plugin can continue with the existing AST-based export detection.

Suggested change
await ensureLexerInit();
try {
await ensureLexerInit();
} catch (error) {
this.warn({
code: 'CJS_LEXER_INIT_FAILED',
message:
'Failed to initialize cjs-module-lexer. Falling back to AST-based export detection.' +
(error && error.message ? ` Cause: ${error.message}` : '')
});
}

Copilot uses AI. Check for mistakes.
const nodeResolve = plugins.find(({ name }) => name === 'node-resolve');
if (nodeResolve) {
validateVersion(nodeResolve.version, '^13.0.6', '@rollup/plugin-node-resolve');
Expand Down Expand Up @@ -291,10 +303,12 @@ export default function commonjs(options = {}) {

if (isWrappedId(id, ES_IMPORT_SUFFIX)) {
const actualId = unwrapId(id, ES_IMPORT_SUFFIX);
const loadedModule = await this.load({ id: actualId });
return getEsImportProxy(
actualId,
getDefaultIsModuleExports(actualId),
(await this.load({ id: actualId })).moduleSideEffects
loadedModule.moduleSideEffects,
loadedModule.meta?.commonjs?.lexerExports || []
);
}

Expand All @@ -319,11 +333,11 @@ export default function commonjs(options = {}) {
return requireResolver.shouldTransformCachedModule.call(this, ...args);
},

transform(code, id) {
async transform(code, id) {
if (!isPossibleCjsId(id)) return null;

try {
return transformAndCheckExports.call(this, code, id);
return await transformAndCheckExports.call(this, code, id);
} catch (err) {
return this.error(err, err.pos);
}
Expand Down
16 changes: 14 additions & 2 deletions packages/commonjs/src/proxies.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,15 @@ export function getEntryProxy(id, defaultIsModuleExports, getModuleInfo, shebang
}
return shebang + code;
}
const result = getEsImportProxy(id, defaultIsModuleExports, true);
const lexerExports = commonjsMeta?.lexerExports || [];
const result = getEsImportProxy(id, defaultIsModuleExports, true, lexerExports);
return {
...result,
code: shebang + result.code
};
}

export function getEsImportProxy(id, defaultIsModuleExports, moduleSideEffects) {
export function getEsImportProxy(id, defaultIsModuleExports, moduleSideEffects, lexerExports = []) {
const name = getName(id);
const exportsName = `${name}Exports`;
const requireModule = `require${capitalize(name)}`;
Expand All @@ -80,6 +81,17 @@ export function getEsImportProxy(id, defaultIsModuleExports, moduleSideEffects)
} else {
code += `\nexport default /*@__PURE__*/getDefaultExportFromCjs(${exportsName});`;
}

// Add explicit named re-exports detected by cjs-module-lexer
const namedExports = lexerExports.filter(
(e) => e !== 'default' && e !== '__esModule' && /^[$_a-zA-Z][$_a-zA-Z0-9]*$/.test(e)
);
if (namedExports.length > 0) {
for (const exportName of namedExports) {
code += `\nvar _cjsExport_${exportName} = ${exportsName}["${exportName}"];\nexport { _cjsExport_${exportName} as ${exportName} };`;
}
}

return {
code,
syntheticNamedExports: '__moduleExports'
Expand Down
25 changes: 24 additions & 1 deletion packages/commonjs/src/transform-commonjs.js
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ export default async function transformCommonjs(
commonjsMeta
);
const usesRequireWrapper = commonjsMeta.isCommonJS === IS_WRAPPED_COMMONJS;
const exportBlock = isEsModule
let exportBlock = isEsModule
? ''
: rewriteExportsAndGetExportsBlock(
magicString,
Expand All @@ -559,6 +559,29 @@ export default async function transformCommonjs(
requireName
);

// Enhance export block with cjs-module-lexer detected exports
// that were not already found by the AST walk
if (!isEsModule && !usesRequireWrapper) {
const lexerExports = commonjsMeta.lexerExports || [];
const astDetectedExports = new Set(exportsAssignmentsByName.keys());
const additionalExports = lexerExports.filter(
(name) =>
!astDetectedExports.has(name) &&
name !== 'default' &&
name !== '__esModule' &&
/^[$_a-zA-Z][$_a-zA-Z0-9]*$/.test(name)
);
if (additionalExports.length > 0) {
const sourceObj = exportMode === 'module' ? exportedExportsName : exportsName;
for (const name of additionalExports) {
const deconflictedName = deconflict([scope], globals, name);
exportBlock += `\nvar ${deconflictedName} = ${sourceObj}["${name}"];\nexport { ${
deconflictedName === name ? name : `${deconflictedName} as ${name}`
} };`;
}
}
}

if (shouldWrap) {
wrapCode(magicString, uses, moduleName, exportsName, indentExclusionRanges);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
exports.$Enums = {};
exports.AccessLevel = exports.$Enums.AccessLevel = {
TEST: 'TEST',
};
9 changes: 9 additions & 0 deletions packages/commonjs/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,15 @@ test('import CommonJS module with esm property should get default export ', asyn
const result2 = await executeBundle(bundle2, avaAssertions);
expect(result2.error.message).toBe('libExports is not a function');
});
test('test named exports from cjs', async (t) => {
const bundle = await rollup({
input: 'fixtures/samples/named-cjs-exports/main.cjs',
plugins: [commonjs()]
});
Comment on lines +297 to +301
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The PR description mentions supporting named exports created via Object.defineProperty(exports, "foo", …), but the added fixture/test only covers assignment-based exports. Add a fixture that defines a named export via Object.defineProperty and assert it is recognized to prevent regressions for the reported issue.

Copilot uses AI. Check for mistakes.

t.plan(3);
await testBundle(t, bundle);
Comment on lines +297 to +304
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

This test is executed by Vitest (see package.json), which does not pass an AVA-style t object into the test callback. As written, t will be undefined and t.plan(3) will throw, failing the suite. Align this test with the surrounding ones (e.g. expect.assertions(…) + testBundle(avaAssertions, bundle)), or use Vitest’s test context APIs.

Suggested change
test('test named exports from cjs', async (t) => {
const bundle = await rollup({
input: 'fixtures/samples/named-cjs-exports/main.cjs',
plugins: [commonjs()]
});
t.plan(3);
await testBundle(t, bundle);
test('test named exports from cjs', async () => {
const bundle = await rollup({
input: 'fixtures/samples/named-cjs-exports/main.cjs',
plugins: [commonjs()]
});
expect.assertions(3);
await testBundle(avaAssertions, bundle);

Copilot uses AI. Check for mistakes.
});
test('identifies named exports from object literals', async () => {
const bundle = await rollup({
input: 'fixtures/samples/named-exports-from-object-literal/main.js',
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

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

Loading