Skip to content

Commit 64f3e7a

Browse files
committed
fix: recognize named export
1 parent 5cc7fc5 commit 64f3e7a

6 files changed

Lines changed: 146 additions & 8 deletions

File tree

packages/commonjs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
},
6565
"dependencies": {
6666
"@rollup/pluginutils": "^5.0.1",
67+
"cjs-module-lexer": "^2.2.0",
6768
"commondir": "^1.0.1",
6869
"estree-walker": "^2.0.2",
6970
"fdir": "^6.2.0",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { init, parse } from 'cjs-module-lexer';
2+
3+
let initialized = false;
4+
5+
/**
6+
* Ensure cjs-module-lexer WASM is initialized.
7+
* Safe to call multiple times — will only init once.
8+
*/
9+
export async function ensureInit() {
10+
if (!initialized) {
11+
await init();
12+
initialized = true;
13+
}
14+
}
15+
16+
/**
17+
* Analyze a CommonJS module source to detect named exports.
18+
*
19+
* @param {string} code — The raw CJS source code.
20+
* @param {string} id — The module ID (for error reporting).
21+
* @returns {Promise<{
22+
* exports: string[]
23+
* reexports: string[]
24+
* hasDefaultExport: boolean
25+
* }>}
26+
*/
27+
export async function analyzeExports(code, id) {
28+
await ensureInit();
29+
try {
30+
const result = parse(code);
31+
// Deduplicate and filter out "default" — handled separately
32+
const namedExports = [...new Set(result.exports)].filter(e => e !== 'default');
33+
const reexports = [...new Set(result.reexports)];
34+
return {
35+
exports: namedExports,
36+
reexports,
37+
hasDefaultExport: result.exports.includes('default'),
38+
};
39+
} catch (err) {
40+
// If lexer fails (e.g. WASM issue), fall back gracefully
41+
console.warn(
42+
`[commonjs] cjs-module-lexer failed for ${id}: ${err.message}. ` +
43+
'Falling back to no named exports.'
44+
);
45+
46+
return { exports: [], reexports: [], hasDefaultExport: true };
47+
}
48+
}
49+
50+
/**
51+
* Given a list of reexport sources, recursively resolve
52+
* their named exports using the provided resolver.
53+
*
54+
* @param {string[]} reexportSources
55+
* @param {(source: string) => Promise<string|null>} resolve
56+
* @param {(id: string) => Promise<string>} loadCode
57+
* @param {Set<string>} [seen]
58+
* @returns {Promise<string[]>}
59+
*/
60+
export async function resolveReexports(reexportSources, resolve, loadCode, seen = new Set()) {
61+
const allExports = [];
62+
for (const source of reexportSources) {
63+
const resolved = await resolve(source);
64+
if (!resolved || seen.has(resolved)) continue;
65+
seen.add(resolved);
66+
try {
67+
const code = await loadCode(resolved);
68+
const { exports: childExports, reexports: childReexports } = await analyzeExports(code, resolved);
69+
allExports.push(...childExports);
70+
if (childReexports.length > 0) {
71+
const nested = await resolveReexports(childReexports, resolve, loadCode, seen);
72+
allExports.push(...nested);
73+
}
74+
} catch {
75+
// skip unresolvable reexports
76+
}
77+
}
78+
79+
return [...new Set(allExports)];
80+
}

packages/commonjs/src/index.js

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createFilter } from '@rollup/pluginutils';
44

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

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

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

116-
function transformAndCheckExports(code, id) {
117+
async function transformAndCheckExports(code, id) {
117118
const normalizedId = normalizePathSlashes(id);
118119
const { isEsModule, hasDefaultExport, hasNamedExports, ast } = analyzeTopLevelStatements(
119120
this.parse,
@@ -138,6 +139,16 @@ export default function commonjs(options = {}) {
138139
return { meta: { commonjs: commonjsMeta } };
139140
}
140141

142+
// Use cjs-module-lexer for named export detection on CJS modules
143+
if (!isEsModule) {
144+
const lexerResult = await analyzeExports(code, id);
145+
commonjsMeta.lexerExports = lexerResult.exports;
146+
commonjsMeta.lexerReexports = lexerResult.reexports;
147+
if (lexerResult.hasDefaultExport && !commonjsMeta.hasDefaultExport) {
148+
commonjsMeta.hasDefaultExport = true;
149+
}
150+
}
151+
141152
const needsRequireWrapper =
142153
!isEsModule && (dynamicRequireModules.has(normalizedId) || strictRequiresFilter(id));
143154

@@ -202,8 +213,9 @@ export default function commonjs(options = {}) {
202213
return { ...rawOptions, plugins };
203214
},
204215

205-
buildStart({ plugins }) {
216+
async buildStart({ plugins }) {
206217
validateVersion(this.meta.rollupVersion, peerDependencies.rollup, 'rollup');
218+
await ensureLexerInit();
207219
const nodeResolve = plugins.find(({ name }) => name === 'node-resolve');
208220
if (nodeResolve) {
209221
validateVersion(nodeResolve.version, '^13.0.6', '@rollup/plugin-node-resolve');
@@ -291,10 +303,12 @@ export default function commonjs(options = {}) {
291303

292304
if (isWrappedId(id, ES_IMPORT_SUFFIX)) {
293305
const actualId = unwrapId(id, ES_IMPORT_SUFFIX);
306+
const loadedModule = await this.load({ id: actualId });
294307
return getEsImportProxy(
295308
actualId,
296309
getDefaultIsModuleExports(actualId),
297-
(await this.load({ id: actualId })).moduleSideEffects
310+
loadedModule.moduleSideEffects,
311+
loadedModule.meta?.commonjs?.lexerExports || []
298312
);
299313
}
300314

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

322-
transform(code, id) {
336+
async transform(code, id) {
323337
if (!isPossibleCjsId(id)) return null;
324338

325339
try {
326-
return transformAndCheckExports.call(this, code, id);
340+
return await transformAndCheckExports.call(this, code, id);
327341
} catch (err) {
328342
return this.error(err, err.pos);
329343
}

packages/commonjs/src/proxies.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,15 @@ export function getEntryProxy(id, defaultIsModuleExports, getModuleInfo, shebang
5757
}
5858
return shebang + code;
5959
}
60-
const result = getEsImportProxy(id, defaultIsModuleExports, true);
60+
const lexerExports = commonjsMeta?.lexerExports || [];
61+
const result = getEsImportProxy(id, defaultIsModuleExports, true, lexerExports);
6162
return {
6263
...result,
6364
code: shebang + result.code
6465
};
6566
}
6667

67-
export function getEsImportProxy(id, defaultIsModuleExports, moduleSideEffects) {
68+
export function getEsImportProxy(id, defaultIsModuleExports, moduleSideEffects, lexerExports = []) {
6869
const name = getName(id);
6970
const exportsName = `${name}Exports`;
7071
const requireModule = `require${capitalize(name)}`;
@@ -80,6 +81,17 @@ export function getEsImportProxy(id, defaultIsModuleExports, moduleSideEffects)
8081
} else {
8182
code += `\nexport default /*@__PURE__*/getDefaultExportFromCjs(${exportsName});`;
8283
}
84+
85+
// Add explicit named re-exports detected by cjs-module-lexer
86+
const namedExports = lexerExports.filter(
87+
(e) => e !== 'default' && e !== '__esModule' && /^[$_a-zA-Z][$_a-zA-Z0-9]*$/.test(e)
88+
);
89+
if (namedExports.length > 0) {
90+
for (const exportName of namedExports) {
91+
code += `\nvar _cjsExport_${exportName} = ${exportsName}["${exportName}"];\nexport { _cjsExport_${exportName} as ${exportName} };`;
92+
}
93+
}
94+
8395
return {
8496
code,
8597
syntheticNamedExports: '__moduleExports'

packages/commonjs/src/transform-commonjs.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ export default async function transformCommonjs(
537537
commonjsMeta
538538
);
539539
const usesRequireWrapper = commonjsMeta.isCommonJS === IS_WRAPPED_COMMONJS;
540-
const exportBlock = isEsModule
540+
let exportBlock = isEsModule
541541
? ''
542542
: rewriteExportsAndGetExportsBlock(
543543
magicString,
@@ -559,6 +559,29 @@ export default async function transformCommonjs(
559559
requireName
560560
);
561561

562+
// Enhance export block with cjs-module-lexer detected exports
563+
// that were not already found by the AST walk
564+
if (!isEsModule && !usesRequireWrapper) {
565+
const lexerExports = commonjsMeta.lexerExports || [];
566+
const astDetectedExports = new Set(exportsAssignmentsByName.keys());
567+
const additionalExports = lexerExports.filter(
568+
(name) =>
569+
!astDetectedExports.has(name) &&
570+
name !== 'default' &&
571+
name !== '__esModule' &&
572+
/^[$_a-zA-Z][$_a-zA-Z0-9]*$/.test(name)
573+
);
574+
if (additionalExports.length > 0) {
575+
const sourceObj = exportMode === 'module' ? exportedExportsName : exportsName;
576+
for (const name of additionalExports) {
577+
const deconflictedName = deconflict([scope], globals, name);
578+
exportBlock += `\nvar ${deconflictedName} = ${sourceObj}["${name}"];\nexport { ${
579+
deconflictedName === name ? name : `${deconflictedName} as ${name}`
580+
} };`;
581+
}
582+
}
583+
}
584+
562585
if (shouldWrap) {
563586
wrapCode(magicString, uses, moduleName, exportsName, indentExclusionRanges);
564587
}

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)