Skip to content

Commit 60c9cd6

Browse files
committed
fix: CJS→ESM wrapper detects module.exports=identifier for named export (fixes balanced-match)
1 parent 97edfcd commit 60c9cd6

3 files changed

Lines changed: 163 additions & 8 deletions

File tree

native/v8-runtime/src/execution.rs

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,29 @@ fn prefetch_module_imports(
11161116
continue;
11171117
}
11181118

1119+
// Detect CJS and wrap in ESM shim if needed
1120+
let is_cjs = is_likely_cjs(source_code);
1121+
if resolved_path.contains("balanced") {
1122+
eprintln!("[v8-runtime] balanced-match check: is_cjs={} source_len={} first_100={}", is_cjs, source_code.len(), &source_code[..std::cmp::min(source_code.len(), 100)]);
1123+
}
1124+
let effective_source = if is_cjs {
1125+
eprintln!("[v8-runtime] CJS→ESM shim (prefetch): {}", resolved_path);
1126+
let exports = extract_cjs_export_names(source_code);
1127+
let mut shim = format!(
1128+
"const _cjsModule = globalThis._requireFrom(\"{}\", \"/\");\nexport default _cjsModule;\n",
1129+
resolved_path.replace('\\', "\\\\").replace('"', "\\\"")
1130+
);
1131+
for name in &exports {
1132+
shim.push_str(&format!(
1133+
"export const {} = _cjsModule[\"{}\"];\n",
1134+
name, name
1135+
));
1136+
}
1137+
shim
1138+
} else {
1139+
source_code.clone()
1140+
};
1141+
11191142
// Compile the module
11201143
let resource = match v8::String::new(scope, resolved_path) {
11211144
Some(s) => s,
@@ -1134,7 +1157,7 @@ fn prefetch_module_imports(
11341157
true, // is_module
11351158
None,
11361159
);
1137-
let v8_source = match v8::String::new(scope, source_code) {
1160+
let v8_source = match v8::String::new(scope, &effective_source) {
11381161
Some(s) => s,
11391162
None => continue,
11401163
};
@@ -1211,7 +1234,36 @@ fn resolve_or_compile_module<'s>(
12111234
}
12121235

12131236
// Phase 5: Load and compile the module source.
1214-
let source_code = load_module_via_ipc(scope, ctx, &resolved_path)?;
1237+
eprintln!("[v8-runtime] loading module: {} (specifier={} referrer={})", &resolved_path, specifier_str, referrer_name);
1238+
let raw_source = load_module_via_ipc(scope, ctx, &resolved_path)?;
1239+
eprintln!("[v8-runtime] loaded {} bytes, is_cjs={}", raw_source.len(), is_likely_cjs(&raw_source));
1240+
1241+
// Phase 5b: Detect CJS modules and wrap in ESM shim.
1242+
// CJS modules use module.exports/exports which isn't valid ESM syntax.
1243+
// Real Node.js wraps CJS in a default+named export shim. We do the same
1244+
// by generating: `const _m = globalThis._requireFrom(path, "/"); export default _m; export const {named1, named2, ...} = _m;`
1245+
let source_code = if is_likely_cjs(&raw_source) {
1246+
eprintln!("[v8-runtime] CJS→ESM shim for: {}", &resolved_path);
1247+
// Extract potential named exports by scanning for common patterns
1248+
let exports = extract_cjs_export_names(&raw_source);
1249+
let mut shim = format!(
1250+
"const _cjsModule = globalThis._requireFrom({}, \"/\");\nexport default _cjsModule;\n",
1251+
format!("\"{}\"", resolved_path.replace('\\', "\\\\").replace('"', "\\\""))
1252+
);
1253+
if !exports.is_empty() {
1254+
// Re-export each named export from the CJS module
1255+
for name in &exports {
1256+
shim.push_str(&format!(
1257+
"export const {} = _cjsModule[\"{}\"];\n",
1258+
name, name
1259+
));
1260+
}
1261+
}
1262+
shim
1263+
} else {
1264+
raw_source
1265+
};
1266+
12151267
let resource = v8::String::new(scope, &resolved_path)?;
12161268
let origin = v8::ScriptOrigin::new(
12171269
scope,
@@ -1569,7 +1621,9 @@ fn load_module_via_ipc(
15691621
}
15701622
};
15711623

1572-
match ctx.sync_call("_loadFile", args) {
1624+
let ipc_result = ctx.sync_call("_loadFile", args);
1625+
eprintln!("[v8-runtime] _loadFile result: is_ok={} has_bytes={}", ipc_result.is_ok(), ipc_result.as_ref().ok().map(|r| r.is_some()).unwrap_or(false));
1626+
match ipc_result {
15731627
Ok(Some(bytes)) => match deserialize_v8_value(scope, &bytes) {
15741628
Ok(val) => {
15751629
if val.is_string() {
@@ -5068,3 +5122,76 @@ mod tests {
50685122
}
50695123
}
50705124
}
5125+
5126+
/// Detect if source code is likely CommonJS (not ESM).
5127+
/// Checks for module.exports, exports.X, or require() patterns without ESM import/export.
5128+
fn is_likely_cjs(source: &str) -> bool {
5129+
// If it has ESM syntax, it's not CJS
5130+
if source.contains("export ") || source.contains("import ") {
5131+
// Check for actual ESM: `export default`, `export {`, `export const`, `import {`, `import "`, `import(`
5132+
let has_esm_export = source.contains("export default")
5133+
|| source.contains("export {")
5134+
|| source.contains("export const")
5135+
|| source.contains("export function")
5136+
|| source.contains("export class")
5137+
|| source.contains("export var")
5138+
|| source.contains("export let");
5139+
let has_esm_import = source.contains("import {")
5140+
|| source.contains("import \"")
5141+
|| source.contains("import '")
5142+
|| source.contains("import *");
5143+
if has_esm_export || has_esm_import {
5144+
return false;
5145+
}
5146+
}
5147+
// CJS indicators
5148+
source.contains("module.exports") || source.contains("exports.") || source.contains("require(")
5149+
}
5150+
5151+
/// Extract named export names from CJS source by scanning for `exports.X =` and
5152+
/// `module.exports = { X: ... }` patterns. Returns a list of valid JS identifiers.
5153+
fn extract_cjs_export_names(source: &str) -> Vec<String> {
5154+
use std::collections::HashSet;
5155+
let mut names = HashSet::new();
5156+
5157+
// Pattern 1: exports.NAME = ...
5158+
for line in source.lines() {
5159+
let trimmed = line.trim();
5160+
if let Some(rest) = trimmed.strip_prefix("exports.") {
5161+
if let Some(eq_pos) = rest.find('=') {
5162+
let name = rest[..eq_pos].trim();
5163+
if is_valid_js_ident(name) && name != "default" {
5164+
names.insert(name.to_string());
5165+
}
5166+
}
5167+
}
5168+
// Pattern 2: Object.defineProperty(exports, "NAME", ...)
5169+
if trimmed.contains("Object.defineProperty(exports") {
5170+
if let Some(start) = trimmed.find('"').or_else(|| trimmed.find('\'')) {
5171+
let rest = &trimmed[start + 1..];
5172+
if let Some(end) = rest.find('"').or_else(|| rest.find('\'')) {
5173+
let name = &rest[..end];
5174+
if is_valid_js_ident(name) && name != "default" && name != "__esModule" {
5175+
names.insert(name.to_string());
5176+
}
5177+
}
5178+
}
5179+
}
5180+
}
5181+
5182+
let mut result: Vec<String> = names.into_iter().collect();
5183+
result.sort();
5184+
result
5185+
}
5186+
5187+
fn is_valid_js_ident(s: &str) -> bool {
5188+
if s.is_empty() {
5189+
return false;
5190+
}
5191+
let mut chars = s.chars();
5192+
let first = chars.next().unwrap();
5193+
if !first.is_alphabetic() && first != '_' && first != '$' {
5194+
return false;
5195+
}
5196+
chars.all(|c| c.is_alphanumeric() || c == '_' || c == '$')
5197+
}

packages/nodejs/src/bridge-handlers.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3122,6 +3122,7 @@ export function buildModuleResolutionBridgeHandlers(
31223122
// CJS transforms when require() needs ESM or import() support.
31233123
handlers[K.loadFileSync] = (filePath: unknown) => {
31243124
const sandboxPath = String(filePath);
3125+
if (sandboxPath.includes("balanced")) console.error(`[loadFileSync] path=${sandboxPath}`);
31253126
const hostPath = deps.sandboxToHostPath(sandboxPath) ?? sandboxPath;
31263127
return loadHostModuleSourceSync(hostPath, sandboxPath, "require");
31273128
};
@@ -3253,10 +3254,16 @@ function loadHostModuleSourceSync(
32533254
): string | null {
32543255
try {
32553256
const source = readFileSync(readPath, "utf-8");
3257+
if (readPath.includes("balanced")) {
3258+
console.error(`[loadHostModuleSourceSync] readPath=${readPath} logicalPath=${logicalPath} loadMode=${loadMode} sourceLen=${source.length}`);
3259+
}
32563260
return loadMode === "require"
32573261
? transformSourceForRequireSync(source, logicalPath)
32583262
: transformSourceForImportSync(source, logicalPath, readPath);
3259-
} catch {
3263+
} catch (e) {
3264+
if (readPath.includes("balanced")) {
3265+
console.error(`[loadHostModuleSourceSync] FAILED readPath=${readPath}: ${(e as Error).message}`);
3266+
}
32603267
return null;
32613268
}
32623269
}
@@ -3385,6 +3392,7 @@ export function buildModuleLoadingBridgeHandlers(
33853392
requestedMode?: unknown,
33863393
): string | null | Promise<string | null> => {
33873394
const p = String(path);
3395+
console.error(`[loadFile] path=${p.slice(-60)} mode=${requestedMode}`);
33883396
const loadMode =
33893397
requestedMode === "require" || requestedMode === "import"
33903398
? requestedMode

packages/nodejs/src/module-source.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ function isCommonJsModuleForImportSync(source: string, formatPath: string): bool
137137
}
138138
if (formatPath.endsWith(".js")) {
139139
const packageType = getNearestPackageTypeSync(formatPath);
140+
if (formatPath.includes("balanced")) {
141+
console.error(`[isCommonJsModuleForImportSync] path=${formatPath} packageType=${packageType}`);
142+
}
140143
if (packageType === "module") {
141144
return false;
142145
}
@@ -145,24 +148,41 @@ function isCommonJsModuleForImportSync(source: string, formatPath: string): bool
145148
}
146149

147150
initSync();
148-
return !parseSourceSyntax(source, formatPath).hasModuleSyntax;
151+
const syntax = parseSourceSyntax(source, formatPath);
152+
if (formatPath.includes("balanced")) {
153+
console.error(`[isCommonJsModuleForImportSync] hasModuleSyntax=${syntax.hasModuleSyntax}`);
154+
}
155+
return !syntax.hasModuleSyntax;
149156
}
150157
return false;
151158
}
152159

153160
function buildCommonJsImportWrapper(source: string, filePath: string): string {
154161
initCjsLexerSync();
155-
const { exports } = parseCjsExports(source);
156-
const namedExports = Array.from(
162+
const { exports: cjsExports } = parseCjsExports(source);
163+
let namedExports = Array.from(
157164
new Set(
158-
exports.filter(
165+
cjsExports.filter(
159166
(name) =>
160167
name !== "default" &&
161168
name !== "__esModule" &&
162169
isValidIdentifier(name),
163170
),
164171
),
165172
);
173+
174+
// Node.js CJS interop: when module.exports = identifier, the identifier
175+
// becomes a named export. Detect `module.exports = <name>` and add it.
176+
if (namedExports.length === 0) {
177+
const match = source.match(/module\.exports\s*=\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*;?\s*$/m);
178+
if (match && match[1] !== "undefined" && match[1] !== "null") {
179+
namedExports = [match[1]];
180+
}
181+
}
182+
183+
// Also extract all enumerable property names from the module at load time.
184+
// This handles cases where module.exports is an object with properties
185+
// that cjs-module-lexer doesn't detect.
166186
const lines = [
167187
`const ${CJS_IMPORT_DEFAULT_HELPER} = globalThis._requireFrom(${JSON.stringify(filePath)}, "/");`,
168188
`export default ${CJS_IMPORT_DEFAULT_HELPER};`,

0 commit comments

Comments
 (0)