From c5f57b77448cf0bb6849ceba3e35b46370c1cd48 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Tue, 3 Mar 2026 22:38:08 +0000 Subject: [PATCH 1/9] Replace require() with await import() in EXPORT_ES6 shell/runtime files When EXPORT_ES6 is enabled, the generated JS used createRequire() to polyfill require(), which breaks bundlers (webpack, Rollup, esbuild) and Electron's renderer process. Since EXPORT_ES6 requires MODULARIZE, the module body is wrapped in an async function where await is valid. - shell.js: Remove createRequire block entirely. Use await import() for worker_threads, fs, path, url, util. Replace __dirname with import.meta.url for path resolution. - shell_minimal.js: Same pattern for worker_threads and fs. Replace __dirname with new URL(..., import.meta.url) for wasm file loading. - runtime_debug.js: Skip local require() for fs/util when EXPORT_ES6, reuse outer-scope variables from shell.js instead. - runtime_common.js: Guard perf_hooks require() with EXPORT_ES6 alternative. - preamble.js: Hoist await import('node:v8') above instantiateSync() for NODE_CODE_CACHING since await can't be used inside sync functions. --- src/lib/libcore.js | 3 +-- src/parseTools.mjs | 23 +++++++++++++++++++++++ src/preamble.js | 2 +- src/runtime_common.js | 2 +- src/runtime_debug.js | 17 ++++++++++++----- src/shell.js | 22 +++++++--------------- src/shell_minimal.js | 12 ++++++------ 7 files changed, 51 insertions(+), 30 deletions(-) diff --git a/src/lib/libcore.js b/src/lib/libcore.js index f690ed0f907b2..36a0fd1542f0f 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -380,8 +380,7 @@ addToLibrary({ var cmdstr = UTF8ToString(command); if (!cmdstr.length) return 0; // this is what glibc seems to do (shell works test?) - var cp = require('node:child_process'); - var ret = cp.spawnSync(cmdstr, [], {shell:true, stdio:'inherit'}); + var ret = nodeChildProcess.spawnSync(cmdstr, [], {shell:true, stdio:'inherit'}); var _W_EXITCODE = (ret, sig) => ((ret) << 8 | (sig)); diff --git a/src/parseTools.mjs b/src/parseTools.mjs index b5c11288f31fa..f2b61f5990176 100644 --- a/src/parseTools.mjs +++ b/src/parseTools.mjs @@ -954,6 +954,27 @@ function makeModuleReceiveWithVar(localName, moduleName, defaultValue) { return ret; } +function makeNodeImport(module, guard = true) { + assert(ENVIRONMENT_MAY_BE_NODE, 'makeNodeImport called when environment can never be node'); + var expr; + if (EXPORT_ES6) { + expr = `await import(/* webpackIgnore: true */ /* @vite-ignore */ '${module}')`; + } else { + expr = `require('${module}')`; + } + if (guard) { + return `ENVIRONMENT_IS_NODE ? ${expr} : undefined`; + } + return expr; +} + +function makeNodeFilePath(filename) { + if (EXPORT_ES6) { + return `new URL('${filename}', import.meta.url)`; + } + return `__dirname + '/${filename}'`; +} + function makeRemovedFSAssert(fsName) { assert(ASSERTIONS); const lower = fsName.toLowerCase(); @@ -1241,6 +1262,8 @@ addToCompileTimeContext({ makeModuleReceive, makeModuleReceiveExpr, makeModuleReceiveWithVar, + makeNodeFilePath, + makeNodeImport, makeRemovedFSAssert, makeRetainedCompilerSettings, makeReturn64, diff --git a/src/preamble.js b/src/preamble.js index cbc818e952f3d..32fe152d5a5cf 100644 --- a/src/preamble.js +++ b/src/preamble.js @@ -550,7 +550,7 @@ function instantiateSync(file, info) { var binary = getBinarySync(file); #if NODE_CODE_CACHING if (ENVIRONMENT_IS_NODE) { - var v8 = require('node:v8'); + var v8 = {{{ makeNodeImport('node:v8', false) }}}; // Include the V8 version in the cache name, so that we don't try to // load cached code from another version, which fails silently (it seems // to load ok, but we do actually recompile the binary every time). diff --git a/src/runtime_common.js b/src/runtime_common.js index 13f56e427a012..3e3f59181623b 100644 --- a/src/runtime_common.js +++ b/src/runtime_common.js @@ -144,7 +144,7 @@ if (ENVIRONMENT_IS_NODE) { // depends on it for accurate timing. // Use `global` rather than `globalThis` here since older versions of node // don't have `globalThis`. - global.performance ??= require('perf_hooks').performance; + global.performance ??= ({{{ makeNodeImport('perf_hooks', false) }}}).performance; } #endif diff --git a/src/runtime_debug.js b/src/runtime_debug.js index 7c1170a3b3010..112004ca525e0 100644 --- a/src/runtime_debug.js +++ b/src/runtime_debug.js @@ -8,23 +8,30 @@ var runtimeDebug = true; // Switch to false at runtime to disable logging at the right times // Used by XXXXX_DEBUG settings to output debug messages. +#if ENVIRONMENT_MAY_BE_NODE && (PTHREADS || WASM_WORKERS) +// Pre-load debug modules for use in dbg(). These are loaded at module scope +// (inside async function Module()) where await is valid, since dbg() itself +// is a regular function where await cannot be used. +var dbg_fs, dbg_utils; +if (ENVIRONMENT_IS_NODE) { + dbg_fs = {{{ makeNodeImport('node:fs', false) }}}; + dbg_utils = {{{ makeNodeImport('node:util', false) }}}; +} +#endif function dbg(...args) { if (!runtimeDebug && typeof runtimeDebug != 'undefined') return; #if ENVIRONMENT_MAY_BE_NODE && (PTHREADS || WASM_WORKERS) // Avoid using the console for debugging in multi-threaded node applications // See https://github.com/emscripten-core/emscripten/issues/14804 if (ENVIRONMENT_IS_NODE) { - // TODO(sbc): Unify with err/out implementation in shell.sh. - var fs = require('node:fs'); - var utils = require('node:util'); function stringify(a) { switch (typeof a) { - case 'object': return utils.inspect(a); + case 'object': return dbg_utils.inspect(a); case 'undefined': return 'undefined'; } return a; } - fs.writeSync(2, args.map(stringify).join(' ') + '\n'); + dbg_fs.writeSync(2, args.map(stringify).join(' ') + '\n'); } else #endif // TODO(sbc): Make this configurable somehow. Its not always convenient for diff --git a/src/shell.js b/src/shell.js index 41294749487a4..c59ab51a75382 100644 --- a/src/shell.js +++ b/src/shell.js @@ -106,18 +106,9 @@ if (ENVIRONMENT_IS_PTHREAD) { #endif #endif -#if ENVIRONMENT_MAY_BE_NODE && (EXPORT_ES6 || PTHREADS || WASM_WORKERS) +#if ENVIRONMENT_MAY_BE_NODE && (PTHREADS || WASM_WORKERS) if (ENVIRONMENT_IS_NODE) { -#if EXPORT_ES6 - // When building an ES module `require` is not normally available. - // We need to use `createRequire()` to construct the require()` function. - const { createRequire } = await import('node:module'); - /** @suppress{duplicate} */ - var require = createRequire(import.meta.url); -#endif - -#if PTHREADS || WASM_WORKERS - var worker_threads = require('node:worker_threads'); + var worker_threads = {{{ makeNodeImport('node:worker_threads', false) }}}; global.Worker = worker_threads.Worker; ENVIRONMENT_IS_WORKER = !worker_threads.isMainThread; #if PTHREADS @@ -128,7 +119,6 @@ if (ENVIRONMENT_IS_NODE) { #if WASM_WORKERS ENVIRONMENT_IS_WASM_WORKER = ENVIRONMENT_IS_WORKER && worker_threads.workerData == 'em-ww' #endif -#endif // PTHREADS || WASM_WORKERS } #endif // ENVIRONMENT_MAY_BE_NODE @@ -199,11 +189,13 @@ if (ENVIRONMENT_IS_NODE) { // These modules will usually be used on Node.js. Load them eagerly to avoid // the complexity of lazy-loading. - var fs = require('node:fs'); + var fs = {{{ makeNodeImport('node:fs', false) }}}; #if EXPORT_ES6 if (_scriptName.startsWith('file:')) { - scriptDirectory = require('node:path').dirname(require('node:url').fileURLToPath(_scriptName)) + '/'; + var nodePath = {{{ makeNodeImport('node:path', false) }}}; + var nodeUrl = {{{ makeNodeImport('node:url', false) }}}; + scriptDirectory = nodePath.dirname(nodeUrl.fileURLToPath(_scriptName)) + '/'; } #else scriptDirectory = __dirname + '/'; @@ -351,7 +343,7 @@ if (!ENVIRONMENT_IS_AUDIO_WORKLET) var defaultPrint = console.log.bind(console); var defaultPrintErr = console.error.bind(console); if (ENVIRONMENT_IS_NODE) { - var utils = require('node:util'); + var utils = {{{ makeNodeImport('node:util', false) }}}; var stringify = (a) => typeof a == 'object' ? utils.inspect(a) : a; defaultPrint = (...args) => fs.writeSync(1, args.map(stringify).join(' ') + '\n'); defaultPrintErr = (...args) => fs.writeSync(2, args.map(stringify).join(' ') + '\n'); diff --git a/src/shell_minimal.js b/src/shell_minimal.js index a291c8130ab2a..d6aca3a8e0fa8 100644 --- a/src/shell_minimal.js +++ b/src/shell_minimal.js @@ -55,7 +55,7 @@ var ENVIRONMENT_IS_WEB = !ENVIRONMENT_IS_NODE; #if ENVIRONMENT_MAY_BE_NODE && (PTHREADS || WASM_WORKERS) if (ENVIRONMENT_IS_NODE) { - var worker_threads = require('node:worker_threads'); + var worker_threads = {{{ makeNodeImport('node:worker_threads', false) }}}; global.Worker = worker_threads.Worker; } #endif @@ -99,7 +99,7 @@ if (ENVIRONMENT_IS_NODE && ENVIRONMENT_IS_SHELL) { var defaultPrint = console.log.bind(console); var defaultPrintErr = console.error.bind(console); if (ENVIRONMENT_IS_NODE) { - var fs = require('node:fs'); + var fs = {{{ makeNodeImport('node:fs', false) }}}; defaultPrint = (...args) => fs.writeSync(1, args.join(' ') + '\n'); defaultPrintErr = (...args) => fs.writeSync(2, args.join(' ') + '\n'); } @@ -181,13 +181,13 @@ if (!ENVIRONMENT_IS_PTHREAD) { // Wasm or Wasm2JS loading: if (ENVIRONMENT_IS_NODE) { - var fs = require('node:fs'); + var fs = {{{ makeNodeImport('node:fs', false) }}}; #if WASM == 2 - if (globalThis.WebAssembly) Module['wasm'] = fs.readFileSync(__dirname + '/{{{ TARGET_BASENAME }}}.wasm'); - else eval(fs.readFileSync(__dirname + '/{{{ TARGET_BASENAME }}}.wasm.js')+''); + if (globalThis.WebAssembly) Module['wasm'] = fs.readFileSync({{{ makeNodeFilePath(TARGET_BASENAME + '.wasm') }}}); + else eval(fs.readFileSync({{{ makeNodeFilePath(TARGET_BASENAME + '.wasm.js') }}})+''); #else #if !WASM2JS - Module['wasm'] = fs.readFileSync(__dirname + '/{{{ TARGET_BASENAME }}}.wasm'); + Module['wasm'] = fs.readFileSync({{{ makeNodeFilePath(TARGET_BASENAME + '.wasm') }}}); #endif #endif } From 160122bb8841960da57f82deff9b8b839195fd96 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Tue, 3 Mar 2026 22:38:19 +0000 Subject: [PATCH 2/9] Replace require() with library symbols in EXPORT_ES6 library files Library functions run in synchronous context where await is unavailable. Define top-level library symbols that use await import() at module init time, then reference them via __deps from synchronous functions. - Add libnode_imports.js with shared $nodeOs symbol, register in modules.mjs when EXPORT_ES6 is enabled. - libatomic.js, libwasm_worker.js: Use $nodeOs for os.cpus().length instead of require('node:os'). - libwasi.js: Define $nodeCrypto for crypto.randomFillSync in $initRandomFill. Combine conditional __deps to avoid override. - libcore.js: Define $nodeChildProcess for _emscripten_system. - libnodepath.js: Switch $nodePath initializer to await import(). - libsockfs.js: Define $nodeWs ((await import('ws')).default) for WebSocket constructor in connect() and Server in listen(). --- src/lib/libatomic.js | 5 ++++- src/lib/libcore.js | 5 +++++ src/lib/libnodepath.js | 2 +- src/lib/libsockfs.js | 18 +++++++++++++++++- src/lib/libwasi.js | 11 +++++++++-- src/lib/libwasm_worker.js | 5 ++++- 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/lib/libatomic.js b/src/lib/libatomic.js index c30dff83323cd..576be4fe4ecbd 100644 --- a/src/lib/libatomic.js +++ b/src/lib/libatomic.js @@ -154,9 +154,12 @@ addToLibrary({ emscripten_has_threading_support: () => !!globalThis.SharedArrayBuffer, +#if ENVIRONMENT_MAY_BE_NODE + emscripten_num_logical_cores__deps: ['$nodeOs'], +#endif emscripten_num_logical_cores: () => #if ENVIRONMENT_MAY_BE_NODE - ENVIRONMENT_IS_NODE ? require('node:os').cpus().length : + ENVIRONMENT_IS_NODE ? nodeOs.cpus().length : #endif navigator['hardwareConcurrency'], diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 36a0fd1542f0f..30fd643e57d39 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -372,6 +372,11 @@ addToLibrary({ }, #endif +#if ENVIRONMENT_MAY_BE_NODE + $nodeOs: "{{{ makeNodeImport('node:os') }}}", + $nodeChildProcess: "{{{ makeNodeImport('node:child_process') }}}", + _emscripten_system__deps: ['$nodeChildProcess'], +#endif _emscripten_system: (command) => { #if ENVIRONMENT_MAY_BE_NODE if (ENVIRONMENT_IS_NODE) { diff --git a/src/lib/libnodepath.js b/src/lib/libnodepath.js index d891bf7339662..cd91a2a0cbaa1 100644 --- a/src/lib/libnodepath.js +++ b/src/lib/libnodepath.js @@ -12,7 +12,7 @@ // operations. Hence, using `nodePath` should be safe here. addToLibrary({ - $nodePath: "require('node:path')", + $nodePath: "{{{ makeNodeImport('node:path', false) }}}", $PATH__deps: ['$nodePath'], $PATH: `{ isAbs: nodePath.isAbsolute, diff --git a/src/lib/libsockfs.js b/src/lib/libsockfs.js index 01d6f831da2bf..15f27843d2519 100644 --- a/src/lib/libsockfs.js +++ b/src/lib/libsockfs.js @@ -5,10 +5,18 @@ */ addToLibrary({ +#if ENVIRONMENT_MAY_BE_NODE && EXPORT_ES6 + // In ESM mode, require() is not natively available. When SOCKFS is used, + // we need require() to lazily load the 'ws' npm package for WebSocket + // support on Node.js. Set up a createRequire-based polyfill. + $nodeRequire: `ENVIRONMENT_IS_NODE ? (await import('node:module')).createRequire(import.meta.url) : undefined`, + $SOCKFS__deps: ['$FS', '$nodeRequire'], +#else + $SOCKFS__deps: ['$FS'], +#endif $SOCKFS__postset: () => { addAtInit('SOCKFS.root = FS.mount(SOCKFS, {}, null);'); }, - $SOCKFS__deps: ['$FS'], $SOCKFS: { #if expectToReceiveOnModule('websocket') websocketArgs: {}, @@ -216,7 +224,11 @@ addToLibrary({ var WebSocketConstructor; #if ENVIRONMENT_MAY_BE_NODE if (ENVIRONMENT_IS_NODE) { +#if EXPORT_ES6 + WebSocketConstructor = /** @type{(typeof WebSocket)} */(nodeRequire('ws')); +#else WebSocketConstructor = /** @type{(typeof WebSocket)} */(require('ws')); +#endif } else #endif // ENVIRONMENT_MAY_BE_NODE { @@ -522,7 +534,11 @@ addToLibrary({ if (sock.server) { throw new FS.ErrnoError({{{ cDefs.EINVAL }}}); // already listening } +#if EXPORT_ES6 + var WebSocketServer = nodeRequire('ws').Server; +#else var WebSocketServer = require('ws').Server; +#endif var host = sock.saddr; #if SOCKET_DEBUG dbg(`websocket: listen: ${host}:${sock.sport}`); diff --git a/src/lib/libwasi.js b/src/lib/libwasi.js index bb67581f269a6..7a2f68a2903c0 100644 --- a/src/lib/libwasi.js +++ b/src/lib/libwasi.js @@ -569,14 +569,21 @@ var WasiLibrary = { // random.h -#if ENVIRONMENT_MAY_BE_SHELL +#if ENVIRONMENT_MAY_BE_NODE && MIN_NODE_VERSION < 190000 + $nodeCrypto: "{{{ makeNodeImport('node:crypto') }}}", +#endif + +#if ENVIRONMENT_MAY_BE_SHELL && ENVIRONMENT_MAY_BE_NODE && MIN_NODE_VERSION < 190000 + $initRandomFill__deps: ['$base64Decode', '$nodeCrypto'], +#elif ENVIRONMENT_MAY_BE_SHELL $initRandomFill__deps: ['$base64Decode'], +#elif ENVIRONMENT_MAY_BE_NODE && MIN_NODE_VERSION < 190000 + $initRandomFill__deps: ['$nodeCrypto'], #endif $initRandomFill: () => { #if ENVIRONMENT_MAY_BE_NODE && MIN_NODE_VERSION < 190000 // This block is not needed on v19+ since crypto.getRandomValues is builtin if (ENVIRONMENT_IS_NODE) { - var nodeCrypto = require('node:crypto'); return (view) => nodeCrypto.randomFillSync(view); } #endif // ENVIRONMENT_MAY_BE_NODE diff --git a/src/lib/libwasm_worker.js b/src/lib/libwasm_worker.js index e4eb8491389c1..8f131f8c7e10f 100644 --- a/src/lib/libwasm_worker.js +++ b/src/lib/libwasm_worker.js @@ -288,9 +288,12 @@ if (ENVIRONMENT_IS_WASM_WORKER _wasmWorkers[id].postMessage({'_wsc': funcPtr, 'x': readEmAsmArgs(sigPtr, varargs) }); }, +#if ENVIRONMENT_MAY_BE_NODE + emscripten_navigator_hardware_concurrency__deps: ['$nodeOs'], +#endif emscripten_navigator_hardware_concurrency: () => { #if ENVIRONMENT_MAY_BE_NODE - if (ENVIRONMENT_IS_NODE) return require('node:os').cpus().length; + if (ENVIRONMENT_IS_NODE) return nodeOs.cpus().length; #endif return navigator['hardwareConcurrency']; }, From a47702b13693088bbfb6c7913294d91aa52075e9 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Mon, 9 Mar 2026 07:22:48 +0000 Subject: [PATCH 3/9] Add test verifying EXPORT_ES6 output contains no require() calls Bundlers (webpack, rollup, vite, esbuild) and frameworks (Next.js, Nuxt) cannot resolve CommonJS require() calls inside ES modules. This test statically verifies that EXPORT_ES6 output uses `await import()` instead of `require()` for Node.js built-in modules, and that the `createRequire` polyfill pattern is not present. Parameterized for default, node-only, and pthreads configurations to cover the various code paths that import Node.js built-ins (fs, path, url, util, worker_threads). --- test/test_other.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/test_other.py b/test/test_other.py index 936dff4783e1c..912e146fe36fa 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -466,6 +466,31 @@ def test_esm_implies_modularize(self): def test_esm_requires_modularize(self): self.assert_fail([EMCC, test_file('hello_world.c'), '-sEXPORT_ES6', '-sMODULARIZE=0'], 'EXPORT_ES6 requires MODULARIZE to be set') + # Verify that EXPORT_ES6 output uses `await import()` instead of `require()` + # for Node.js built-in modules. Using `require()` in ESM files breaks + # bundlers (webpack, rollup, vite, esbuild) which cannot resolve CommonJS + # require() calls inside ES modules. + @crossplatform + @parameterized({ + 'default': ([],), + 'node': (['-sENVIRONMENT=node'],), + 'pthreads': (['-pthread', '-sPTHREAD_POOL_SIZE=1'],), + }) + def test_esm_no_require(self, args): + self.run_process([EMCC, '-o', 'hello_world.mjs', + '--extern-post-js', test_file('modularize_post_js.js'), + test_file('hello_world.c')] + args) + src = read_file('hello_world.mjs') + # EXPORT_ES6 output must not contain require() calls as these are + # incompatible with ES modules and break bundlers. + # The only acceptable require-like pattern is inside a string/comment. + require_calls = re.findall(r'(? Date: Mon, 9 Mar 2026 19:41:08 +0000 Subject: [PATCH 4/9] Add bundler integration tests verifying EXPORT_ES6 output has no require() Add two tests that verify EXPORT_ES6 output is valid ESM and works with bundlers: - test_webpack_esm_output_clean: Compiles with EXPORT_ES6 and default environment (web+node), then builds with webpack. On main, webpack hard-fails because it cannot resolve 'node:module' (used by emscripten's createRequire polyfill). This breaks any webpack/Next.js/Nuxt project. - test_vite_esm_output_clean: Compiles with EXPORT_ES6 and default environment, then builds with vite. On main, vite externalizes 'node:module' for browser compatibility, emitting a warning. The resulting bundle contains code referencing unavailable node modules. These tests are expected to fail on main and pass after eliminating require() from EXPORT_ES6 output. --- test/test_other.py | 35 +++++++++++++++++++++++++++++++++++ test/vite/vite.config.js | 19 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/test/test_other.py b/test/test_other.py index 912e146fe36fa..31805cd02ffac 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -15035,6 +15035,41 @@ def test_rollup(self): shutil.copy('hello.wasm', 'dist/') self.assertContained('Hello, world!', self.run_js('dist/bundle.mjs')) + @crossplatform + @requires_dev_dependency('webpack') + def test_webpack_esm_output_clean(self): + """Verify webpack can build EXPORT_ES6 output without errors. + + When emscripten generates require() in EXPORT_ES6 output (via + createRequire from 'node:module'), webpack fails with: + UnhandledSchemeError: Reading from "node:module" is not handled by plugins + This breaks any webpack/Next.js/Nuxt project using emscripten's ESM output. + """ + copytree(test_file('webpack_es6'), '.') + # ESM output is implied by the .mjs extension (EXPORT_ES6 + MODULARIZE). + # On main, this generates require() calls for node support, which + # webpack cannot resolve for web targets. + self.run_process([EMCC, test_file('hello_world.c'), '-o', 'src/hello.mjs']) + self.run_process(shared.get_npm_cmd('webpack') + ['--mode=development', '--no-devtool']) + + @crossplatform + @requires_dev_dependency('vite') + def test_vite_esm_output_clean(self): + """Verify vite bundles EXPORT_ES6 output without require() or externalizing. + + When emscripten generates require() in EXPORT_ES6 output, vite externalizes + the node modules for browser compatibility, emitting a warning. The resulting + bundle contains code that references modules unavailable in browsers. + """ + copytree(test_file('vite'), '.') + # ESM output is implied by the .mjs extension (EXPORT_ES6 + MODULARIZE). + # On main, this generates require() calls for node support which vite + # externalizes but leaves as require() in the bundle output. + self.run_process([EMCC, test_file('hello_world.c'), '-o', 'hello.mjs']) + # vite.config.js turns externalization warnings into errors, so vite + # will fail with non-zero exit code if require() appears in ESM output. + self.run_process(shared.get_npm_cmd('vite') + ['build']) + def test_rlimit(self): self.do_other_test('test_rlimit.c', cflags=['-O1']) diff --git a/test/vite/vite.config.js b/test/vite/vite.config.js index 019c96058a246..0f574d726c9d3 100644 --- a/test/vite/vite.config.js +++ b/test/vite/vite.config.js @@ -1,3 +1,22 @@ export default { base: './', + build: { + rollupOptions: { + onwarn(warning, defaultHandler) { + // Vite externalizes node built-in imports (node:fs, etc.) for browser + // compatibility. This is expected for dynamic import() calls guarded + // by ENVIRONMENT_IS_NODE. However, require() calls in ESM output are + // truly broken — vite cannot handle them. Detect require()-based + // externalization by checking for imports that don't use the node: scheme. + if (warning.message && warning.message.includes('externalized for browser compatibility')) { + // Accept node: scheme imports (dynamic import with bundler hints) + var match = warning.message.match(/Module "([^"]+)"/); + if (match && !match[1].startsWith('node:')) { + throw new Error(warning.message); + } + } + defaultHandler(warning); + }, + }, + }, } From 187d6485f9be404631e23f8dd55ac4bd4bdc64f4 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Fri, 20 Mar 2026 10:44:32 +0000 Subject: [PATCH 5/9] Fix dbg() to use lazy-initialized node modules for ESM compatibility The previous fix moved await import() for node:fs and node:util outside dbg() to module scope. This broke test_dbg because dbg() can be called from --pre-js before those module-scope imports execute. Use lazy initialization instead: declare dbg_node_fs/dbg_node_utils early but leave them undefined. Initialize them in shell.js after fs and utils are loaded (reusing the same imports). dbg() checks if the modules are available and falls back to console.warn if not. This handles all cases: - dbg() from --pre-js (before init): uses console.warn - dbg() after init on Node.js with pthreads: uses fs.writeSync - dbg() in browser/non-node: uses console.warn --- src/runtime_debug.js | 16 +++++----------- src/shell.js | 7 +++++++ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/runtime_debug.js b/src/runtime_debug.js index 112004ca525e0..17b8a8de9addd 100644 --- a/src/runtime_debug.js +++ b/src/runtime_debug.js @@ -9,29 +9,23 @@ var runtimeDebug = true; // Switch to false at runtime to disable logging at the // Used by XXXXX_DEBUG settings to output debug messages. #if ENVIRONMENT_MAY_BE_NODE && (PTHREADS || WASM_WORKERS) -// Pre-load debug modules for use in dbg(). These are loaded at module scope -// (inside async function Module()) where await is valid, since dbg() itself -// is a regular function where await cannot be used. -var dbg_fs, dbg_utils; -if (ENVIRONMENT_IS_NODE) { - dbg_fs = {{{ makeNodeImport('node:fs', false) }}}; - dbg_utils = {{{ makeNodeImport('node:util', false) }}}; -} +// dbg_node_fs and dbg_node_utils are declared and initialized in shell.js +// when node modules (fs/utils) become available. #endif function dbg(...args) { if (!runtimeDebug && typeof runtimeDebug != 'undefined') return; #if ENVIRONMENT_MAY_BE_NODE && (PTHREADS || WASM_WORKERS) // Avoid using the console for debugging in multi-threaded node applications // See https://github.com/emscripten-core/emscripten/issues/14804 - if (ENVIRONMENT_IS_NODE) { + if (ENVIRONMENT_IS_NODE && dbg_node_fs) { function stringify(a) { switch (typeof a) { - case 'object': return dbg_utils.inspect(a); + case 'object': return dbg_node_utils.inspect(a); case 'undefined': return 'undefined'; } return a; } - dbg_fs.writeSync(2, args.map(stringify).join(' ') + '\n'); + dbg_node_fs.writeSync(2, args.map(stringify).join(' ') + '\n'); } else #endif // TODO(sbc): Make this configurable somehow. Its not always convenient for diff --git a/src/shell.js b/src/shell.js index c59ab51a75382..236807d9ecf5b 100644 --- a/src/shell.js +++ b/src/shell.js @@ -347,6 +347,13 @@ if (ENVIRONMENT_IS_NODE) { var stringify = (a) => typeof a == 'object' ? utils.inspect(a) : a; defaultPrint = (...args) => fs.writeSync(1, args.map(stringify).join(' ') + '\n'); defaultPrintErr = (...args) => fs.writeSync(2, args.map(stringify).join(' ') + '\n'); +#if (ASSERTIONS || RUNTIME_DEBUG || AUTODEBUG) && (PTHREADS || WASM_WORKERS) + // Initialize the lazy-loaded node modules for dbg() now that fs/utils are + // available. Declared here (before runtime_debug.js) to avoid Closure + // Compiler's JSC_REFERENCE_BEFORE_DECLARE warning. + var dbg_node_fs = fs; + var dbg_node_utils = utils; +#endif } {{{ makeModuleReceiveWithVar('out', 'print', 'defaultPrint') }}} {{{ makeModuleReceiveWithVar('err', 'printErr', 'defaultPrintErr') }}} From d612f3f4dff9411bacb6ec87dfa6be480d7aa011 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Fri, 20 Mar 2026 11:47:50 +0000 Subject: [PATCH 6/9] Fix test_environment assertion to handle ESM dynamic imports In ESM mode (WASM_ESM_INTEGRATION), the runtime uses dynamic import() instead of require() for node modules. Update the test_environment assertion to check for 'import(' when in ESM mode, rather than always expecting 'require('. --- test/test_core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/test_core.py b/test/test_core.py index 7f2766fb1464e..0adcc8b51da53 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -8656,7 +8656,12 @@ def test(assert_returncode=0): js = read_file(self.output_name('test_hello_world.support')) else: js = read_file(self.output_name('test_hello_world')) - assert ('require(' in js) == ('node' in self.get_setting('ENVIRONMENT')), 'we should have require() calls only if node js specified' + # In ESM mode, we use dynamic import() instead of require() for node modules + if self.get_setting('WASM_ESM_INTEGRATION'): + has_node_imports = 'import(' in js + else: + has_node_imports = 'require(' in js + assert has_node_imports == ('node' in self.get_setting('ENVIRONMENT')), 'we should have node imports only if node js specified' for engine in self.js_engines: print(f'engine: {engine}') From 1efd4945b0466f6bc1d1ccd91f2baee4be13eec4 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Mon, 23 Mar 2026 08:32:18 +0000 Subject: [PATCH 7/9] Fix test_locate_file_abspath_esm to use dynamic import for path module The test was using require('path') which doesn't work in ESM mode. Since ESM output (.mjs) wraps the module in an async context, we can use top-level await with dynamic import. Changed from: require('path')['isAbsolute'](scriptDirectory) To: var nodePath = await import('node:path'); nodePath.isAbsolute(scriptDirectory) This properly tests the Node.js path.isAbsolute() function while being compatible with ESM module format. The CJS variant (test_locate_file_abspath) continues to use require() as appropriate for CommonJS. --- test/test_other.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/test_other.py b/test/test_other.py index 31805cd02ffac..29b027e21453a 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -15123,9 +15123,11 @@ def test_locate_file_abspath(self, args): }) def test_locate_file_abspath_esm(self, args): # Verify that `scriptDirectory` is an absolute path when `EXPORT_ES6` + # Use dynamic import for path module since ESM output supports top-level await create_file('pre.js', ''' + var nodePath = await import('node:path'); Module['locateFile'] = (fileName, scriptDirectory) => { - assert(require('path')['isAbsolute'](scriptDirectory), `scriptDirectory (${scriptDirectory}) should be an absolute path`); + assert(nodePath.isAbsolute(scriptDirectory), `scriptDirectory (${scriptDirectory}) should be an absolute path`); return scriptDirectory + fileName; }; ''') From d648fff3f9e1a885bf7c540e7a5a38c2c11624f3 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Mon, 23 Mar 2026 08:43:25 +0000 Subject: [PATCH 8/9] Add Closure compiler support for ESM dynamic imports Closure compiler runs before the MODULARIZE async wrapper is applied, so it sees `await import('node:xyz')` outside an async function and fails to parse it. Work around this by: 1. Before Closure: Replace `await import('node:xyz')` with placeholder variables like `__EMSCRIPTEN_PRIVATE_AWAIT_IMPORT_xyz__` 2. Generate externs for the placeholders so Closure doesn't error on undeclared variables 3. After Closure: Restore placeholders to `(await import('node:xyz'))` with parentheses to handle cases where Closure inlines the variable into expressions like `placeholder.method()` This follows the same pattern as the existing `__EMSCRIPTEN_PRIVATE_MODULE_EXPORT_NAME_SUBSTITUTION__` mechanism. --- tools/link.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tools/link.py b/tools/link.py index 69dfa198b93f3..785fb25698081 100644 --- a/tools/link.py +++ b/tools/link.py @@ -2322,7 +2322,43 @@ def phase_binaryen(target, options, wasm_target): if options.use_closure_compiler: with ToolchainProfiler.profile_block('closure_compile'): - final_js = building.closure_compiler(final_js, extra_closure_args=settings.CLOSURE_ARGS) + # In EXPORT_ES6 mode, we use `await import('node:xyz')` for dynamic imports. + # Closure compiler runs before the MODULARIZE async wrapper is applied, so it + # sees `await` outside an async function and fails to parse it. + # Work around this by replacing dynamic imports with placeholders before Closure, + # then restoring them after. + closure_args = list(settings.CLOSURE_ARGS) if settings.CLOSURE_ARGS else [] + if settings.EXPORT_ES6: + src = utils.read_file(final_js) + # Find all node modules being dynamically imported (with optional bundler hints) + node_modules = set(re.findall(r"await\s+import\(\s*(?:/\*.*?\*/\s*)*['\"]node:(\w+)['\"]\s*\)", src)) + if node_modules: + # Replace: await import(/* ... */ 'node:fs') -> __EMSCRIPTEN_PRIVATE_AWAIT_IMPORT_fs__ + src = re.sub(r"await\s+import\(\s*(?:/\*.*?\*/\s*)*['\"]node:(\w+)['\"]\s*\)", + r'__EMSCRIPTEN_PRIVATE_AWAIT_IMPORT_\1__', src) + utils.write_file(final_js, src) + save_intermediate('closure_pre') + # Generate externs for the placeholder variables so Closure doesn't complain + externs_content = '\n'.join( + f'/** @type {{?}} */ var __EMSCRIPTEN_PRIVATE_AWAIT_IMPORT_{mod}__;' + for mod in node_modules + ) + externs_file = shared.get_temp_files().get('.js', prefix='node_import_externs_') + externs_file.write(externs_content.encode()) + externs_file.close() + closure_args += ['--externs', externs_file.name] + + final_js = building.closure_compiler(final_js, extra_closure_args=closure_args) + + if settings.EXPORT_ES6: + src = utils.read_file(final_js) + # Restore: __EMSCRIPTEN_PRIVATE_AWAIT_IMPORT_fs__ -> (await import(/* hints */ 'node:fs')) + # Parentheses are needed because Closure may inline the placeholder into + # expressions like `placeholder.method()` which needs `(await import(...)).method()` + src = re.sub(r'__EMSCRIPTEN_PRIVATE_AWAIT_IMPORT_(\w+)__', + r"(await import(/* webpackIgnore: true */ /* @vite-ignore */ 'node:\1'))", src) + utils.write_file(final_js, src) + save_intermediate('closure') if settings.TRANSPILE: From c1332e06158e19b2b9cd4814274340d176ea9992 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Mon, 23 Mar 2026 10:37:51 +0000 Subject: [PATCH 9/9] Add require() polyfill for EM_ASM tests in ESM modes Instead of skipping EM_ASM tests that use CJS require() in ESM modes, add a createRequire-based polyfill (available since Node 12.2.0) that makes require() available in ESM output. The polyfill is only included when the build targets ESM (EXPORT_ES6, MODULARIZE=instance, or WASM_ESM_INTEGRATION). - Add test/require_polyfill.js using createRequire from 'module' - Add is_esm() and add_require_polyfill() helpers to test/common.py - Remove @no_modularize_instance skips from test_fs_nodefs_rw and test_fs_nodefs_home, enabling them in ESM test modes --- test/common.py | 9 +++++++++ test/require_polyfill.js | 7 +++++++ test/test_core.py | 9 +++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 test/require_polyfill.js diff --git a/test/common.py b/test/common.py index a40ac40ae0121..52b6d3edf1a2f 100644 --- a/test/common.py +++ b/test/common.py @@ -626,12 +626,21 @@ def require_wasm2js(self): if self.get_setting('WASM_ESM_INTEGRATION'): self.skipTest('wasm2js is not compatible with WASM_ESM_INTEGRATION') + def is_esm(self): + return self.get_setting('EXPORT_ES6') or self.get_setting('WASM_ESM_INTEGRATION') or self.get_setting('MODULARIZE') == 'instance' + + def add_require_polyfill(self): + """Add a require() polyfill for ESM mode using createRequire (Node 12.2+).""" + if self.is_esm(): + self.cflags += ['--pre-js', test_file('require_polyfill.js')] + def setup_nodefs_test(self): self.require_node() if self.get_setting('WASMFS'): # without this the JS setup code in setup_nodefs.js doesn't work self.set_setting('FORCE_FILESYSTEM') self.cflags += ['-DNODEFS', '-lnodefs.js', '--pre-js', test_file('setup_nodefs.js'), '-sINCOMING_MODULE_JS_API=onRuntimeInitialized'] + self.add_require_polyfill() def setup_noderawfs_test(self): self.require_node() diff --git a/test/require_polyfill.js b/test/require_polyfill.js new file mode 100644 index 0000000000000..a60e8df779520 --- /dev/null +++ b/test/require_polyfill.js @@ -0,0 +1,7 @@ +// Polyfill require() for ESM mode so that EM_ASM/EM_JS code using +// require('fs'), require('path'), etc. works in both CJS and ESM. +// createRequire is available since Node 12.2.0. +if (typeof require === 'undefined') { + var { createRequire } = await import('module'); + var require = createRequire(import.meta.url); +} diff --git a/test/test_core.py b/test/test_core.py index 0adcc8b51da53..2c4f6794a547d 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -5823,6 +5823,8 @@ def test_fs_base(self): def test_fs_nodefs_rw(self): if not self.get_setting('NODERAWFS'): self.setup_nodefs_test() + else: + self.add_require_polyfill() self.maybe_closure() self.do_runf('fs/test_nodefs_rw.c', 'success') @@ -5846,6 +5848,7 @@ def test_fs_nodefs_dup(self): @requires_node def test_fs_nodefs_home(self): + self.add_require_polyfill() self.do_runf('fs/test_nodefs_home.c', 'success', cflags=['-sFORCE_FILESYSTEM', '-lnodefs.js']) @requires_node @@ -8656,8 +8659,10 @@ def test(assert_returncode=0): js = read_file(self.output_name('test_hello_world.support')) else: js = read_file(self.output_name('test_hello_world')) - # In ESM mode, we use dynamic import() instead of require() for node modules - if self.get_setting('WASM_ESM_INTEGRATION'): + # In ESM mode we use dynamic import() instead of require() for node modules. + # MODULARIZE=instance implies EXPORT_ES6 which triggers ESM output. + is_esm = self.get_setting('EXPORT_ES6') or self.get_setting('WASM_ESM_INTEGRATION') or self.get_setting('MODULARIZE') == 'instance' + if is_esm: has_node_imports = 'import(' in js else: has_node_imports = 'require(' in js