Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,18 @@ commands:
install-rust:
steps:
- run:
name: install rust
name: install rust and wasm-bindgen
# rust and wasm-bindgen are always installed together so there is no
# CI environment with one but not the other. The wasm-bindgen-cli
# version is pinned to match the library the test crate depends on;
# wasm-bindgen requires the CLI and the library to be the exact same
# version.
command: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
export PATH=${HOME}/.cargo/bin:${PATH}
rustup target add wasm32-unknown-emscripten
echo "export PATH=\"\$HOME/.cargo/bin:\$PATH\"" >> $BASH_ENV
cargo install wasm-bindgen-cli --version 0.2.126 --locked
install-node-version:
description: "install a specific version of node"
parameters:
Expand Down
7 changes: 6 additions & 1 deletion site/source/docs/tools_reference/settings_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3385,7 +3385,12 @@ Default value: []
WASM_BINDGEN
============

Run wasm-bindgen and integrate the rust-exported symbols into the rest of Emscripten's JS output.
Run wasm-bindgen and integrate the rust-exported symbols into the rest of
Emscripten's JS output.
Set to 1 to always run wasm-bindgen (e.g. a C/C++ build linking a Rust
staticlib). Set to 'auto' to run it only when the linked wasm carries
wasm-bindgen's marker section, which is how cargo/rustc opts in when driving
emcc as the linker; otherwise 'auto' is a no-op.

Default value: 0

Expand Down
4 changes: 4 additions & 0 deletions src/jsifier.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new feature I think maybe requires a little attention.

I assume this is to that JS librarys can do things like:

EXPORTED_FUNCTIONS.add("foo")

?

I'm not sure how common this need is going to be. I wonder if there are any use cases in the emscirpten itself where we had to hack around the lack of this feature?

Could you share an example of exactly how/when wasm-bindgen adds to this list?

Normally settings flow in single direction "emcc -> js compiler" having settings flow back like this is maybe a little scary.

At the very least, I think we should land this as a separate PR adding this as a new feature with it own specific test case in test/test_jslib.js

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Wasm Bindgen you can declare, e.g. a JS class export:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Counter {
    count: u32,
    name: String,
}

#[wasm_bindgen]
impl Counter {
    #[wasm_bindgen(constructor)]
    pub fn new(name: String) -> Counter {
        Counter { count: 0, name }
    }
    #[wasm_bindgen(getter)]
    pub fn count(&self) -> u32 {
        self.count
    }
    pub fn increment(&mut self) -> u32 {
        self.count += 1;
        self.count
    }
}

This is then provided as import { Counter } from 'mod' where Counter.prototype is configured correctly.

The name of the prototype is handled via configuration (with name modifers such as js_name and js_namespace), and is distinct from the name of the Rust mangled constructor function.

So the actual wasm bindgen exports are an entirely different layer to the Wasm module exports.

This allows us to drive this information back into the Emscripten linker.

The naive implementation is not possible because we currently spill all internal exports as public so do need to add filtering in this PR.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone ahead and split this out into #27436. This will be a prerequisite for this PR still.

nativeAliases,
warnings: warningOccured(),
asyncFuncs,
Expand Down
8 changes: 8 additions & 0 deletions src/postamble.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,15 @@ function checkUnflushedContent() {
#endif // EXIT_RUNTIME
#endif // ASSERTIONS

#if WASM_ESM_INTEGRATION && WASM_BINDGEN
// wasm-bindgen's glue reaches the wasm exports by name on an aggregate exports
// object, so provide it via a namespace import. Only under WASM_BINDGEN -
// plain ESM integration keeps per-symbol named imports so bundlers can
// tree-shake unused wasm exports.
import * as wasmExports from './{{{ WASM_BINARY_FILE }}}';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of them primary reason we are pushing for WASM_ESM_INTEGRATION is to avoid this kind of thing, since I assume once your do this it makes DCE in the bundlers a lot harder/more complicated.

Presumably most programs don't actually need access to the full set of wasmExports, so perhaps we could at least narrow this down a litte?

@guybedford guybedford Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've restricted this to the WASM_BINDGEN mode only.

Note that namespaces in JS bundlers are still fully tree-shakable as they are themselves fully statically analyzable so far as they do not escape analysis paths (which they usually don't so long as they aren't passed around).

#elif !WASM_ESM_INTEGRATION
var wasmExports;
#endif
#if SPLIT_MODULE
var wasmRawExports;
#endif
Expand Down
7 changes: 6 additions & 1 deletion src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -2248,7 +2248,12 @@ var LEGACY_RUNTIME = false;
// [link]
var SIGNATURE_CONVERSIONS = [];

// Run wasm-bindgen and integrate the rust-exported symbols into the rest of Emscripten's JS output.
// Run wasm-bindgen and integrate the rust-exported symbols into the rest of
// Emscripten's JS output.
// Set to 1 to always run wasm-bindgen (e.g. a C/C++ build linking a Rust
// staticlib). Set to 'auto' to run it only when the linked wasm carries
// wasm-bindgen's marker section, which is how cargo/rustc opts in when driving
// emcc as the linker; otherwise 'auto' is a no-op.
// [link]
var WASM_BINDGEN = 0;

Expand Down
7 changes: 7 additions & 0 deletions test/rust/bindgen_greeter/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[build]
target = "wasm32-unknown-emscripten"
rustflags = [
"-Cllvm-args=-enable-emscripten-cxx-exceptions=0",
"-Cpanic=abort",
"-Crelocation-model=static",
]
10 changes: 10 additions & 0 deletions test/rust/bindgen_greeter/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "bindgen_greeter"
edition = "2021"

[[bin]]
name = "bindgen_greeter"
path = "src/main.rs"

[dependencies]
wasm-bindgen = "=0.2.126"
23 changes: 23 additions & 0 deletions test/rust/bindgen_greeter/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Greeter {
greeting: String,
}

#[wasm_bindgen]
impl Greeter {
#[wasm_bindgen(constructor)]
pub fn new(greeting: String) -> Greeter {
Greeter { greeting }
}

pub fn greet(&self, name: String) -> String {
format!("{}, {}!", self.greeting, name)
}
}

fn main() {
// Matches the emscripten idiom: main runs automatically on init.
println!("main ran");
}
31 changes: 30 additions & 1 deletion test/test_jslib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
80 changes: 75 additions & 5 deletions test/test_other.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,11 @@ def requires_rust(func):
return requires_tool('cargo', 'RUST')(func)


def requires_wasm_bindgen(func):
assert callable(func)
return requires_tool('wasm-bindgen', 'WASM_BINDGEN')(func)


def requires_pkg_config(func):
assert callable(func)

Expand Down Expand Up @@ -15136,20 +15141,85 @@ def test_rust_integration_basics(self):
self.do_runf('main.cpp', 'Hello from rust!', cflags=[lib])

@requires_rust
@requires_wasm_bindgen
def test_wasm_bindgen_integration(self):
copytree(test_file('rust/bindgen_integration'), '.')
self.run_process(['cargo', 'add', 'wasm-bindgen'])
# Pin the library to the (managed) wasm-bindgen-cli version on PATH;
# wasm-bindgen requires the CLI and the library to match exactly.
self.run_process(['cargo', 'add', 'wasm-bindgen@=0.2.126'])
self.run_process(['cargo', 'build'])
lib = 'target/wasm32-unknown-emscripten/debug/libbindgen_integration.a'
self.assertExists(lib)

create_file('empty.c', '')
# A hand-written EMSCRIPTEN_KEEPALIVE C export must remain surfaced
# alongside wasm-bindgen's self-registered API; the wasm-bindgen glue
# suppression must not drop it.
create_file('native.c', '''
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE int em_double(int x) { return x * 2; }
''')
create_file('post.js', '''
Module.onRuntimeInitialized = () => out(Module.rs_add(17, 25));
Module.onRuntimeInitialized = () => {
out('rs_add=' + Module.rs_add(17, 25));
out('em_double=' + Module._em_double(20));
};
''')

self.run_process(['cargo', 'install', 'wasm-bindgen-cli'])
self.do_runf('empty.c', '42', cflags=[lib, '-sWASM_BINDGEN', '--post-js=post.js', '-lexports.js'])
output = self.do_runf('native.c', cflags=[lib, '-sWASM_BINDGEN', '--post-js=post.js', '-lexports.js'])
self.assertContained('rs_add=42', output)
self.assertContained('em_double=40', output)

# ESM-integration and factory (MODULARIZE) surface the clean wasm-bindgen API
# differently (named ESM exports vs `Module.<name>`). Both must expose exactly
# the `Greeter` class and none of the raw wasm exports rustc lists.
@requires_rust
@requires_wasm_bindgen
@parameterized({
'esm': (['-sWASM_ESM_INTEGRATION'], '''
import init, * as mod from './bindgen_greeter.js';
await init();
'''),
'factory': (['-sMODULARIZE', '-sEXPORT_ES6'], '''
import Module from './bindgen_greeter.js';
const mod = await Module();
'''),
})
def test_wasm_bindgen_rustc_driven(self, cflags, prelude):
# cargo/rustc links via emcc; pass -sWASM_BINDGEN=auto (plus the output-mode
# settings) through so emcc detects wasm-bindgen's marker section in the
# linked wasm and runs wasm-bindgen as a post-link step.
copytree(test_file('rust/bindgen_greeter'), '.')
# rustc invokes emcc as the linker; ensure it uses *this* emcc and pass the
# link settings through.
with env_modify({'CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_LINKER': EMCC,
'EMCC_CFLAGS': ' '.join(['-sWASM_BINDGEN=auto'] + cflags)}):
self.run_process(['cargo', 'build'])

# cargo copies only the .js and .wasm; the ESM support module and snippets
# stay in deps/, so run from there.
out_dir = 'target/wasm32-unknown-emscripten/debug/deps'
create_file(os.path.join(out_dir, 'run.mjs'), prelude + '''
const greeting = new mod.Greeter('Hello').greet('world');
if (greeting !== 'Hello, world!') throw new Error('unexpected greeting: ' + greeting);
// None of the raw wasm exports leak into the user-facing API.
for (const name of ['_main', 'greeter_greet', '_greeter_greet',
'__wbindgen_malloc', '___wbindgen_malloc']) {
if (mod[name] !== undefined) throw new Error('leaked export: ' + name);
}
console.log(greeting);
''')
self.node_args += ['--experimental-wasm-modules', '--no-warnings']
output = self.run_js(os.path.join(out_dir, 'run.mjs'))
self.assertContained('Hello, world!', output)
# `main` runs automatically on init (matching the emscripten C++ idiom),
# even though `_main` is not surfaced as a user-facing export.
self.assertContained('main ran', output)

def test_wasm_bindgen_auto_no_marker(self):
# -sWASM_BINDGEN=auto is a no-op for an ordinary build with no wasm-bindgen
# marker section: wasm-bindgen is never invoked (so it need not be installed)
# and the program builds and runs normally.
self.do_runf('hello_world.c', 'Hello, world!', cflags=['-sWASM_BINDGEN=auto'])

@requires_rust
@requires_dev_dependency('typescript')
Expand Down
53 changes: 47 additions & 6 deletions tools/building.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
utils,
webassembly,
)
from .settings import settings
from .settings import settings, user_settings
from .shared import (
CLANG_CC,
CLANG_CXX,
Expand Down Expand Up @@ -57,6 +57,14 @@
_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()
# The raw wasm exports wasm-bindgen's generated bindings reach by name (the
# supplied/expansion glue), mangled. These are suppressed from the public
# surface; EMSCRIPTEN_KEEPALIVE exports are not in this set and remain.
wasm_bindgen_internal_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`.
Expand Down Expand Up @@ -300,7 +308,12 @@ def lld_flags(args, linker_inputs=None):
# grouping.
args = [a for a in args if a not in {'--start-group', '--end-group'}]

if settings.WASM_BINDGEN:
# Retain the wasm exports wasm-bindgen's glue reaches by name. This is the
# emcc-driven staticlib flow (explicit -sWASM_BINDGEN), where nobody else
# computed the export set, so we discover it here. The cargo/rustc-driven flow
# (-sWASM_BINDGEN=auto, still unresolved at link time) supplies EXPORTED_FUNCTIONS
# itself and never needs this.
if settings.WASM_BINDGEN == 1 and 'EXPORTED_FUNCTIONS' not in user_settings:
exported_symbols = get_wasm_bindgen_exported_symbols(linker_inputs)
args.extend(f'--export={e}' for e in exported_symbols)

Expand Down Expand Up @@ -1293,6 +1306,14 @@ def run_wasm_opt(infile, outfile=None, args=[], **kwargs): # noqa
return run_binaryen_command('wasm-opt', infile, outfile, args=args, **kwargs)


def is_wasm_bindgen_module(wasm_file):
# wasm-bindgen marks modules built for the emscripten target with this custom
# section so emcc, when used as the linker (e.g. by cargo/rustc), can detect
# under `-sWASM_BINDGEN=auto` that wasm-bindgen needs to run as a post-link step.
with webassembly.Module(wasm_file) as module:
return module.get_custom_section('__wasm_bindgen_emscripten_marker') is not None


def run_wasm_bindgen(infile):
bindgen_out_dir = os.path.join(get_emscripten_temp_dir(), 'bindgen_out')

Expand All @@ -1307,16 +1328,36 @@ def run_wasm_bindgen(infile):
'--out-dir',
bindgen_out_dir,
]
exports_before = {e.name for e in webassembly.get_exports(infile)}

check_call(cmd)

# Don't try to predict the .wasm filename that wasm-bindgen outputs. Instead
# just grab the .wasm file itself.
all_output_files = os.listdir(bindgen_out_dir)
new_wasm_file = [x for x in all_output_files if x.endswith('.wasm')][0]

shutil.copyfile(os.path.join(bindgen_out_dir, new_wasm_file), infile)

return os.path.join(bindgen_out_dir, 'library_bindgen.js')
new_wasm_path = os.path.join(bindgen_out_dir, new_wasm_file)

exports_after = {e.name for e in webassembly.get_exports(new_wasm_path)}
# Report which placeholder exports wasm-bindgen consumed so the caller can
# drop them from EXPORTED_FUNCTIONS, and which exports its expansion added so
# the caller can keep them off the public surface.
removed_exports = exports_before - exports_after
added_exports = exports_after - exports_before

shutil.copyfile(new_wasm_path, infile)

# wasm-bindgen emits imported JS snippets into `snippets/` and the `import`
# statements referencing them into `library_bindgen.extern-pre.js`, only when
# the crate actually imports JS.
extern_pre_js = os.path.join(bindgen_out_dir, 'library_bindgen.extern-pre.js')
if not os.path.exists(extern_pre_js):
extern_pre_js = None
snippets_dir = os.path.join(bindgen_out_dir, 'snippets')
if not os.path.isdir(snippets_dir):
snippets_dir = None

return os.path.join(bindgen_out_dir, 'library_bindgen.js'), removed_exports, added_exports, extern_pre_js, snippets_dir


intermediate_counter = 0
Expand Down
Loading
Loading