From 93ed310778ce51cb36dfafb56d43d9e8a0f0cf16 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Tue, 3 Mar 2026 22:38:08 +0000 Subject: [PATCH 01/12] 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 39b581db8a074..fe1d2317d818a 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 b1560c6dd9d42..bbdd3adcd7273 100644 --- a/src/parseTools.mjs +++ b/src/parseTools.mjs @@ -953,6 +953,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(); @@ -1240,6 +1261,8 @@ addToCompileTimeContext({ makeModuleReceive, makeModuleReceiveExpr, makeModuleReceiveWithVar, + makeNodeFilePath, + makeNodeImport, makeRemovedFSAssert, makeRetainedCompilerSettings, makeReturn64, diff --git a/src/preamble.js b/src/preamble.js index ab3a7d78b8806..5f1797ef22851 100644 --- a/src/preamble.js +++ b/src/preamble.js @@ -549,7 +549,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 4752179a8d188..662781b9be475 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 4a45eff85b4f3..a5f48abab841a 100644 --- a/src/shell_minimal.js +++ b/src/shell_minimal.js @@ -59,7 +59,7 @@ var ENVIRONMENT_IS_WORKER = !!globalThis.WorkerGlobalScope; #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; ENVIRONMENT_IS_WORKER = !worker_threads.isMainThread; } @@ -104,7 +104,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'); } @@ -182,13 +182,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 eb718f52a3bb7be2fcc83df855deb986571b9b40 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Tue, 3 Mar 2026 22:38:19 +0000 Subject: [PATCH 02/12] 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 fe1d2317d818a..33470d582f00e 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 d8657a9dc2052..73b0b6aafc550 100644 --- a/src/lib/libwasi.js +++ b/src/lib/libwasi.js @@ -570,14 +570,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 b46b6897c7d39923d8cbcb63380d5a9ba7720fa8 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Mon, 9 Mar 2026 07:22:48 +0000 Subject: [PATCH 03/12] 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 f5fab92327817..894e2af55c810 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 04/12] 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 894e2af55c810..e0437d0af0643 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -15078,6 +15078,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 f673388a0ba1a09d994be0967a11465ef57860a7 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Fri, 20 Mar 2026 10:44:32 +0000 Subject: [PATCH 05/12] 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 662781b9be475..bd8e73e18506d 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 0a83a9882bd517b72c29b1d4a7d241025f9e4428 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Fri, 20 Mar 2026 11:47:50 +0000 Subject: [PATCH 06/12] 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 c3f4e7fa1ed17..70d5f5831130d 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -8662,7 +8662,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 a7717aa6e62bbc9ce9ca849ae42a0d2816273845 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Mon, 23 Mar 2026 08:32:18 +0000 Subject: [PATCH 07/12] 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 e0437d0af0643..129182219905b 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -15166,9 +15166,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 145c96190c9bc18255a35e1b237f3b618426bbde Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Mon, 23 Mar 2026 08:43:25 +0000 Subject: [PATCH 08/12] 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 eb850b597b87e..e7606c9e48c57 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 581bbaa5506217db2d8ede899cd02d52ae7e58f5 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Mon, 23 Mar 2026 10:37:51 +0000 Subject: [PATCH 09/12] 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 33f538c8c7829..096b492596537 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 70d5f5831130d..145719e70d22c 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -5825,6 +5825,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') @@ -5848,6 +5850,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 @@ -8662,8 +8665,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 From 2cd89c34d189b88ac019d1e6e793fd1c4a9aa07c Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Wed, 1 Apr 2026 19:06:41 +0000 Subject: [PATCH 10/12] Fix Babel transpile for EXPORT_ES6 by using sourceType module When EXPORT_ES6 is enabled, makeNodeImport() generates top-level await import() expressions. Babel's transpile step was configured with sourceType 'script', which cannot parse top-level await. This caused test_node_prefix_transpile to fail at compilation. Use sourceType 'module' when EXPORT_ES6 is active so Babel can correctly parse and transpile the ESM output. --- tools/building.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/building.py b/tools/building.py index bc09df6d26012..0f0d3a4444184 100644 --- a/tools/building.py +++ b/tools/building.py @@ -541,7 +541,7 @@ def version_split(v): @ToolchainProfiler.profile() def transpile(filename): config = { - 'sourceType': 'script', + 'sourceType': 'module' if settings.EXPORT_ES6 else 'script', 'presets': ['@babel/preset-env'], 'plugins': [], 'targets': {}, From d68b648e8f198c3135d9503da91a6a9369ef5367 Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Wed, 1 Apr 2026 19:06:46 +0000 Subject: [PATCH 11/12] Add dbg_node_fs/dbg_node_utils declarations to shell_minimal.js runtime_debug.js references dbg_node_fs and dbg_node_utils when ENVIRONMENT_MAY_BE_NODE with PTHREADS or WASM_WORKERS, but these variables were only declared in shell.js, not in shell_minimal.js. This caused Closure compiler to fail with JSC_UNDEFINED_VARIABLE for MINIMAL_RUNTIME builds with threading and --closure=1. Add the declarations for both paths: - PTHREADS: inside the existing if(ENVIRONMENT_IS_NODE) block that already imports fs - WASM_WORKERS without PTHREADS: in a new conditional block --- src/shell_minimal.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/shell_minimal.js b/src/shell_minimal.js index a5f48abab841a..82117f3338633 100644 --- a/src/shell_minimal.js +++ b/src/shell_minimal.js @@ -107,6 +107,11 @@ if (ENVIRONMENT_IS_NODE) { var fs = {{{ makeNodeImport('node:fs', false) }}}; defaultPrint = (...args) => fs.writeSync(1, args.join(' ') + '\n'); defaultPrintErr = (...args) => fs.writeSync(2, args.join(' ') + '\n'); +#if (ASSERTIONS || RUNTIME_DEBUG || AUTODEBUG) + var utils = {{{ makeNodeImport('node:util', false) }}}; + var dbg_node_fs = fs; + var dbg_node_utils = utils; +#endif } var out = defaultPrint; var err = defaultPrintErr; @@ -115,6 +120,16 @@ var out = (...args) => console.log(...args); var err = (...args) => console.error(...args); #endif +#if !PTHREADS && WASM_WORKERS && ENVIRONMENT_MAY_BE_NODE && (ASSERTIONS || RUNTIME_DEBUG || AUTODEBUG) +// Initialize dbg() node module references for WASM_WORKERS without PTHREADS. +// (With PTHREADS these are set in the print setup block above.) +var dbg_node_fs, dbg_node_utils; +if (ENVIRONMENT_IS_NODE) { + dbg_node_fs = {{{ makeNodeImport('node:fs', false) }}}; + dbg_node_utils = {{{ makeNodeImport('node:util', false) }}}; +} +#endif + // Override this function in a --pre-js file to get a signal for when // compilation is ready. In that callback, call the function run() to start // the program. From 12363770fdc5f6d97dd19364e0f82c52f5a1545c Mon Sep 17 00:00:00 2001 From: Bartek Wrona Date: Wed, 1 Apr 2026 21:29:04 +0000 Subject: [PATCH 12/12] Replace require('node:tty') with library symbol in libnoderawfs.js NODERAWFS postset used require('node:tty') directly, which breaks in ESM output (.mjs) where require is not available. Extract it into a $nodeTTY library symbol using makeNodeImport, matching the pattern used by $nodePath and other node module imports. This fixes all _rawfs test failures in test-esm-integration and test-modularize-instance. --- src/lib/libnoderawfs.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/libnoderawfs.js b/src/lib/libnoderawfs.js index 57387ad233674..542be206027e0 100644 --- a/src/lib/libnoderawfs.js +++ b/src/lib/libnoderawfs.js @@ -5,12 +5,12 @@ */ addToLibrary({ - $NODERAWFS__deps: ['$ERRNO_CODES', '$FS', '$NODEFS', '$mmapAlloc', '$FS_modeStringToFlags', '$NODERAWFS_stream_funcs'], + $nodeTTY: "{{{ makeNodeImport('node:tty', false) }}}", + $NODERAWFS__deps: ['$ERRNO_CODES', '$FS', '$NODEFS', '$mmapAlloc', '$FS_modeStringToFlags', '$NODERAWFS_stream_funcs', '$nodeTTY'], $NODERAWFS__postset: ` if (!ENVIRONMENT_IS_NODE) { throw new Error("NODERAWFS is currently only supported on Node.js environment.") } - var nodeTTY = require('node:tty'); function _wrapNodeError(func) { return (...args) => { try {