55
66import * as net from "node:net" ;
77import * as tls from "node:tls" ;
8- import { readFileSync } from "node:fs" ;
8+ import { readFileSync , realpathSync , existsSync } from "node:fs" ;
9+ import { dirname as pathDirname , join as pathJoin , resolve as pathResolve } from "node:path" ;
910import { createRequire } from "node:module" ;
1011import {
1112 randomFillSync ,
@@ -34,8 +35,9 @@ import {
3435} from "@secure-exec/core" ;
3536import { normalizeBuiltinSpecifier } from "./builtin-modules.js" ;
3637import { resolveModule , loadFile } from "./package-bundler.js" ;
37- import { transformDynamicImport } from "@secure-exec/core/internal/shared/esm-utils" ;
38+ import { transformDynamicImport , isESM } from "@secure-exec/core/internal/shared/esm-utils" ;
3839import { bundlePolyfill , hasPolyfill } from "./polyfills.js" ;
40+ import { getStaticBuiltinWrapperSource , getEmptyBuiltinESMWrapper } from "./esm-compiler.js" ;
3941import {
4042 checkBridgeBudget ,
4143 assertPayloadByteLength ,
@@ -954,6 +956,169 @@ export interface ModuleResolutionBridgeDeps {
954956 hostToSandboxPath : ( hostPath : string ) => string ;
955957}
956958
959+ /**
960+ * Convert ESM source to CJS-compatible code for require() loading.
961+ * Handles import declarations, export declarations, and re-exports.
962+ */
963+ /** Strip // and /* comments from an export/import list string. */
964+ function stripComments ( s : string ) : string {
965+ return s . replace ( / \/ \/ [ ^ \n ] * / g, "" ) . replace ( / \/ \* [ \s \S ] * ?\* \/ / g, "" ) ;
966+ }
967+
968+ function convertEsmToCjs ( source : string , filePath : string ) : string {
969+ if ( ! isESM ( source , filePath ) ) return source ;
970+
971+ let code = source ;
972+
973+ // Remove const __filename/dirname declarations (already provided by CJS wrapper)
974+ code = code . replace ( / ^ \s * (?: c o n s t | l e t | v a r ) \s + _ _ f i l e n a m e \s * = \s * [ ^ ; ] + ; ? \s * $ / gm, "// __filename provided by CJS wrapper" ) ;
975+ code = code . replace ( / ^ \s * (?: c o n s t | l e t | v a r ) \s + _ _ d i r n a m e \s * = \s * [ ^ ; ] + ; ? \s * $ / gm, "// __dirname provided by CJS wrapper" ) ;
976+
977+ // import X from 'Y' → const X = require('Y')
978+ code = code . replace (
979+ / ^ \s * i m p o r t \s + ( \w + ) \s + f r o m \s + [ ' " ] ( [ ^ ' " ] + ) [ ' " ] \s * ; ? / gm,
980+ "const $1 = (function(m) { return m && m.__esModule ? m.default : m; })(require('$2'));" ,
981+ ) ;
982+
983+ // import { a, b as c } from 'Y' → const { a, b: c } = require('Y')
984+ code = code . replace (
985+ / ^ \s * i m p o r t \s + \{ ( [ ^ } ] + ) \} \s + f r o m \s + [ ' " ] ( [ ^ ' " ] + ) [ ' " ] \s * ; ? / gm,
986+ ( _match , imports : string , mod : string ) => {
987+ const mapped = stripComments ( imports ) . split ( "," ) . map ( ( s : string ) => {
988+ const t = s . trim ( ) ;
989+ if ( ! t ) return null ;
990+ const parts = t . split ( / \s + a s \s + / ) ;
991+ return parts . length === 2 ? `${ parts [ 0 ] . trim ( ) } : ${ parts [ 1 ] . trim ( ) } ` : t ;
992+ } ) . filter ( Boolean ) . join ( ", " ) ;
993+ return `const { ${ mapped } } = require('${ mod } ');` ;
994+ } ,
995+ ) ;
996+
997+ // import * as X from 'Y' → const X = require('Y')
998+ code = code . replace (
999+ / ^ \s * i m p o r t \s + \* \s + a s \s + ( \w + ) \s + f r o m \s + [ ' " ] ( [ ^ ' " ] + ) [ ' " ] \s * ; ? / gm,
1000+ "const $1 = require('$2');" ,
1001+ ) ;
1002+
1003+ // Side-effect imports: import 'Y' → require('Y')
1004+ code = code . replace (
1005+ / ^ \s * i m p o r t \s + [ ' " ] ( [ ^ ' " ] + ) [ ' " ] \s * ; ? / gm,
1006+ "require('$1');" ,
1007+ ) ;
1008+
1009+ // export { a, b } from 'Y' → re-export
1010+ code = code . replace (
1011+ / ^ \s * e x p o r t \s + \{ ( [ ^ } ] + ) \} \s + f r o m \s + [ ' " ] ( [ ^ ' " ] + ) [ ' " ] \s * ; ? / gm,
1012+ ( _match , exports : string , mod : string ) => {
1013+ return stripComments ( exports ) . split ( "," ) . map ( ( s : string ) => {
1014+ const t = s . trim ( ) ;
1015+ if ( ! t ) return "" ;
1016+ const parts = t . split ( / \s + a s \s + / ) ;
1017+ const local = parts [ 0 ] . trim ( ) ;
1018+ const exported = parts . length === 2 ? parts [ 1 ] . trim ( ) : local ;
1019+ return `Object.defineProperty(exports, '${ exported } ', { get: () => require('${ mod } ').${ local } , enumerable: true });` ;
1020+ } ) . filter ( Boolean ) . join ( "\n" ) ;
1021+ } ,
1022+ ) ;
1023+
1024+ // export * from 'Y'
1025+ code = code . replace (
1026+ / ^ \s * e x p o r t \s + \* \s + f r o m \s + [ ' " ] ( [ ^ ' " ] + ) [ ' " ] \s * ; ? / gm,
1027+ "Object.assign(exports, require('$1'));" ,
1028+ ) ;
1029+
1030+ // export default X → module.exports.default = X
1031+ code = code . replace (
1032+ / ^ \s * e x p o r t \s + d e f a u l t \s + / gm,
1033+ "module.exports.default = " ,
1034+ ) ;
1035+
1036+ // export const/let/var X = ... → const/let/var X = ...; exports.X = X;
1037+ code = code . replace (
1038+ / ^ \s * e x p o r t \s + ( c o n s t | l e t | v a r ) \s + ( \w + ) \s * = / gm,
1039+ "$1 $2 =" ,
1040+ ) ;
1041+ // Capture the names separately to add exports at the end
1042+ const exportedVars : string [ ] = [ ] ;
1043+ for ( const m of source . matchAll ( / ^ \s * e x p o r t \s + (?: c o n s t | l e t | v a r ) \s + ( \w + ) \s * = / gm) ) {
1044+ exportedVars . push ( m [ 1 ] ) ;
1045+ }
1046+
1047+ // export function X(...) → function X(...); exports.X = X;
1048+ code = code . replace (
1049+ / ^ \s * e x p o r t \s + f u n c t i o n \s + ( \w + ) / gm,
1050+ "function $1" ,
1051+ ) ;
1052+ for ( const m of source . matchAll ( / ^ \s * e x p o r t \s + f u n c t i o n \s + ( \w + ) / gm) ) {
1053+ exportedVars . push ( m [ 1 ] ) ;
1054+ }
1055+
1056+ // export class X → class X; exports.X = X;
1057+ code = code . replace (
1058+ / ^ \s * e x p o r t \s + c l a s s \s + ( \w + ) / gm,
1059+ "class $1" ,
1060+ ) ;
1061+ for ( const m of source . matchAll ( / ^ \s * e x p o r t \s + c l a s s \s + ( \w + ) / gm) ) {
1062+ exportedVars . push ( m [ 1 ] ) ;
1063+ }
1064+
1065+ // export { a, b } (local re-export without from)
1066+ code = code . replace (
1067+ / ^ \s * e x p o r t \s + \{ ( [ ^ } ] + ) \} \s * ; ? / gm,
1068+ ( _match , exports : string ) => {
1069+ return stripComments ( exports ) . split ( "," ) . map ( ( s : string ) => {
1070+ const t = s . trim ( ) ;
1071+ if ( ! t ) return "" ;
1072+ const parts = t . split ( / \s + a s \s + / ) ;
1073+ const local = parts [ 0 ] . trim ( ) ;
1074+ const exported = parts . length === 2 ? parts [ 1 ] . trim ( ) : local ;
1075+ return `Object.defineProperty(exports, '${ exported } ', { get: () => ${ local } , enumerable: true });` ;
1076+ } ) . filter ( Boolean ) . join ( "\n" ) ;
1077+ } ,
1078+ ) ;
1079+
1080+ // Append named exports for exported vars/functions/classes
1081+ if ( exportedVars . length > 0 ) {
1082+ const lines = exportedVars . map (
1083+ ( name ) => `Object.defineProperty(exports, '${ name } ', { get: () => ${ name } , enumerable: true });` ,
1084+ ) ;
1085+ code += "\n" + lines . join ( "\n" ) ;
1086+ }
1087+
1088+ return code ;
1089+ }
1090+
1091+ /**
1092+ * Resolve a package specifier by walking up directories and reading package.json exports.
1093+ * Handles both root imports ('pkg') and subpath imports ('pkg/sub').
1094+ */
1095+ function resolvePackageExport ( req : string , startDir : string ) : string | null {
1096+ // Split into package name and subpath
1097+ const parts = req . startsWith ( "@" ) ? req . split ( "/" ) : [ req . split ( "/" ) [ 0 ] , ...req . split ( "/" ) . slice ( 1 ) ] ;
1098+ const pkgName = req . startsWith ( "@" ) ? parts . slice ( 0 , 2 ) . join ( "/" ) : parts [ 0 ] ;
1099+ const subpath = req . startsWith ( "@" )
1100+ ? ( parts . length > 2 ? "./" + parts . slice ( 2 ) . join ( "/" ) : "." )
1101+ : ( parts . length > 1 ? "./" + parts . slice ( 1 ) . join ( "/" ) : "." ) ;
1102+
1103+ let cur = startDir ;
1104+ while ( cur !== pathDirname ( cur ) ) {
1105+ const pkgJsonPath = pathJoin ( cur , "node_modules" , ...pkgName . split ( "/" ) , "package.json" ) ;
1106+ if ( existsSync ( pkgJsonPath ) ) {
1107+ const pkg = JSON . parse ( readFileSync ( pkgJsonPath , "utf-8" ) ) ;
1108+ let entry : string | undefined ;
1109+ if ( pkg . exports ) {
1110+ const exportEntry = pkg . exports [ subpath ] ;
1111+ if ( typeof exportEntry === "string" ) entry = exportEntry ;
1112+ else if ( exportEntry ) entry = exportEntry . import ?? exportEntry . default ;
1113+ }
1114+ if ( ! entry && subpath === "." ) entry = pkg . main ;
1115+ if ( entry ) return pathResolve ( pathDirname ( pkgJsonPath ) , entry ) ;
1116+ }
1117+ cur = pathDirname ( cur ) ;
1118+ }
1119+ return null ;
1120+ }
1121+
9571122const hostRequire = createRequire ( import . meta. url ) ;
9581123
9591124/**
@@ -971,6 +1136,7 @@ export function buildModuleResolutionBridgeHandlers(
9711136 const K = HOST_BRIDGE_GLOBAL_KEYS ;
9721137
9731138 // Sync require.resolve — translates sandbox paths and uses Node.js resolution.
1139+ // Falls back to realpath + manual package.json resolution for pnpm/ESM packages.
9741140 handlers [ K . resolveModuleSync ] = ( request : unknown , fromDir : unknown ) => {
9751141 const req = String ( request ) ;
9761142
@@ -982,23 +1148,38 @@ export function buildModuleResolutionBridgeHandlers(
9821148 const sandboxDir = String ( fromDir ) ;
9831149 const hostDir = deps . sandboxToHostPath ( sandboxDir ) ?? sandboxDir ;
9841150
1151+ // Try require.resolve first
9851152 try {
9861153 const resolved = hostRequire . resolve ( req , { paths : [ hostDir ] } ) ;
987- // Translate resolved host path back to sandbox path
9881154 return deps . hostToSandboxPath ( resolved ) ;
989- } catch {
990- return null ;
991- }
1155+ } catch { /* CJS resolution failed */ }
1156+
1157+ // Fallback: follow symlinks and try ESM-compatible resolution
1158+ try {
1159+ let realDir : string ;
1160+ try { realDir = realpathSync ( hostDir ) ; } catch { realDir = hostDir ; }
1161+ // Try require.resolve from real path
1162+ try {
1163+ const resolved = hostRequire . resolve ( req , { paths : [ realDir ] } ) ;
1164+ return deps . hostToSandboxPath ( resolved ) ;
1165+ } catch { /* ESM-only, manual resolution */ }
1166+ // Manual package.json resolution for ESM packages
1167+ const resolved = resolvePackageExport ( req , realDir ) ;
1168+ if ( resolved ) return deps . hostToSandboxPath ( resolved ) ;
1169+ } catch { /* fallback failed */ }
1170+ return null ;
9921171 } ;
9931172
9941173 // Sync file read — translates sandbox path and reads via readFileSync.
995- // Also transforms dynamic import() calls for V8 compatibility.
1174+ // Transforms dynamic import() to __dynamicImport() and converts ESM to CJS
1175+ // for npm packages so require() can load ESM-only dependencies.
9961176 handlers [ K . loadFileSync ] = ( filePath : unknown ) => {
9971177 const sandboxPath = String ( filePath ) ;
9981178 const hostPath = deps . sandboxToHostPath ( sandboxPath ) ?? sandboxPath ;
9991179
10001180 try {
1001- const source = readFileSync ( hostPath , "utf-8" ) ;
1181+ let source = readFileSync ( hostPath , "utf-8" ) ;
1182+ source = convertEsmToCjs ( source , hostPath ) ;
10021183 return transformDynamicImport ( source ) ;
10031184 } catch {
10041185 return null ;
@@ -1081,6 +1262,8 @@ export function buildConsoleBridgeHandlers(deps: ConsoleBridgeDeps): BridgeHandl
10811262export interface ModuleLoadingBridgeDeps {
10821263 filesystem : VirtualFileSystem ;
10831264 resolutionCache : ResolutionCache ;
1265+ /** Convert sandbox path to host path for pnpm/symlink resolution fallback. */
1266+ sandboxToHostPath ?: ( sandboxPath : string ) => string | null ;
10841267}
10851268
10861269/** Build module loading bridge handlers (loadPolyfill, resolveModule, loadFile). */
@@ -1131,16 +1314,59 @@ export function buildModuleLoadingBridgeHandlers(
11311314 } ;
11321315
11331316 // Async module path resolution via VFS
1317+ // V8 ESM module resolve sends the full file path as referrer, not a directory.
1318+ // Extract dirname when the referrer looks like a file path.
1319+ // Falls back to Node.js require.resolve() with realpath for pnpm compatibility.
11341320 handlers [ K . resolveModule ] = async ( request : unknown , fromDir : unknown ) : Promise < string | null > => {
11351321 const req = String ( request ) ;
11361322 const builtin = normalizeBuiltinSpecifier ( req ) ;
11371323 if ( builtin ) return builtin ;
1138- return resolveModule ( req , String ( fromDir ) , deps . filesystem , "require" , deps . resolutionCache ) ;
1324+ let dir = String ( fromDir ) ;
1325+ if ( / \. [ c m ] ? [ j t ] s x ? $ / . test ( dir ) ) {
1326+ const lastSlash = dir . lastIndexOf ( "/" ) ;
1327+ if ( lastSlash > 0 ) dir = dir . slice ( 0 , lastSlash ) ;
1328+ }
1329+ const vfsResult = await resolveModule ( req , dir , deps . filesystem , "require" , deps . resolutionCache ) ;
1330+ if ( vfsResult ) return vfsResult ;
1331+ // Fallback: resolve through real host paths for pnpm symlink compatibility.
1332+ const hostDir = deps . sandboxToHostPath ?.( dir ) ?? dir ;
1333+ try {
1334+ let realDir : string ;
1335+ try { realDir = realpathSync ( hostDir ) ; } catch { realDir = hostDir ; }
1336+ // Try require.resolve (works for CJS packages)
1337+ try {
1338+ return hostRequire . resolve ( req , { paths : [ realDir ] } ) ;
1339+ } catch { /* ESM-only, try manual resolution */ }
1340+ // Manual package.json resolution for ESM packages
1341+ const resolved = resolvePackageExport ( req , realDir ) ;
1342+ if ( resolved ) return resolved ;
1343+ } catch { /* resolution failed */ }
1344+ return null ;
11391345 } ;
11401346
1141- // Async file read + dynamic import transform
1347+ // Dynamic import bridge — returns null to fall back to require() in the sandbox.
1348+ // V8 ESM module mode handles static imports natively via module_resolve_callback;
1349+ // this handler covers the __dynamicImport() path used in exec mode.
1350+ handlers [ K . dynamicImport ] = async ( ) : Promise < null > => null ;
1351+
1352+ // Async file read + dynamic import transform.
1353+ // Also serves ESM wrappers for built-in modules (fs, path, etc.) when
1354+ // used from V8's ES module system which calls _loadFile after _resolveModule.
11421355 handlers [ K . loadFile ] = async ( path : unknown ) : Promise < string | null > => {
1143- const source = await loadFile ( String ( path ) , deps . filesystem ) ;
1356+ const p = String ( path ) ;
1357+ // Built-in module ESM wrappers (V8 module system resolves 'fs' then loads it)
1358+ const bare = p . replace ( / ^ n o d e : / , "" ) ;
1359+ const builtin = getStaticBuiltinWrapperSource ( bare ) ;
1360+ if ( builtin ) return builtin ;
1361+ // Polyfill-backed builtins (crypto, zlib, etc.)
1362+ if ( hasPolyfill ( bare ) ) {
1363+ const code = await bundlePolyfill ( bare ) ;
1364+ // Wrap polyfill CJS bundle as ESM: export default + named re-exports
1365+ return `const _p = (function(){var module={exports:{}};var exports=module.exports;${ code } ;return module.exports})();\nexport default _p;\n` +
1366+ `for(const[k,v]of Object.entries(_p)){if(k!=='default'&&/^[A-Za-z_$]/.test(k))globalThis['__esm_'+k]=v;}\n` ;
1367+ }
1368+ // Regular file — keep ESM source intact for V8 module system
1369+ const source = await loadFile ( p , deps . filesystem ) ;
11441370 if ( source === null ) return null ;
11451371 return transformDynamicImport ( source ) ;
11461372 } ;
0 commit comments