diff --git a/packages/commonjs/package.json b/packages/commonjs/package.json index b8788b838..f10e7cdeb 100644 --- a/packages/commonjs/package.json +++ b/packages/commonjs/package.json @@ -63,6 +63,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", diff --git a/packages/commonjs/src/analyze-exports-lexer.js b/packages/commonjs/src/analyze-exports-lexer.js new file mode 100644 index 000000000..b36d23ae3 --- /dev/null +++ b/packages/commonjs/src/analyze-exports-lexer.js @@ -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; + } +} + +/** + * 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 { + 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 + console.warn( + `[commonjs] cjs-module-lexer failed for ${id}: ${err.message}. ` + + 'Falling back to no named exports.' + ); + + return { exports: [], reexports: [], hasDefaultExport: true }; + } +} + +/** + * Given a list of reexport sources, recursively resolve + * their named exports using the provided resolver. + * + * @param {string[]} reexportSources + * @param {(source: string) => Promise} resolve + * @param {(id: string) => Promise} loadCode + * @param {Set} [seen] + * @returns {Promise} + */ +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)]; +} diff --git a/packages/commonjs/src/index.js b/packages/commonjs/src/index.js index 6476ab423..e70def704 100644 --- a/packages/commonjs/src/index.js +++ b/packages/commonjs/src/index.js @@ -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'; @@ -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, @@ -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)); @@ -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(); const nodeResolve = plugins.find(({ name }) => name === 'node-resolve'); if (nodeResolve) { validateVersion(nodeResolve.version, '^13.0.6', '@rollup/plugin-node-resolve'); @@ -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 || [] ); } @@ -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); } diff --git a/packages/commonjs/src/proxies.js b/packages/commonjs/src/proxies.js index 72aae3640..7a81b39e0 100644 --- a/packages/commonjs/src/proxies.js +++ b/packages/commonjs/src/proxies.js @@ -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)}`; @@ -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' diff --git a/packages/commonjs/src/transform-commonjs.js b/packages/commonjs/src/transform-commonjs.js index 80edc3e82..4b45fd044 100644 --- a/packages/commonjs/src/transform-commonjs.js +++ b/packages/commonjs/src/transform-commonjs.js @@ -531,7 +531,7 @@ export default async function transformCommonjs( commonjsMeta ); const usesRequireWrapper = commonjsMeta.isCommonJS === IS_WRAPPED_COMMONJS; - const exportBlock = isEsModule + let exportBlock = isEsModule ? '' : rewriteExportsAndGetExportsBlock( magicString, @@ -553,6 +553,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); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5908b66b..ff028360c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,6 +208,9 @@ importers: '@rollup/pluginutils': specifier: ^5.0.1 version: 5.0.1(rollup@4.0.0-24) + cjs-module-lexer: + specifier: ^2.2.0 + version: 2.2.0 commondir: specifier: ^1.0.1 version: 1.0.1 @@ -2621,6 +2624,9 @@ packages: ci-parallel-vars@1.0.1: resolution: {integrity: sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -7315,6 +7321,8 @@ snapshots: ci-parallel-vars@1.0.1: {} + cjs-module-lexer@2.2.0: {} + clean-stack@2.2.0: {} clean-stack@4.2.0: