From ed92d7fba641121169fc71b874362d2f6373498e Mon Sep 17 00:00:00 2001 From: vogel76 <6086581+vogel76@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:38:08 +0000 Subject: [PATCH 01/10] 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 | 35 +++++++++++++++++++++++++++++++++++ src/preamble.js | 2 +- src/runtime_debug.js | 17 ++++++++++++----- src/shell.js | 22 +++++++--------------- src/shell_minimal.js | 12 ++++++------ 6 files changed, 62 insertions(+), 29 deletions(-) diff --git a/src/lib/libcore.js b/src/lib/libcore.js index e12adf857b47f..ba34ffd91f079 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -377,8 +377,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 61e4d20565de1..c0a971fba2961 100644 --- a/src/parseTools.mjs +++ b/src/parseTools.mjs @@ -975,6 +975,39 @@ 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) { + // Emit a plain top-level `await import()`. When Closure is enabled, + // `mangleUnsupportedSyntax()` rewrites `await import` to the + // EMSCRIPTEN$AWAIT$IMPORT placeholder before Closure runs and + // `fix_js_mangling()` restores it afterwards (Closure can't parse + // top-level await); without Closure it is left as-is. + // + // The `/* webpackIgnore: true */` hint is required because webpack does + // NOT auto-handle `node:`-prefixed URIs for dynamic import; without it, + // webpack fails with `UnhandledSchemeError: Reading from "node:xxx" is + // not handled by plugins` (see test_webpack_esm_output_clean). + // `/* @vite-ignore */` similarly silences vite's dynamic-import analysis. + 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) { + assert(ENVIRONMENT_MAY_BE_NODE, 'makeNodeFilePath called when environment can never be node'); + if (EXPORT_ES6) { + return `new URL('${filename}', import.meta.url)`; + } + return `__dirname + '/${filename}'`; +} + function makeRemovedFSAssert(fsName) { assert(ASSERTIONS); const lower = fsName.toLowerCase(); @@ -1253,6 +1286,8 @@ addToCompileTimeContext({ makeModuleReceive, makeModuleReceiveExpr, makeModuleReceiveWithVar, + makeNodeFilePath, + makeNodeImport, makeRemovedFSAssert, makeRetainedCompilerSettings, makeReturn64, diff --git a/src/preamble.js b/src/preamble.js index 97f6366910344..17d294ca08c21 100644 --- a/src/preamble.js +++ b/src/preamble.js @@ -553,7 +553,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_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 ad4fd53554e63..e23424652b667 100644 --- a/src/shell.js +++ b/src/shell.js @@ -104,18 +104,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) }}}; globalThis.Worker = worker_threads.Worker; ENVIRONMENT_IS_WORKER = !worker_threads.isMainThread; #if PTHREADS @@ -126,7 +117,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 && (EXPORT_ES6 || PTHREADS || WASM_WORKERS) @@ -197,11 +187,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 + '/'; @@ -346,7 +338,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 9615ea8e7a651..5ee02a83d0237 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) }}}; globalThis.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'); } @@ -179,13 +179,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 1aa83ddb2191189c53ad46b4bd9934a2ebbcd0cc Mon Sep 17 00:00:00 2001 From: vogel76 <6086581+vogel76@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:38:19 +0000 Subject: [PATCH 02/10] 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 e4e92a2370b87..222bf1ad4dae3 100644 --- a/src/lib/libatomic.js +++ b/src/lib/libatomic.js @@ -170,9 +170,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 ba34ffd91f079..95cd88670d6da 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -369,6 +369,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 a9e99be7729ee..9e031e34190e7 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 { @@ -518,7 +530,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 c058362e28157..ab9ad7cf231b0 100644 --- a/src/lib/libwasi.js +++ b/src/lib/libwasi.js @@ -563,14 +563,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), 0); } #endif // ENVIRONMENT_MAY_BE_NODE diff --git a/src/lib/libwasm_worker.js b/src/lib/libwasm_worker.js index 35767b196b455..87a9628e5f86c 100644 --- a/src/lib/libwasm_worker.js +++ b/src/lib/libwasm_worker.js @@ -293,9 +293,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 858e99672bcb94f36c1705cf9f77f3b5abab60a8 Mon Sep 17 00:00:00 2001 From: vogel76 <6086581+vogel76@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:22:48 +0000 Subject: [PATCH 03/10] 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 93a124c51e40e..d8be723b7151a 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -479,6 +479,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/10] 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 | 42 ++++++++++++++++++++++++++++++++++++++++ test/vite/vite.config.js | 19 ++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/test/test_other.py b/test/test_other.py index d8be723b7151a..7efb73567f770 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -15082,6 +15082,48 @@ 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 + # vite 8 is rolldown-based and its win32 native binding + # (@rolldown/binding-win32-x64-msvc) is not installed by `npm ci` on the + # Windows CI image due to the npm optional-dependencies bug (npm/cli#4828), + # so `vite build` cannot start there. The same "no require() in ESM output" + # property is covered cross-platform (incl. Windows) by the pure-JS + # test_webpack_esm_output_clean below. + @no_windows('vite 8 rolldown native binding is not installed on Windows CI (npm/cli#4828)') + @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 d879569be22238ac665bfc2a57402859defe339a Mon Sep 17 00:00:00 2001 From: vogel76 <6086581+vogel76@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:44:32 +0000 Subject: [PATCH 05/10] 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 e23424652b667..4e7f2f498c302 100644 --- a/src/shell.js +++ b/src/shell.js @@ -342,6 +342,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 c91fc510efb6a48e2158b49d23dfca92dca3b061 Mon Sep 17 00:00:00 2001 From: vogel76 <6086581+vogel76@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:47:50 +0000 Subject: [PATCH 06/10] 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 a43c3d113ab00..14385b70b4e49 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -8715,7 +8715,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 7c85b0e95cea768bece16fb97c4177a6b8fd9d0b Mon Sep 17 00:00:00 2001 From: vogel76 <6086581+vogel76@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:32:18 +0000 Subject: [PATCH 07/10] 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 7efb73567f770..0955a323b0b2a 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -15177,9 +15177,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 265d8662a10412a7cd7ef4c36c9557c96ab9aa42 Mon Sep 17 00:00:00 2001 From: vogel76 <6086581+vogel76@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:37:51 +0000 Subject: [PATCH 08/10] Use library symbols instead of require() in EM_ASM nodefs tests The nodefs EM_ASM tests called require('fs')/require('path') inline, which does not work in ESM output. Rather than injecting a require() polyfill, declare the dependency with EM_JS_DEPS and reference the library symbols directly: - Add $nodeFs in libcore.js alongside $nodeOs/$nodeChildProcess. - test_nodefs_home.c / test_nodefs_rw.c declare EM_JS_DEPS(deps, ...) and use nodePath/nodeFs in place of require(). - Link -lnodepath.js into test_fs_nodefs_home so $nodePath resolves. - Add the is_esm() helper in common.py. --- src/lib/libcore.js | 1 + test/common.py | 3 +++ test/fs/test_nodefs_home.c | 7 ++++--- test/fs/test_nodefs_rw.c | 8 ++++---- test/test_core.py | 6 +++--- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 95cd88670d6da..7eb5370399f24 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -372,6 +372,7 @@ addToLibrary({ #if ENVIRONMENT_MAY_BE_NODE $nodeOs: "{{{ makeNodeImport('node:os') }}}", $nodeChildProcess: "{{{ makeNodeImport('node:child_process') }}}", + $nodeFs: "{{{ makeNodeImport('node:fs') }}}", _emscripten_system__deps: ['$nodeChildProcess'], #endif _emscripten_system: (command) => { diff --git a/test/common.py b/test/common.py index 98e71bcac81b1..dea381f9cbc84 100644 --- a/test/common.py +++ b/test/common.py @@ -664,6 +664,9 @@ 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 setup_nodefs_test(self): self.require_node() if self.get_setting('WASMFS'): diff --git a/test/fs/test_nodefs_home.c b/test/fs/test_nodefs_home.c index b4e4e71ddbccd..fb8d8cf9f3679 100644 --- a/test/fs/test_nodefs_home.c +++ b/test/fs/test_nodefs_home.c @@ -8,16 +8,17 @@ #include #include +EM_JS_DEPS(deps, "$nodePath"); + int main(void) { EM_ASM( - var path = require("path"); var home = process.env.HOME; // On Windows HOME environment variable doesn't exist, but concatenating HOMEDRIVE and HOMEPATH // does the same thing. if (!home) home = process.env.HOMEDRIVE + process.env.HOMEPATH; - var parent = path.dirname(home); - var relative = path.relative(parent, home); + var parent = nodePath.dirname(home); + var relative = nodePath.relative(parent, home); FS.mkdir('/nodefs_home'); FS.mount(NODEFS, { root: parent }, '/nodefs_home'); // Reading C:/Users/(username) on Windows, /home/(username) on Linux diff --git a/test/fs/test_nodefs_rw.c b/test/fs/test_nodefs_rw.c index 895345761993e..380ac720bdc0f 100644 --- a/test/fs/test_nodefs_rw.c +++ b/test/fs/test_nodefs_rw.c @@ -11,6 +11,8 @@ #include #include +EM_JS_DEPS(deps, "$nodeFs"); + int main() { FILE *file; int res; @@ -18,8 +20,7 @@ int main() { // write something locally with node EM_ASM( - var fs = require('fs'); - fs.writeFileSync('foobar.txt', 'yeehaw'); + nodeFs.writeFileSync('foobar.txt', 'yeehaw'); ); // read and validate the contents of the file @@ -42,8 +43,7 @@ int main() { // validate the changes were persisted to the underlying fs EM_ASM( - var fs = require('fs'); - var contents = fs.readFileSync('foobar.txt', { encoding: 'utf8' }); + var contents = nodeFs.readFileSync('foobar.txt', { encoding: 'utf8' }); assert(contents === 'cheez'); ); diff --git a/test/test_core.py b/test/test_core.py index 14385b70b4e49..c813ae02ff811 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -5863,7 +5863,7 @@ def test_fs_nodefs_dup(self): @requires_node def test_fs_nodefs_home(self): - self.do_runf('fs/test_nodefs_home.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-lnodefs.js']) + self.do_runf('fs/test_nodefs_home.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-lnodefs.js', '-lnodepath.js']) @requires_node def test_fs_nodefs_nofollow(self): @@ -8715,8 +8715,8 @@ 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. + if self.is_esm(): has_node_imports = 'import(' in js else: has_node_imports = 'require(' in js From 9ad25fe355831217551d5ac99bf8c9da33974deb Mon Sep 17 00:00:00 2001 From: vogel76 <6086581+vogel76@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:06:46 +0000 Subject: [PATCH 09/10] 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 5ee02a83d0237..ba666fa96702d 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 b8d8fbd288ec1e25216658cb62a7c02b346c603a Mon Sep 17 00:00:00 2001 From: vogel76 <6086581+vogel76@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:29:04 +0000 Subject: [PATCH 10/10] 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 {