From 6eb0984532600c6e7d838a20f680d81681935e3b Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 28 Jul 2026 14:06:08 -0700 Subject: [PATCH 1/7] Support JS libraries self-registering their exports via EXPORTED_FUNCTIONS JS library code can already mutate the compile-time EXPORTED_FUNCTIONS set at library load time, which under MODULARIZE=instance causes jsifier to emit the symbol with an `export` declaration. This makes that flow fully work by forwarding the final EXPORTED_FUNCTIONS set back from the JS compiler so the linker can derive which JS library symbols were exported, and have the WASM_ESM_INTEGRATION wrapper re-export them. This is used by binding layers (e.g. wasm-bindgen) that define a public JS API surface distinct from the wasm export names, registering it from their generated JS library. --- src/jsifier.mjs | 4 ++++ test/test_jslib.py | 31 ++++++++++++++++++++++++++++++- tools/building.py | 4 ++++ tools/emscripten.py | 8 ++++++++ tools/link.py | 4 ++++ 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 77a534648bb78..1f5bcd8335262 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -973,6 +973,10 @@ var proxiedFunctionTable = [ '//FORWARDED_DATA:' + JSON.stringify({ librarySymbols, + // The final EXPORTED_FUNCTIONS set, including any additions made by + // JS libraries at load time, so the caller can re-derive which + // library symbols were exported. + exportedFunctions: Array.from(EXPORTED_FUNCTIONS), nativeAliases, warnings: warningOccured(), asyncFuncs, diff --git a/test/test_jslib.py b/test/test_jslib.py index e8d26e1618087..aae4f7ec76053 100644 --- a/test/test_jslib.py +++ b/test/test_jslib.py @@ -6,7 +6,7 @@ from subprocess import PIPE from common import RunnerCore, copy_asset, create_file, read_file, test_file -from decorators import also_with_wasm64, also_without_bigint, parameterized +from decorators import also_with_wasm64, also_without_bigint, parameterized, requires_node_25 from tools.shared import EMCC from tools.utils import delete_file @@ -164,6 +164,35 @@ def test_jslib_exported(self): self.do_runf('src.c', 'c calling: 12\njs calling: 10.', cflags=['--js-library', 'lib.js', '-sEXPORTED_FUNCTIONS=_main,_jslibfunc']) + @parameterized({ + '': ([],), + 'esm_integration': (['-sWASM_ESM_INTEGRATION'],), + }) + @requires_node_25 + def test_jslib_self_export(self, args): + # A JS library can add its own symbols to EXPORTED_FUNCTIONS at load time + # (e.g. a binding layer registering the public API it defines), making them + # ES module exports under MODULARIZE=instance without the user needing to + # list them on the command line. + self.node_args += ['--no-warnings'] + create_file('lib.js', '''\ +EXPORTED_FUNCTIONS.add('libExport'); +addToLibrary({ + $libExport: () => 42, +}); +''') + create_file('main.c', 'int main() { return 0; }') + self.run_process([EMCC, 'main.c', '-sMODULARIZE=instance', '-Wno-experimental', + '--js-library', 'lib.js', '-o', 'mod.mjs'] + args + self.get_cflags()) + create_file('runner.mjs', ''' + import { strict as assert } from 'assert'; + import init, { libExport } from './mod.mjs'; + await init(); + assert(libExport() == 42); + console.log('ok'); + ''') + self.assertContained('ok', self.run_js('runner.mjs')) + def test_jslib_using_asm_lib(self): create_file('lib.js', r''' addToLibrary({ diff --git a/tools/building.py b/tools/building.py index fb9a99eceb364..6c0e6db39f53a 100644 --- a/tools/building.py +++ b/tools/building.py @@ -59,6 +59,10 @@ _is_ar_cache: dict[str, bool] = {} # the exports the user requested user_requested_exports: set[str] = set() +# JS library symbols that ended up in EXPORTED_FUNCTIONS (including additions +# made by JS libraries themselves at load time), derived from the JS compiler's +# forwarded data; the WASM_ESM_INTEGRATION wrapper re-exports them. +exported_js_library_symbols: set[str] = set() # A list of feature flags to pass to each binaryen invocation (like `wasm-opt`, # etc.). This is received by the first call to binaryen (e.g. `wasm-emscripten-finalize`) # which reads it using `--detect-features`. diff --git a/tools/emscripten.py b/tools/emscripten.py index 38dfd6cb5c43d..a35db0ce910d8 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -446,6 +446,14 @@ def emscript(in_wasm, out_wasm, outfile_js, js_syms, finalize=True, base_metadat report_missing_exports(forwarded_json['librarySymbols']) + # A JS library symbol is exported (MODULARIZE=instance) when it is in + # EXPORTED_FUNCTIONS; derive that set rather than tracking it separately. The + # forwarded EXPORTED_FUNCTIONS includes additions made by JS libraries + # themselves at load time. + exported_functions = set(forwarded_json['exportedFunctions']) + building.exported_js_library_symbols.update( + s for s in forwarded_json['librarySymbols'] if s in exported_functions) + asm_const_pairs = ['%s: %s' % (key, value) for key, value in asm_consts] if asm_const_pairs or settings.MAIN_MODULE: pre += 'var ASM_CONSTS = {\n ' + ', \n '.join(asm_const_pairs) + '\n};\n' diff --git a/tools/link.py b/tools/link.py index 8d787765624f0..942ead75e4a13 100644 --- a/tools/link.py +++ b/tools/link.py @@ -2195,6 +2195,10 @@ def node_detection_code(): def create_esm_wrapper(wrapper_file, support_target, wasm_target): js_exports = building.user_requested_exports.union(settings.EXPORTED_RUNTIME_METHODS) + # JS library symbols the support module exports at declaration (including + # any the libraries themselves added to EXPORTED_FUNCTIONS at load time); + # the wrapper must forward these too. + js_exports |= building.exported_js_library_symbols js_exports = ', '.join(sorted(js_exports)) wrapper = [] From 40cadd91ad2db973e612c71baa99c3f5f5a24c81 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 28 Jul 2026 15:28:16 -0700 Subject: [PATCH 2/7] Retrigger CI From cbe38b1a678009223d4ab39700bc51bc6dd19884 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 5 Aug 2026 11:35:19 -0700 Subject: [PATCH 3/7] Use symbol attributes for JS library exports --- .../Interacting-with-code.rst | 3 + src/jsifier.mjs | 19 +++-- src/modules.mjs | 10 ++- src/utility.mjs | 4 ++ test/test_jslib.py | 72 ++++++++++++++++--- tools/building.py | 8 +-- tools/emscripten.py | 13 ++-- tools/link.py | 5 +- 8 files changed, 101 insertions(+), 33 deletions(-) diff --git a/site/source/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.rst b/site/source/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.rst index 2d3df59fb45c6..30074c2688bf0 100644 --- a/site/source/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.rst +++ b/site/source/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.rst @@ -598,6 +598,9 @@ See the `library_*.js`_ files for other examples. by ``_``. In other words ``my_func: function() {},`` becomes ``function _my_func() {}``, as all C methods in emscripten have a ``_`` prefix. Keys starting with ``$`` have the ``$`` stripped and no underscore added. + - A library symbol can use ``__force: true`` to be included even when it is + not referenced, and ``__export: true`` to be exported when included. Use + both decorators to unconditionally include and export a symbol. .. _interacting-with-code-call-function-pointers-from-c: diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 1f5bcd8335262..85514857c1827 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -41,7 +41,7 @@ import { localFile, timer, } from './utility.mjs'; -import {LibraryManager, librarySymbols, nativeAliases} from './modules.mjs'; +import {extraLibraryExports, LibraryManager, librarySymbols, nativeAliases} from './modules.mjs'; const addedLibraryItems = {}; @@ -421,6 +421,12 @@ export async function runJSify(outputFile, symbolsOnly) { LibraryManager.load(); + for (const key of Object.keys(LibraryManager.library)) { + if (!isDecorator(key) && LibraryManager.library[key + '__force']) { + extraLibraryFuncs.push(key); + } + } + let outputHandle = process.stdout; if (outputFile) { outputHandle = await fs.open(outputFile, 'w'); @@ -700,6 +706,10 @@ function(${args}) { librarySymbols.push(mangled); + if (!isStub && LibraryManager.library[symbol + '__export']) { + extraLibraryExports.add(mangled); + } + const original = LibraryManager.library[symbol]; let snippet = original; const isUserSymbol = LibraryManager.library[symbol + '__user']; @@ -838,7 +848,7 @@ function(${args}) { contentText = `var ${mangled} = ${snippet};`; } - if (contentText && MODULARIZE == 'instance' && (EXPORT_ALL || EXPORTED_FUNCTIONS.has(mangled)) && !isStub) { + if (contentText && MODULARIZE == 'instance' && (EXPORT_ALL || EXPORTED_FUNCTIONS.has(mangled) || extraLibraryExports.has(mangled)) && !isStub) { // In MODULARIZE=instance mode mark JS library symbols are exported at // the point of declaration. contentText = 'export ' + contentText; @@ -973,10 +983,7 @@ var proxiedFunctionTable = [ '//FORWARDED_DATA:' + JSON.stringify({ librarySymbols, - // The final EXPORTED_FUNCTIONS set, including any additions made by - // JS libraries at load time, so the caller can re-derive which - // library symbols were exported. - exportedFunctions: Array.from(EXPORTED_FUNCTIONS), + extraExports: Array.from(extraLibraryExports), nativeAliases, warnings: warningOccured(), asyncFuncs, diff --git a/src/modules.mjs b/src/modules.mjs index bc6aa5295f5fa..56178f2d8eddf 100644 --- a/src/modules.mjs +++ b/src/modules.mjs @@ -30,6 +30,8 @@ import {preprocess, processMacros} from './parseTools.mjs'; // List of symbols that were added from the library. export const librarySymbols = []; +// Library symbols exported via the `__export` decorator. +export const extraLibraryExports = new Set(); // Map of library symbols which are aliases for native symbols // e.g. `wasmTable` -> `__indirect_function_table` export const nativeAliases = {}; @@ -480,6 +482,10 @@ function exportRuntimeSymbols() { if (nativeAliases[name]) { return false; } + // Symbols with `__export` are exported at their declaration site. + if (extraLibraryExports.has(name)) { + return false; + } // If requested to be exported, export it. if (EXPORTED_RUNTIME_METHODS.has(name)) { // Unless we are in MODULARIZE=instance mode then HEAP objects are @@ -575,6 +581,7 @@ function exportRuntimeSymbols() { if ( !EXPORTED_RUNTIME_METHODS.has(name) && !EXPORTED_FUNCTIONS.has(name) && + !extraLibraryExports.has(name) && !unusedLibSymbols.has(name) ) { unexported.push(name); @@ -601,7 +608,7 @@ function exportLibrarySymbols() { assert(MODULARIZE != 'instance'); const results = ['// Begin JS library exports']; for (const ident of librarySymbols) { - if ((EXPORT_ALL || EXPORTED_FUNCTIONS.has(ident)) && !nativeAliases[ident]) { + if ((EXPORT_ALL || EXPORTED_FUNCTIONS.has(ident) || extraLibraryExports.has(ident)) && !nativeAliases[ident]) { results.push(exportSymbol(ident)); } } @@ -621,6 +628,7 @@ addToCompileTimeContext({ loadStructInfo, LibraryManager, librarySymbols, + extraLibraryExports, addToLibrary, cDefs, cDefine, diff --git a/src/utility.mjs b/src/utility.mjs index 7d185e0c89fa5..dd6411879e582 100644 --- a/src/utility.mjs +++ b/src/utility.mjs @@ -188,6 +188,8 @@ export function mergeInto(obj, other, options = null) { __user: 'boolean', __async: ['string', 'boolean'], __i53abi: 'boolean', + __export: 'boolean', + __force: 'boolean', }; const expected = decoratorTypes[decoratorName]; if (type !== expected && !expected.includes(type)) { @@ -219,6 +221,8 @@ export const decoratorSuffixes = [ '__user', '__async', '__i53abi', + '__export', + '__force', ]; export function isDecorator(ident) { diff --git a/test/test_jslib.py b/test/test_jslib.py index aae4f7ec76053..1aabe3fcc455f 100644 --- a/test/test_jslib.py +++ b/test/test_jslib.py @@ -166,29 +166,74 @@ def test_jslib_exported(self): @parameterized({ '': ([],), + 'optimized': (['-O3'],), 'esm_integration': (['-sWASM_ESM_INTEGRATION'],), + 'esm_integration_optimized': (['-sWASM_ESM_INTEGRATION', '-O3'],), }) @requires_node_25 - def test_jslib_self_export(self, args): - # A JS library can add its own symbols to EXPORTED_FUNCTIONS at load time - # (e.g. a binding layer registering the public API it defines), making them - # ES module exports under MODULARIZE=instance without the user needing to - # list them on the command line. + def test_jslib_export_decorators(self, args): self.node_args += ['--no-warnings'] create_file('lib.js', '''\ -EXPORTED_FUNCTIONS.add('libExport'); addToLibrary({ - $libExport: () => 42, + $dependencyExport__export: true, + $dependencyExport: () => 41, + + $forceOnly__deps: ['$dependencyExport'], + $forceOnly__force: true, + $forceOnly__postset: 'globalThis.forceOnlyIncluded = true', + $forceOnly: () => {}, + + $forceExport__export: true, + $forceExport__force: true, + $forceExport: () => 42, + + $unusedExport__export: true, + $unusedExport: () => 43, }); ''') create_file('main.c', 'int main() { return 0; }') self.run_process([EMCC, 'main.c', '-sMODULARIZE=instance', '-Wno-experimental', + '-sEXPORTED_RUNTIME_METHODS=forceExport', '--js-library', 'lib.js', '-o', 'mod.mjs'] + args + self.get_cflags()) create_file('runner.mjs', ''' import { strict as assert } from 'assert'; - import init, { libExport } from './mod.mjs'; + import init, * as exports from './mod.mjs'; await init(); - assert(libExport() == 42); + assert.equal(exports.dependencyExport(), 41); + assert.equal(exports.forceExport(), 42); + assert.equal(exports.forceOnly, undefined); + assert.equal(exports.unusedExport, undefined); + assert(globalThis.forceOnlyIncluded); + console.log('ok'); + ''') + self.assertContained('ok', self.run_js('runner.mjs')) + + def test_jslib_export_decorators_legacy(self): + create_file('lib.js', '''\ +addToLibrary({ + $forceOnly__force: true, + $forceOnly__postset: "Module['forceOnlyIncluded'] = true", + $forceOnly: () => {}, + + $forceExport__export: true, + $forceExport__force: true, + $forceExport: () => 42, + + $unusedExport__export: true, + $unusedExport: () => 43, +}); +''') + create_file('main.c', 'int main() { return 0; }') + self.run_process([EMCC, 'main.c', '-sMODULARIZE', '-sEXPORT_ES6', + '--js-library', 'lib.js', '-o', 'mod.mjs'] + self.get_cflags()) + create_file('runner.mjs', ''' + import { strict as assert } from 'assert'; + import createModule from './mod.mjs'; + const module = await createModule(); + assert.equal(module.forceExport(), 42); + assert.throws(() => module.forceOnly); + assert.throws(() => module.unusedExport); + assert(module.forceOnlyIncluded); console.log('ok'); ''') self.assertContained('ok', self.run_js('runner.mjs')) @@ -312,6 +357,15 @@ def test_jslib_invalid_decorator(self): self.assert_fail([EMCC, test_file('hello_world.c'), '--js-library', 'lib.js'], "lib.js: Decorator (jslibfunc__internal) has wrong type. Expected 'boolean' not 'string'") + create_file('lib.js', r''' +addToLibrary({ + jslibfunc__export: 'yes', + jslibfunc: (x) => {}, +}); +''') + self.assert_fail([EMCC, test_file('hello_world.c'), '--js-library', 'lib.js'], + "lib.js: Decorator (jslibfunc__export) has wrong type. Expected 'boolean' not 'string'") + @also_with_wasm64 @also_without_bigint def test_jslib_i53abi(self): diff --git a/tools/building.py b/tools/building.py index 6c0e6db39f53a..52d523b52b98e 100644 --- a/tools/building.py +++ b/tools/building.py @@ -59,10 +59,8 @@ _is_ar_cache: dict[str, bool] = {} # the exports the user requested user_requested_exports: set[str] = set() -# JS library symbols that ended up in EXPORTED_FUNCTIONS (including additions -# made by JS libraries themselves at load time), derived from the JS compiler's -# forwarded data; the WASM_ESM_INTEGRATION wrapper re-exports them. -exported_js_library_symbols: set[str] = set() +# JS library symbols exported via the `__export` decorator. +extra_js_exports: set[str] = set() # A list of feature flags to pass to each binaryen invocation (like `wasm-opt`, # etc.). This is received by the first call to binaryen (e.g. `wasm-emscripten-finalize`) # which reads it using `--detect-features`. @@ -834,7 +832,7 @@ def metadce(js_file, wasm_file, debug_info, last): return js_file graph = json.loads(txt) # ensure that functions expected to be exported to the outside are roots - required_symbols = user_requested_exports.union(set(settings.SIDE_MODULE_IMPORTS)) + required_symbols = user_requested_exports.union(extra_js_exports, settings.SIDE_MODULE_IMPORTS) for item in graph: if 'export' in item: export = asmjs_mangle(item['export']) diff --git a/tools/emscripten.py b/tools/emscripten.py index a35db0ce910d8..f9c84a0a93b02 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -446,13 +446,7 @@ def emscript(in_wasm, out_wasm, outfile_js, js_syms, finalize=True, base_metadat report_missing_exports(forwarded_json['librarySymbols']) - # A JS library symbol is exported (MODULARIZE=instance) when it is in - # EXPORTED_FUNCTIONS; derive that set rather than tracking it separately. The - # forwarded EXPORTED_FUNCTIONS includes additions made by JS libraries - # themselves at load time. - exported_functions = set(forwarded_json['exportedFunctions']) - building.exported_js_library_symbols.update( - s for s in forwarded_json['librarySymbols'] if s in exported_functions) + building.extra_js_exports.update(forwarded_json['extraExports']) asm_const_pairs = ['%s: %s' % (key, value) for key, value in asm_consts] if asm_const_pairs or settings.MAIN_MODULE: @@ -972,7 +966,7 @@ def install_debug_wrapper(sym): def should_export(sym): - return settings.EXPORT_ALL or (settings.EXPORT_KEEPALIVE and sym in settings.EXPORTED_FUNCTIONS) + return settings.EXPORT_ALL or sym in building.extra_js_exports or (settings.EXPORT_KEEPALIVE and sym in settings.EXPORTED_FUNCTIONS) def create_receiving(function_exports, other_exports, library_symbols, aliases): @@ -994,6 +988,9 @@ def create_receiving(function_exports, other_exports, library_symbols, aliases): receiving.append('import {') receiving.append(' ' + ',\n '.join(exports)) receiving.append(f"}} from './{settings.WASM_BINARY_FILE}';") + alias_exports = building.extra_js_exports.intersection(aliases) + if alias_exports: + receiving.append(f"export {{ {', '.join(sorted(alias_exports))} }};") if generate_dyncall_assignment: receiving.append('\nfunction assignDynCalls() {') diff --git a/tools/link.py b/tools/link.py index 942ead75e4a13..45b76a0026573 100644 --- a/tools/link.py +++ b/tools/link.py @@ -2195,10 +2195,7 @@ def node_detection_code(): def create_esm_wrapper(wrapper_file, support_target, wasm_target): js_exports = building.user_requested_exports.union(settings.EXPORTED_RUNTIME_METHODS) - # JS library symbols the support module exports at declaration (including - # any the libraries themselves added to EXPORTED_FUNCTIONS at load time); - # the wrapper must forward these too. - js_exports |= building.exported_js_library_symbols + js_exports |= building.extra_js_exports js_exports = ', '.join(sorted(js_exports)) wrapper = [] From abe7b9289cb08e2b30ce86d8e83828b05519617b Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 5 Aug 2026 11:57:19 -0700 Subject: [PATCH 4/7] Address JS library export review feedback --- src/jsifier.mjs | 8 ++++---- src/lib/libasync.js | 5 +---- src/lib/libautodebug.js | 42 +++++++++------------------------------- src/lib/libcore.js | 6 ++++-- src/lib/libembind_gen.js | 3 +-- src/lib/libfs.js | 4 ++++ src/lib/libfs_shared.js | 7 ------- src/lib/libglemu.js | 3 +-- src/lib/libwasmfs.js | 4 ++++ src/modules.mjs | 12 ++++-------- test/test_jslib.py | 1 - 11 files changed, 32 insertions(+), 63 deletions(-) diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 85514857c1827..29139fe900a78 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -41,7 +41,7 @@ import { localFile, timer, } from './utility.mjs'; -import {extraLibraryExports, LibraryManager, librarySymbols, nativeAliases} from './modules.mjs'; +import {extraExports, LibraryManager, librarySymbols, nativeAliases} from './modules.mjs'; const addedLibraryItems = {}; @@ -707,7 +707,7 @@ function(${args}) { librarySymbols.push(mangled); if (!isStub && LibraryManager.library[symbol + '__export']) { - extraLibraryExports.add(mangled); + extraExports.add(mangled); } const original = LibraryManager.library[symbol]; @@ -848,7 +848,7 @@ function(${args}) { contentText = `var ${mangled} = ${snippet};`; } - if (contentText && MODULARIZE == 'instance' && (EXPORT_ALL || EXPORTED_FUNCTIONS.has(mangled) || extraLibraryExports.has(mangled)) && !isStub) { + if (contentText && MODULARIZE == 'instance' && (EXPORT_ALL || EXPORTED_FUNCTIONS.has(mangled) || extraExports.has(mangled)) && !isStub) { // In MODULARIZE=instance mode mark JS library symbols are exported at // the point of declaration. contentText = 'export ' + contentText; @@ -983,7 +983,7 @@ var proxiedFunctionTable = [ '//FORWARDED_DATA:' + JSON.stringify({ librarySymbols, - extraExports: Array.from(extraLibraryExports), + extraExports: Array.from(extraExports), nativeAliases, warnings: warningOccured(), asyncFuncs, diff --git a/src/lib/libasync.js b/src/lib/libasync.js index 9f9a2d315b1f5..b0d59fa7503df 100644 --- a/src/lib/libasync.js +++ b/src/lib/libasync.js @@ -20,6 +20,7 @@ addToLibrary({ }, #if ASYNCIFY + $Asyncify__force: true, $Asyncify__deps: ['$runAndAbortIfError', '$callUserCallback', #if ASSERTIONS '$createNamedFunction', @@ -627,7 +628,3 @@ addToLibrary({ }, #endif // ASYNCIFY }); - -if (ASYNCIFY) { - extraLibraryFuncs.push('$Asyncify'); -} diff --git a/src/lib/libautodebug.js b/src/lib/libautodebug.js index 461201c02d184..435e8a24be7e6 100644 --- a/src/lib/libautodebug.js +++ b/src/lib/libautodebug.js @@ -9,7 +9,7 @@ #error "Should only be included in AUTODEBUG mode" #endif -addToLibrary({ +const LibraryAutodebug = { $log_execution: (loc) => dbg('log_execution ' + loc), $get_i32: (loc, index, value) => { dbg('get_i32 ' + [loc, index, value]); @@ -131,36 +131,12 @@ addToLibrary({ dbg('memory_grow_post ' + [loc, result]); return result; }, -}); +}; -extraLibraryFuncs.push( - '$log_execution', - '$get_i32', - '$get_i64', - '$get_f32', - '$get_f64', - '$get_funcref', - '$get_externref', - '$get_anyref', - '$get_exnref', - '$set_i32', - '$set_i64', - '$set_f32', - '$set_f64', - '$set_funcref', - '$set_externref', - '$set_anyref', - '$set_exnref', - '$load_ptr', - '$load_val_i32', - '$load_val_i64', - '$load_val_f32', - '$load_val_f64', - '$store_ptr', - '$store_val_i32', - '$store_val_i64', - '$store_val_f32', - '$store_val_f64', - '$memory_grow_pre', - '$memory_grow_post', -); +for (const symbol of Object.keys(LibraryAutodebug)) { + if (!isDecorator(symbol)) { + LibraryAutodebug[symbol + '__force'] = true; + } +} + +addToLibrary(LibraryAutodebug); diff --git a/src/lib/libcore.js b/src/lib/libcore.js index a621664b009fd..c5a458c76f197 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -2517,7 +2517,7 @@ function autoAddDeps(lib, name) { #if LEGACY_RUNTIME // Library functions that were previously included as runtime functions are // automatically included when `LEGACY_RUNTIME` is set. -extraLibraryFuncs.push( +for (const symbol of [ '$addFunction', '$removeFunction', '$AsciiToString', @@ -2544,7 +2544,9 @@ extraLibraryFuncs.push( '$stringToUTF8Array', '$stringToUTF8', '$lengthBytesUTF8', -); +]) { + LibraryManager.library[symbol + '__force'] = true; +} #endif function wrapSyscallFunction(x, library, isWasi) { diff --git a/src/lib/libembind_gen.js b/src/lib/libembind_gen.js index c20082e94daf1..132e8deeff214 100644 --- a/src/lib/libembind_gen.js +++ b/src/lib/libembind_gen.js @@ -914,6 +914,7 @@ var LibraryEmbind = { #endif ], $emitOutput__postset: () => { addAtPostCtor('emitOutput()'); }, + $emitOutput__force: true, $emitOutput: () => { for (const typeId in awaitingDependencies) { throwBindingError(`Missing binding for type: '${getTypeName(typeId)}' typeId: ${typeId}`); @@ -936,6 +937,4 @@ var LibraryEmbind = { $PureVirtualError: () => { throw new Error('stub function should not be called'); }, }; -extraLibraryFuncs.push('$emitOutput'); - addToLibrary(LibraryEmbind); diff --git a/src/lib/libfs.js b/src/lib/libfs.js index 70d3fda1334e7..4907180698353 100644 --- a/src/lib/libfs.js +++ b/src/lib/libfs.js @@ -5,6 +5,10 @@ */ var LibraryFS = { +#if FORCE_FILESYSTEM + // Include FS even when it is not referenced by compiled code. + $FS__force: true, +#endif $FS__deps: ['$randomFill', '$PATH', '$PATH_FS', '$TTY', '$MEMFS', '$FS_modeStringToFlags', '$FS_fileDataToTypedArray', diff --git a/src/lib/libfs_shared.js b/src/lib/libfs_shared.js index 838b52309701a..b7206da202c29 100644 --- a/src/lib/libfs_shared.js +++ b/src/lib/libfs_shared.js @@ -200,10 +200,3 @@ addToLibrary({ $FS_readFile__deps: ['$FS'], $FS_readFile: 'FS.readFile', }); - -// Normally only the FS things that the compiler sees are needed are included. -// FORCE_FILESYSTEM makes us always include the FS object, which lets the user -// call APIs on it from JS freely. -if (FORCE_FILESYSTEM) { - extraLibraryFuncs.push('$FS'); -} diff --git a/src/lib/libglemu.js b/src/lib/libglemu.js index 0dbcad1d93e45..872f594ae4491 100644 --- a/src/lib/libglemu.js +++ b/src/lib/libglemu.js @@ -34,6 +34,7 @@ var LibraryGLEmulation = { 'glVertexAttribPointer', 'glActiveTexture', '$stringToNewUTF8', '$ptrToString', '$getEmscriptenSupportedExtensions', ], + $GLEmulation__force: true, $GLEmulation__postset: ` // Forward declare GL functions that are overridden by GLEmulation. /**@suppress {duplicate, undefinedVars}*/var _emscripten_glDrawArrays; @@ -3942,8 +3943,6 @@ var LibraryGLEmulation = { gluOrtho2D: (left, right, bottom, top) => _glOrtho(left, right, bottom, top, -1, 1), }; -extraLibraryFuncs.push('$GLEmulation'); - recordGLProcAddressGet(LibraryGLEmulation); addToLibrary(LibraryGLEmulation); diff --git a/src/lib/libwasmfs.js b/src/lib/libwasmfs.js index ebe5d87869ed8..e5c36ba8a9b21 100644 --- a/src/lib/libwasmfs.js +++ b/src/lib/libwasmfs.js @@ -5,6 +5,10 @@ */ addToLibrary({ +#if FORCE_FILESYSTEM + // Include FS even when it is not referenced by compiled code. + $FS__force: true, +#endif $MEMFS__deps: ['wasmfs_create_memory_backend'], $MEMFS: { createBackend(opts) { diff --git a/src/modules.mjs b/src/modules.mjs index 56178f2d8eddf..a7341ef20f3d1 100644 --- a/src/modules.mjs +++ b/src/modules.mjs @@ -31,7 +31,7 @@ import {preprocess, processMacros} from './parseTools.mjs'; // List of symbols that were added from the library. export const librarySymbols = []; // Library symbols exported via the `__export` decorator. -export const extraLibraryExports = new Set(); +export const extraExports = new Set(); // Map of library symbols which are aliases for native symbols // e.g. `wasmTable` -> `__indirect_function_table` export const nativeAliases = {}; @@ -482,10 +482,6 @@ function exportRuntimeSymbols() { if (nativeAliases[name]) { return false; } - // Symbols with `__export` are exported at their declaration site. - if (extraLibraryExports.has(name)) { - return false; - } // If requested to be exported, export it. if (EXPORTED_RUNTIME_METHODS.has(name)) { // Unless we are in MODULARIZE=instance mode then HEAP objects are @@ -581,7 +577,7 @@ function exportRuntimeSymbols() { if ( !EXPORTED_RUNTIME_METHODS.has(name) && !EXPORTED_FUNCTIONS.has(name) && - !extraLibraryExports.has(name) && + !extraExports.has(name) && !unusedLibSymbols.has(name) ) { unexported.push(name); @@ -608,7 +604,7 @@ function exportLibrarySymbols() { assert(MODULARIZE != 'instance'); const results = ['// Begin JS library exports']; for (const ident of librarySymbols) { - if ((EXPORT_ALL || EXPORTED_FUNCTIONS.has(ident) || extraLibraryExports.has(ident)) && !nativeAliases[ident]) { + if ((EXPORT_ALL || EXPORTED_FUNCTIONS.has(ident) || extraExports.has(ident)) && !nativeAliases[ident]) { results.push(exportSymbol(ident)); } } @@ -628,7 +624,7 @@ addToCompileTimeContext({ loadStructInfo, LibraryManager, librarySymbols, - extraLibraryExports, + extraExports, addToLibrary, cDefs, cDefine, diff --git a/test/test_jslib.py b/test/test_jslib.py index 1aabe3fcc455f..9a6c6d4b1f21e 100644 --- a/test/test_jslib.py +++ b/test/test_jslib.py @@ -193,7 +193,6 @@ def test_jslib_export_decorators(self, args): ''') create_file('main.c', 'int main() { return 0; }') self.run_process([EMCC, 'main.c', '-sMODULARIZE=instance', '-Wno-experimental', - '-sEXPORTED_RUNTIME_METHODS=forceExport', '--js-library', 'lib.js', '-o', 'mod.mjs'] + args + self.get_cflags()) create_file('runner.mjs', ''' import { strict as assert } from 'assert'; From 9accea3c3bd5158a2d5dc0fe4863115b543da033 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 5 Aug 2026 13:13:09 -0700 Subject: [PATCH 5/7] Process forced library symbols during merge --- src/jsifier.mjs | 9 +-------- src/lib/libcore.js | 2 +- src/utility.mjs | 8 +++++++- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 29139fe900a78..5e58566edb9ba 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -30,6 +30,7 @@ import { debugLog, error, errorOccured, + extraLibraryFuncs, isDecorator, isJsOnlySymbol, compileTimeContext, @@ -45,8 +46,6 @@ import {extraExports, LibraryManager, librarySymbols, nativeAliases} from './mod const addedLibraryItems = {}; -const extraLibraryFuncs = []; - // Experimental feature to check for invalid __deps entries. // See `EMCC_CHECK_DEPS` in in the environment to try it out. const CHECK_DEPS = process.env.EMCC_CHECK_DEPS; @@ -421,12 +420,6 @@ export async function runJSify(outputFile, symbolsOnly) { LibraryManager.load(); - for (const key of Object.keys(LibraryManager.library)) { - if (!isDecorator(key) && LibraryManager.library[key + '__force']) { - extraLibraryFuncs.push(key); - } - } - let outputHandle = process.stdout; if (outputFile) { outputHandle = await fs.open(outputFile, 'w'); diff --git a/src/lib/libcore.js b/src/lib/libcore.js index c5a458c76f197..3fd173a480749 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -2545,7 +2545,7 @@ for (const symbol of [ '$stringToUTF8', '$lengthBytesUTF8', ]) { - LibraryManager.library[symbol + '__force'] = true; + addToLibrary({[symbol + '__force']: true}, {allowMissing: true}); } #endif diff --git a/src/utility.mjs b/src/utility.mjs index dd6411879e582..f827f2e7eef76 100644 --- a/src/utility.mjs +++ b/src/utility.mjs @@ -96,6 +96,8 @@ function range(size) { return Array.from(Array(size).keys()); } +export const extraLibraryFuncs = []; + export function mergeInto(obj, other, options = null) { if (options) { // check for unintended symbol redefinition @@ -148,11 +150,11 @@ export function mergeInto(obj, other, options = null) { } const index = key.lastIndexOf('__'); + const decorated = key.slice(0, index); const decoratorName = key.slice(index); const type = typeof other[key]; if (decoratorName == '__async') { - const decorated = key.slice(0, index); if (isJsOnlySymbol(decorated)) { error(`__async decorator applied to JS symbol: ${decorated}`); } @@ -196,6 +198,10 @@ export function mergeInto(obj, other, options = null) { error(`Decorator (${key}) has wrong type. Expected '${expected}' not '${type}'`); } } + + if (decoratorName === '__force' && other[key]) { + extraLibraryFuncs.push(decorated); + } } } From 8165f40f1c9606d35bbe581d83ca09bd39a4afe3 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 5 Aug 2026 13:16:03 -0700 Subject: [PATCH 6/7] Test JS library export decorators by default --- ChangeLog.md | 2 ++ test/test_jslib.py | 39 ++++++++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 809278a81101f..ae8fc1c2ee5ab 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -36,6 +36,8 @@ See docs/process.md for more on how version tagging works. when reading the config file. This change broke emsdk installations that contained spaces. (#27421) - OpenMP was updated to LLVM 22.1.8 (#27437) +- JavaScript library symbols can now use the `__force` and `__export` + decorators to control inclusion and export visibility. (#27436) 6.0.4 - 07/24/26 ---------------- diff --git a/test/test_jslib.py b/test/test_jslib.py index 9a6c6d4b1f21e..4f0236404e71b 100644 --- a/test/test_jslib.py +++ b/test/test_jslib.py @@ -171,7 +171,7 @@ def test_jslib_exported(self): 'esm_integration_optimized': (['-sWASM_ESM_INTEGRATION', '-O3'],), }) @requires_node_25 - def test_jslib_export_decorators(self, args): + def test_jslib_export_decorators_instance(self, args): self.node_args += ['--no-warnings'] create_file('lib.js', '''\ addToLibrary({ @@ -207,9 +207,13 @@ def test_jslib_export_decorators(self, args): ''') self.assertContained('ok', self.run_js('runner.mjs')) - def test_jslib_export_decorators_legacy(self): + def test_jslib_export_decorators(self): create_file('lib.js', '''\ addToLibrary({ + $dependencyExport__export: true, + $dependencyExport: () => 41, + + $forceOnly__deps: ['$dependencyExport'], $forceOnly__force: true, $forceOnly__postset: "Module['forceOnlyIncluded'] = true", $forceOnly: () => {}, @@ -222,20 +226,25 @@ def test_jslib_export_decorators_legacy(self): $unusedExport: () => 43, }); ''') - create_file('main.c', 'int main() { return 0; }') - self.run_process([EMCC, 'main.c', '-sMODULARIZE', '-sEXPORT_ES6', - '--js-library', 'lib.js', '-o', 'mod.mjs'] + self.get_cflags()) - create_file('runner.mjs', ''' - import { strict as assert } from 'assert'; - import createModule from './mod.mjs'; - const module = await createModule(); - assert.equal(module.forceExport(), 42); - assert.throws(() => module.forceOnly); - assert.throws(() => module.unusedExport); - assert(module.forceOnlyIncluded); - console.log('ok'); + create_file('post.js', ''' + Module.onRuntimeInitialized = () => { + if (Module.dependencyExport() != 41) throw new Error('dependency export failed'); + if (Module.forceExport() != 42) throw new Error('forced export failed'); + const isExported = (name) => { + try { + return Module[name] !== undefined; + } catch { + return false; + } + }; + if (isExported('forceOnly')) throw new Error('force-only symbol was exported'); + if (isExported('unusedExport')) throw new Error('unused symbol was exported'); + if (!Module.forceOnlyIncluded) throw new Error('force-only symbol was not included'); + out('ok'); + }; ''') - self.assertContained('ok', self.run_js('runner.mjs')) + create_file('main.c', 'int main() { return 0; }') + self.do_runf('main.c', 'ok\n', cflags=['--js-library', 'lib.js', '--post-js', 'post.js']) def test_jslib_using_asm_lib(self): create_file('lib.js', r''' From d1fb20316c1df551415bf2e00f005932517fa08b Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 5 Aug 2026 13:18:40 -0700 Subject: [PATCH 7/7] Move changelog entry to 6.0.6 --- ChangeLog.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index ae8fc1c2ee5ab..2d95be00c92a4 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -21,6 +21,9 @@ See docs/process.md for more on how version tagging works. 6.0.7 (in development) ---------------------- +- JavaScript library symbols can now use the `__force` and `__export` + decorators to control inclusion and export visibility. (#27436) + 6.0.6 - 08/05/26 ---------------- - `DEFAULT_TO_CXX` is now disabled by default. This means that `em++` is now @@ -36,8 +39,6 @@ See docs/process.md for more on how version tagging works. when reading the config file. This change broke emsdk installations that contained spaces. (#27421) - OpenMP was updated to LLVM 22.1.8 (#27437) -- JavaScript library symbols can now use the `__force` and `__export` - decorators to control inclusion and export visibility. (#27436) 6.0.4 - 07/24/26 ----------------