diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 7e367c75..b6ccb827 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -12,6 +12,7 @@ - [`test`](./commands/test.md) - [`pack` and `publish`](./commands/pack-and-publish.md) - [`init` (DEPRECATED)](./commands/init.md) +- [Emscripten target](./emscripten-target.md) - [Tutorials](./tutorials/index.md) - [Hybrid applications with Webpack](./tutorials/hybrid-applications-with-webpack/index.md) - [Getting started](./tutorials/hybrid-applications-with-webpack/getting-started.md) diff --git a/docs/src/commands/new.md b/docs/src/commands/new.md index 06fe2a26..85472ed5 100644 --- a/docs/src/commands/new.md +++ b/docs/src/commands/new.md @@ -33,6 +33,20 @@ template. [`cargo-generate`]: https://github.com/ashleygwilliams/cargo-generate +## Emscripten target + +Pass `--emscripten` to scaffold a project that builds for +`wasm32-unknown-emscripten` instead of the default `wasm32-unknown-unknown`: + +``` +wasm-pack new myproject --emscripten +``` + +This selects the `wasm-pack-emscripten-template` (a sibling of the default +template in the same repo). Requires [emsdk](https://emscripten.org/) to be +installed and `emcc` reachable on `PATH`. See the +[Emscripten target chapter](../emscripten-target.html) for details. + ## Mode The `wasm-pack new` command can be given an optional mode argument, e.g.: diff --git a/docs/src/emscripten-target.md b/docs/src/emscripten-target.md new file mode 100644 index 00000000..8140981d --- /dev/null +++ b/docs/src/emscripten-target.md @@ -0,0 +1,179 @@ +# Building for `wasm32-unknown-emscripten` + +`wasm-pack` supports the `wasm32-unknown-emscripten` target as an alternative +to the default `wasm32-unknown-unknown`. The two produce wasm binaries that +behave differently at runtime: emscripten output includes a libc, file system +shims, POSIX-style APIs, and a richer JavaScript runtime around the wasm. + +You generally want emscripten when: + +- You need `std::time::{Instant, SystemTime}`. +- You need `std::env::{current_dir, vars}`. +- You need `std::fs::*` (backed by an in-memory MEMFS). +- You need `std::collections::HashMap` with default random state. +- You need `rand::random()` to Just Work (via `getentropy`). +- You're linking Rust against C/C++ sources via `bindgen`/`cxx`. + +You generally want `wasm32-unknown-unknown` when: + +- Your crate is pure Rust + `js-sys`/`web-sys`. +- You want the smallest possible runtime overhead. +- You care about cold-start time on the web. + +Most projects don't need emscripten. The default target stays the right choice +for the common case. + +## Prerequisites + +`wasm-pack` invokes the Emscripten SDK (`emcc`) during the build. Install +[emsdk](https://emscripten.org/docs/getting_started/downloads.html) and +activate it before running `wasm-pack`: + +```sh +git clone https://github.com/emscripten-core/emsdk.git ~/emsdk +cd ~/emsdk +./emsdk install latest +./emsdk activate latest +source ./emsdk_env.sh +``` + +`emcc --version` should print at least 3.1.60. Older versions lack +`-sSOURCE_PHASE_IMPORTS=1` and will fail the build with a clear error. + +`wasm-pack` will auto-detect a `~/emsdk` install or honor the `$EMSDK` +environment variable if `emcc` isn't already on `PATH`. CI configurations +should `source emsdk_env.sh` before running `wasm-pack` to make `emcc` +available to child processes. + +## Quick start + +```sh +wasm-pack new my-pkg --emscripten +cd my-pkg +wasm-pack build +``` + +The first command scaffolds a project that's pre-configured for the +emscripten target. The second runs the full pipeline: + +``` +cargo build → emcc link → wasm-bindgen → emcc post-link +``` + +The result in `pkg/` matches the layout for other `wasm-pack` targets, with +two notable differences: + +- The JS module has an `.mjs` extension (always ESM). +- There's no `_bg.wasm`; the wasm file is just `.wasm`. + +## What the template generates + +`wasm-pack new --emscripten ` produces a crate with this shape: + +``` +/ + Cargo.toml # crate-type = ["staticlib"] + .cargo/ + config.toml # target = "wasm32-unknown-emscripten" + src/ + lib.rs # a tiny `greet()` example + README.md +``` + +Two things are notable compared to the default template: + +1. **`crate-type = ["staticlib"]`** — emcc consumes a static library and + links it into the final wasm itself. `cdylib` would skip emcc entirely. + +2. **`.cargo/config.toml`** — selects the emscripten target and sets the + rustflags emcc expects: + - `-Cpanic=abort` — `panic=unwind` isn't supported across the wasm-bindgen + boundary on emscripten yet + ([wasm-bindgen#5165](https://github.com/wasm-bindgen/wasm-bindgen/issues/5165)). + - `-Crelocation-model=static` — the staticlib is linked directly; PIC + isn't needed. + - `-Cllvm-args=-enable-emscripten-cxx-exceptions=0` — avoids pulling in + a C++ exception runtime we don't ship. + +If you build your own crate (without using the template), make sure to +configure both of these. + +## Build targets + +The wasm-pack `--target` flag still selects the output shape: + +| `--target` | Output | +|---|---| +| `bundler` (default) | `.mjs` ESM async factory, env: `web,node` | +| `web` | same as `bundler` | +| `module` | `.mjs` with `import source` for the wasm | +| `nodejs` | `.mjs`, env: `node` | +| `deno` | `.mjs`, env: `web,node` | +| `no-modules` | not supported — emcc produces module-shaped output only | + +All shapes are ESM; emscripten doesn't emit CJS. The `nodejs` target is +distinguished only by the `-sENVIRONMENT=node` setting, which omits +browser-detection probes from the runtime. + +The `module` target uses [source-phase imports][src-phase] for the wasm: + +```js +import source wasmModule from './.wasm'; +``` + +This requires a host that understands the proposal (modern bundlers, Node 22+, +Deno). + +[src-phase]: https://github.com/tc39/proposal-source-phase-imports + +## Limitations + +A few things differ from the default wasm-pack target: + +- **`wasm-pack test` is not supported.** The `wasm-bindgen-test` runner + isn't currently wired up for emscripten; tests must run via + `cargo test` directly. +- **TypeScript declarations are wasm-bindgen-driven and wasm-pack-decorated.** + The `.d.ts` in `pkg/` is wasm-bindgen's output (covering every + `#[wasm_bindgen]` export and class), with a default-export factory + declaration appended so `import M from "./.mjs"` type-checks + cleanly. The factory return type is the wasm-bindgen surface only. + emscripten runtime members (`HEAP*`, `_initialize`, `ccall`/`cwrap`, + `FS`) aren't attached to the factory return by default and are not + typed; if you need them you can extend the `.d.ts` in `pkg/` after the + build. +- **Optimization is capped at `-O2`.** emcc's `-O3` enables wasm-opt's + `--minify-imports-and-exports` pass, which renames wasm exports to + single letters — but wasm-bindgen's generated JS glue references the + original names. `-O2` produces near-equivalent output without the + renaming. +- **`--target module` builds run unoptimized today.** emcc's bundled JS + optimizer (`acorn-optimizer`) can't parse `import source` syntax. Pass + `--no-opt` for `--target module` builds, or wait for the upstream emcc + fix. + +## How the build pipeline works + +For curiosity: + +1. **`cargo build`** — cargo invokes `rustc --target wasm32-unknown-emscripten`, + which under the hood links via emcc to produce a `librustworker.a` + staticlib. + +2. **`emcc` link** — `wasm-pack` invokes `emcc --no-entry --oformat=bare` + to produce a bare `.wasm` from the staticlib. Symbols wasm-bindgen + needs are preserved via `-sEXPORTED_FUNCTIONS`. + +3. **`wasm-bindgen`** — runs over the bare `.wasm` with `--keep-lld-exports` + (no `--target`; emscripten output mode is auto-detected from the + `__wasm_bindgen_emscripten_marker` custom section). Produces a + rewritten `_bg.wasm`, a `library_bindgen.js` (an emscripten JS + library), and a `.d.ts`. + +4. **`emcc --post-link`** — combines the wasm-bindgen-rewritten wasm with + `library_bindgen.js` to emit the final `.mjs` (ESM factory + function) and `.wasm` (final, post-linked binary). + +Each phase is a separate, self-contained tool invocation. Intermediate +artifacts (`linked.wasm`, `_bg.wasm`, `library_bindgen.js`) are not +shipped in `pkg/`. diff --git a/src/bindgen.rs b/src/bindgen.rs index caae94b1..87353cbf 100644 --- a/src/bindgen.rs +++ b/src/bindgen.rs @@ -22,6 +22,7 @@ pub fn wasm_bindgen_build( reference_types: bool, target: Target, profile: BuildProfile, + emscripten: bool, ) -> Result<()> { let out_dir = out_dir.to_str().unwrap(); @@ -47,11 +48,24 @@ pub fn wasm_bindgen_build( cmd.arg("--reference-types"); } - let target_arg = build_target_arg(target, &bindgen_path)?; - if supports_dash_dash_target(&bindgen_path)? { - cmd.arg("--target").arg(target_arg); + if emscripten { + // wasm-bindgen's Emscripten output mode is auto-detected from the + // `__wasm_bindgen_emscripten_marker` custom section embedded in the + // wasm by the `wasm-bindgen` runtime. We must not pass `--target` + // here: passing one would force a non-Emscripten output mode and + // wasm-bindgen would fail to find the intrinsics it needs. + // + // `--keep-lld-exports` preserves the symbols `wasm-ld` exported + // (which emcc post-link expects); without it wasm-bindgen would + // strip them during its rewrite pass. + cmd.arg("--keep-lld-exports"); } else { - cmd.arg(target_arg); + let target_arg = build_target_arg(target, &bindgen_path)?; + if supports_dash_dash_target(&bindgen_path)? { + cmd.arg("--target").arg(target_arg); + } else { + cmd.arg(target_arg); + } } if let Some(value) = out_name { @@ -121,6 +135,9 @@ fn build_target_arg_legacy(target: Target, cli_path: &Path) -> Result { } Target::Bundler => "--browser", Target::Deno => "--deno", + Target::Module => { + bail!("The 'module' target requires wasm-bindgen >= 0.2.99. Please update your project to a newer version of wasm-bindgen.") + } }; Ok(target_arg.to_string()) } diff --git a/src/build/mod.rs b/src/build/mod.rs index 796cb38a..2c62af5a 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -208,6 +208,116 @@ pub fn cargo_build_wasm( } } +/// `cargo build --lib` variant that expects a staticlib (`.a`) artifact. +/// +/// Used by the `wasm32-*-emscripten` pipeline: cargo produces a static +/// library which is then linked by emcc in a subsequent step. This is +/// otherwise identical to `cargo_build_wasm` — same flags, same profile +/// handling — but locates the `.a` filename in the artifact JSON instead +/// of `.wasm`. +pub fn cargo_build_staticlib( + path: &Path, + profile: BuildProfile, + extra_options: &[String], + target_triple: &str, + panic_unwind: bool, +) -> Result { + let msg = format!("{}Compiling to staticlib...", emoji::CYCLONE); + PBAR.info(&msg); + + let mut cmd = Command::new("cargo"); + cmd.current_dir(path); + if panic_unwind { + cmd.arg("+nightly"); + } + cmd.arg("build").arg("--lib"); + + if PBAR.quiet() { + cmd.arg("--quiet"); + } + + match profile { + BuildProfile::Profiling | BuildProfile::Release => { + cmd.arg("--release"); + } + BuildProfile::Dev => {} + BuildProfile::Custom(arg) => { + cmd.arg("--profile").arg(arg); + } + } + + cmd.env("CARGO_BUILD_TARGET", target_triple); + + if panic_unwind { + cmd.arg("-Z").arg("build-std=std,panic_unwind"); + let existing = std::env::var("RUSTFLAGS").unwrap_or_default(); + let combined = if existing.is_empty() { + "-Cpanic=unwind".to_string() + } else { + format!("{existing} -Cpanic=unwind") + }; + cmd.env("RUSTFLAGS", combined); + } + + // Same relative→absolute path normalization as `cargo_build_wasm`. + let mut handle_path = false; + let extra_options_with_absolute_paths = extra_options + .iter() + .map(|option| -> Result { + let value = if handle_path && Path::new(option).is_relative() { + std::env::current_dir()? + .join(option) + .to_str() + .ok_or_else(|| anyhow!("path contains non-UTF-8 characters"))? + .to_string() + } else { + option.to_string() + }; + handle_path = matches!(&**option, "--target-dir" | "--out-dir" | "--manifest-path"); + Ok(value) + }) + .collect::>>()?; + cmd.args(extra_options_with_absolute_paths); + cmd.arg("--message-format=json"); + + let mut cargo_process = cmd.stdout(Stdio::piped()).spawn()?; + let final_artifact = + Message::parse_stream(BufReader::new(cargo_process.stdout.as_mut().unwrap())) + .filter_map(|msg| { + match msg { + Ok(Message::CompilerArtifact(artifact)) => return Some(artifact), + Ok(Message::CompilerMessage(msg)) => eprintln!("{msg}"), + Ok(Message::TextLine(text)) => eprintln!("{text}"), + Err(err) => log::error!("Couldn't parse cargo message: {err}"), + _ => {} + } + None + }) + .last(); + + if !cargo_process + .wait() + .context("Failed to wait for cargo build process")? + .success() + { + bail!("`cargo build` failed, see the output above for details"); + } + + let lib_files: Vec<_> = final_artifact + .context("Expected at least one compiler artifact in the output of `cargo build`")? + .filenames + .into_iter() + .filter(|p| p.extension() == Some("a")) + .collect(); + + match <[_; 1]>::try_from(lib_files) { + Ok([filename]) => Ok(filename.into_string()), + Err(filenames) => bail!( + "Expected exactly one .a file from `cargo build` for the emscripten target, found {filenames:?}" + ), + } +} + /// Runs `cargo build --tests` targeting `wasm32-unknown-unknown`. /// /// This generates the `Cargo.lock` file that we use in order to know which version of diff --git a/src/command/build.rs b/src/command/build.rs index 3d8d36fd..bf578fd6 100644 --- a/src/command/build.rs +++ b/src/command/build.rs @@ -12,13 +12,14 @@ use crate::manifest; use crate::readme; use crate::wasm_opt; use crate::PBAR; -use anyhow::{anyhow, bail, Error, Result}; +use anyhow::{anyhow, bail, Context, Error, Result}; use binary_install::Cache; use clap::Args; use log::info; use path_clean::PathClean; use std::fmt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::process::Command; use std::str::FromStr; use std::time::Instant; @@ -44,6 +45,13 @@ pub struct Build { pub panic_unwind: bool, target_triple: String, wasm_path: Option, + /// Path to the `emcc` binary as resolved by `step_check_for_emcc`, + /// reused by the subsequent link/post-link steps. Resolved via + /// `which::which("emcc")` so that on Windows the `PATHEXT` lookup + /// finds `emcc.bat` — `Command::new("emcc")` would not, because + /// Rust's process spawner doesn't honour `PATHEXT`. Always `Some` + /// in the emscripten pipeline by the time the link step runs. + emcc_path: Option, } /// What sort of output we're going to be generating and flags we're invoking @@ -66,6 +74,9 @@ pub enum Target { /// Correspond to `--target deno` where the output is natively usable as /// a Deno module loaded with `import`. Deno, + /// Correspond to `--target module` where the output uses source phase + /// imports syntax to obtain the compiled WebAssembly module. + Module, } impl Default for Target { @@ -82,6 +93,7 @@ impl fmt::Display for Target { Target::Nodejs => "nodejs", Target::NoModules => "no-modules", Target::Deno => "deno", + Target::Module => "module", }; write!(f, "{}", s) } @@ -96,6 +108,7 @@ impl FromStr for Target { "nodejs" => Ok(Target::Nodejs), "no-modules" => Ok(Target::NoModules), "deno" => Ok(Target::Deno), + "module" => Ok(Target::Module), _ => bail!("Unknown target: {}", s), } } @@ -145,7 +158,7 @@ pub struct BuildOptions { pub reference_types: bool, #[clap(long = "target", short = 't', default_value = "bundler")] - /// Sets the target environment. [possible values: bundler, nodejs, web, no-modules, deno] + /// Sets the target environment. [possible values: bundler, nodejs, web, no-modules, deno, module] pub target: Target, #[clap(long = "debug")] @@ -279,7 +292,7 @@ impl Build { }; Ok(Build { - crate_path, + crate_path: crate_path.clone(), crate_data, scope: build_opts.scope, disable_dts: build_opts.disable_dts, @@ -298,6 +311,7 @@ impl Build { extra_options, panic_unwind: build_opts.panic_unwind, wasm_path: None, + emcc_path: None, }) } @@ -306,9 +320,23 @@ impl Build { self.cache = cache; } + /// Returns true if this build targets emscripten (e.g. + /// `wasm32-unknown-emscripten`). The emscripten target requires a + /// completely different build pipeline: cargo produces a staticlib + /// (`.a`), which we then drive through `emcc` for linking, + /// `wasm-bindgen` for JS bindings, and `emcc --post-link` for the + /// final ESM + wasm pair. + fn is_emscripten(&self) -> bool { + self.target_triple.ends_with("-emscripten") + } + /// Execute this `Build` command. pub fn run(&mut self) -> Result<()> { - let process_steps = Build::get_process_steps(self.mode, self.no_pack, self.no_opt); + let process_steps = if self.is_emscripten() { + Build::get_process_steps_emscripten(self.mode, self.no_pack) + } else { + Build::get_process_steps(self.mode, self.no_pack, self.no_opt) + }; let started = Instant::now(); @@ -382,6 +410,54 @@ impl Build { steps } + /// Build steps for the `wasm32-*-emscripten` target. + /// + /// Phases: + /// 1. `cargo build` → staticlib `.a` + /// 2. `emcc` link → bare `.wasm` (no JS, exports preserved) + /// 3. `wasm-bindgen` → rewritten `.wasm` + `library_bindgen.js` + /// 4. `emcc --post-link` → final `.mjs` (ESM) + `.wasm` + /// + /// No `wasm-opt` step — emcc handles optimization itself. + fn get_process_steps_emscripten( + mode: InstallMode, + no_pack: bool, + ) -> Vec<(&'static str, BuildStep)> { + macro_rules! steps { + ($($name:ident),+) => {{ + let mut steps: Vec<(&'static str, BuildStep)> = Vec::new(); + $(steps.push((stringify!($name), Build::$name));)* + steps + }}; + ($($name:ident,)*) => (steps![$($name),*]) + } + let mut steps = Vec::new(); + if !matches!(mode, InstallMode::Force) { + steps.extend(steps![ + step_check_rustc_version, + step_check_crate_config, + step_check_for_wasm_target, + step_check_for_emcc, + ]); + } + steps.extend(steps![ + step_build_wasm_emscripten, + step_create_dir, + step_emcc_link, + step_install_wasm_bindgen, + step_run_wasm_bindgen, + step_emcc_post_link, + ]); + if !no_pack { + steps.extend(steps![ + step_create_json, + step_copy_readme, + step_copy_license, + ]); + } + steps + } + fn step_check_rustc_version(&mut self) -> Result<()> { // The stable rustc version is irrelevant when --panic-unwind is set, // since cargo will be invoked via `+nightly`. @@ -398,7 +474,7 @@ impl Build { fn step_check_crate_config(&mut self) -> Result<()> { info!("Checking crate configuration..."); - self.crate_data.check_crate_config()?; + self.crate_data.check_crate_config(self.is_emscripten())?; info!("Crate is correctly configured."); Ok(()) } @@ -443,6 +519,7 @@ impl Build { &self.scope, self.disable_dts, self.target, + self.is_emscripten(), )?; info!( "Wrote a package.json at {:#?}.", @@ -494,6 +571,7 @@ impl Build { self.reference_types, self.target, self.profile.clone(), + self.is_emscripten(), )?; info!("wasm bindings were built at {:#?}.", &self.out_dir); Ok(()) @@ -526,9 +604,225 @@ impl Build { ) }) } + + // ---- emscripten pipeline steps ----------------------------------------- + + /// Verify that `emcc` is reachable. Required for the emscripten pipeline. + /// + /// Discovery order: + /// 1. `emcc` on `PATH` (the normal case after `source emsdk_env.sh`) + /// 2. `$EMSDK/upstream/emscripten/emcc` (CI-friendly env-var fallback) + /// 3. `~/emsdk/upstream/emscripten/emcc` (manual-install convention) + /// + /// If found via 2 or 3, the discovered directories are prepended to `PATH` + /// so subsequent emcc invocations Just Work. If not found at all, prints a + /// helpful setup message with a link to the emscripten install docs. + fn step_check_for_emcc(&mut self) -> Result<()> { + info!("Checking for emcc..."); + let emcc_path = if let Ok(p) = which::which("emcc") { + info!("Found emcc at {p:?}."); + p + } else if try_locate_emsdk_and_extend_path() { + // PATH was just updated; re-probe. + match which::which("emcc") { + Ok(p) => { + info!("Found emcc at {p:?} (via $EMSDK)."); + p + } + Err(_) => bail!(emcc_missing_install_message()), + } + } else { + bail!(emcc_missing_install_message()); + }; + + // Soft version check: warn if emcc is older than the minimum we + // know to handle. `-sSOURCE_PHASE_IMPORTS=1` landed in 3.1.60; + // `--post-link` semantics have been stable since 3.1.0. Anything + // older than 3.1.60 will likely fail at build time, so we warn + // up front for clearer diagnostics. + warn_if_emcc_too_old(&emcc_path); + + // Stash the resolved path for the subsequent link / post-link + // steps. On Windows, emcc is `emcc.bat`; `which::which` honours + // `PATHEXT` but `Command::new("emcc")` would not, so we must + // invoke via the full resolved path. + self.emcc_path = Some(emcc_path); + Ok(()) + } + + /// `cargo build` variant for the emscripten target. Same as the default + /// step but locates the produced staticlib (`.a`) instead of a `.wasm`. + fn step_build_wasm_emscripten(&mut self) -> Result<()> { + info!("Building staticlib for wasm32-unknown-emscripten..."); + let lib_path = build::cargo_build_staticlib( + &self.crate_path, + self.profile.clone(), + &self.extra_options, + &self.target_triple, + self.panic_unwind, + )?; + info!("staticlib built at {lib_path:?}."); + self.wasm_path = Some(lib_path); + Ok(()) + } + + /// Phase 2: `emcc` link. + /// + /// Turns the cargo-produced `.a` into a bare `.wasm` ready for + /// wasm-bindgen. Preserves all `rs_*`, `__wbg_*`, `__wbindgen_*`, + /// `__externref_*` extern symbols via `-sEXPORTED_FUNCTIONS` so they + /// survive wasm-ld's dead-stripping. + fn step_emcc_link(&mut self) -> Result<()> { + info!("Linking staticlib with emcc..."); + let lib_path = self + .wasm_path + .as_ref() + .context("step_emcc_link called before cargo build set wasm_path")? + .clone(); + let emcc = self + .emcc_path + .as_ref() + .context("step_emcc_link called before step_check_for_emcc set emcc_path")?; + let out_wasm = self.intermediate_wasm_path(); + emcc_link(emcc, &lib_path, &out_wasm)?; + // step_run_wasm_bindgen reads `wasm_path` as its input — point it at + // the freshly-linked wasm so wasm-bindgen processes the right file. + self.wasm_path = Some(out_wasm.to_string_lossy().into_owned()); + info!("emcc link produced {out_wasm:?}."); + Ok(()) + } + + /// Phase 4: `emcc --post-link`. + /// + /// Combines the wasm-bindgen-rewritten wasm with `library_bindgen.js` + /// (also produced by wasm-bindgen) to emit the final ESM module and + /// wasm pair. Uses source-phase imports + `MODULARIZE=1 EXPORT_ES6=1` + /// so the output is a clean async factory function. + fn step_emcc_post_link(&mut self) -> Result<()> { + info!("Running emcc --post-link..."); + let name = self.bindings_basename(); + // wasm-bindgen wrote these into out_dir during step_run_wasm_bindgen: + let in_wasm = self.out_dir.join(format!("{name}_bg.wasm")); + let in_library = self.out_dir.join("library_bindgen.js"); + let bindgen_dts = self.out_dir.join(format!("{name}.d.ts")); + // `library_bindgen.extern-pre.js` only exists when the crate has + // `#[wasm_bindgen(module = "...")]` imports. wasm-bindgen routes + // those there because ESM `import` statements can't live inside + // emcc's modularize wrapper. + let extern_pre = self.out_dir.join("library_bindgen.extern-pre.js"); + let extern_pre = extern_pre.exists().then_some(extern_pre); + + // Drop stale artifacts from prior non-emscripten builds in the + // same out_dir. The non-emscripten flow ships `.js`, the + // emscripten flow ships `.mjs`; both can co-exist as + // leftovers and confuse downstream tooling. + for stale_ext in ["js", "cjs"] { + let _ = std::fs::remove_file(self.out_dir.join(format!("{name}.{stale_ext}"))); + } + + let post_link_settings = emcc_post_link_settings_for(self.target)?; + let out_js = self + .out_dir + .join(format!("{name}.{}", post_link_settings.extension)); + // `--no-opt` skips optimization regardless of profile, matching the + // non-emscripten pipeline's contract. Users hitting toolchain + // limitations (e.g. emcc's bundled JS optimizer not yet parsing + // source-phase imports for `--target module`) can pass `--no-opt` + // or set `wasm-opt = false` in Cargo.toml's wasm-pack metadata. + let opt_level = if self.no_opt { + "-O0" + } else { + emcc_opt_level_for(&self.profile) + }; + + // We deliberately don't ask emcc for `--emit-tsd`. For a pure-Rust + // wasm-pack package the TS surface is fully knowable to us: + // wasm-bindgen owns the user-facing exports (already typed in its + // own .d.ts), and emcc's runtime surface is a small curated set + // wasm-pack chooses to expose. emcc's TSD would only add value if + // we linked arbitrary C/C++ exports, which the wasm-pack flow + // doesn't support today. Avoiding it also sidesteps the emcc + // assertion on wasm-bindgen-style multi-value-return exports. + let emcc = self + .emcc_path + .as_ref() + .context("step_emcc_post_link called before step_check_for_emcc set emcc_path")?; + emcc_post_link( + emcc, + &in_wasm, + &in_library, + extern_pre.as_deref(), + &out_js, + &post_link_settings, + opt_level, + )?; + info!("emcc --post-link produced {out_js:?}."); + + // Decorate wasm-bindgen's bare .d.ts with the emscripten-shaped + // factory: a `MainModule` alias for the wasm-bindgen surface and a + // default-export factory declaration. After this + // `import M from "./.mjs"` type-checks against the produced + // module. We deliberately don't claim emscripten runtime members + // (`HEAP*`, `FS`, `ccall`/`cwrap`) on the factory return — they + // exist on the underlying module but aren't part of the typed API + // wasm-pack ships; users who need them can extend the `.d.ts` in + // pkg/. + if !self.disable_dts && bindgen_dts.exists() { + decorate_bindgen_dts_for_emscripten(&bindgen_dts)?; + } + + // Clean up intermediate artifacts that shouldn't ship in pkg/. + // `_bg.wasm` (pre-post-link) is superseded by the post-linked + // `.wasm`; `library_bindgen.js` and `library_bindgen.extern-pre.js` + // were build-time-only DSL/import files. + let mut to_clean = vec![ + in_library, + in_wasm, + self.out_dir.join(format!("{name}_bg.wasm.d.ts")), + ]; + if let Some(p) = extern_pre { + to_clean.push(p); + } + for stale in to_clean { + let _ = std::fs::remove_file(&stale); + } + Ok(()) + } + + /// Path used for the bare wasm produced by `step_emcc_link` and consumed + /// by `step_run_wasm_bindgen`. Lives in cargo's target dir, not pkg/, + /// because it's an intermediate artifact. + /// + /// The filename matches what wasm-bindgen would have produced from a + /// regular `wasm32-unknown-unknown` build (`.wasm`), so its + /// downstream output filenames (`_bg.wasm`, etc.) match the + /// same conventions wasm-pack already uses for the non-emscripten path. + fn intermediate_wasm_path(&self) -> PathBuf { + let profile_dir = match &self.profile { + BuildProfile::Release | BuildProfile::Profiling => "release", + BuildProfile::Dev => "debug", + BuildProfile::Custom(name) => name.as_str(), + }; + self.crate_path + .join("target") + .join(&self.target_triple) + .join(profile_dir) + .join(format!("{}.wasm", self.bindings_basename())) + } + + /// Basename wasm-bindgen uses for its output files. Mirrors + /// `bindgen::wasm_bindgen_build`'s logic: out_name override wins, + /// otherwise the crate's library name. + fn bindings_basename(&self) -> String { + self.out_name + .clone() + .unwrap_or_else(|| self.crate_data.crate_name()) + } } -/// Read the cargo `[build] target` setting from `.cargo/config.toml`. +/// Read `[build] target = "..."` from the crate's `.cargo/config.toml`, +/// if present. Returns `None` if the file is missing, malformed, or +/// doesn't set a target. /// /// Mirrors cargo's own discovery: walk up from `crate_path` checking /// `.cargo/config.toml` at each ancestor (workspace-aware) and finally @@ -561,10 +855,419 @@ fn parse_build_target(path: &std::path::Path) -> Option { .map(str::to_owned) } +/// Look for emsdk in well-known locations and prepend its directories to +/// `PATH` so subsequent emcc/llvm-nm invocations resolve correctly. +/// +/// Probes `$EMSDK` first (set by `source emsdk_env.sh`) and then `~/emsdk`. +/// Returns true if a candidate was found and PATH was updated. +fn try_locate_emsdk_and_extend_path() -> bool { + let candidates: Vec = [ + std::env::var_os("EMSDK").map(PathBuf::from), + dirs::home_dir().map(|h| h.join("emsdk")), + ] + .into_iter() + .flatten() + .collect(); + + for root in candidates { + let emcc = root.join("upstream/emscripten/emcc"); + if !emcc.exists() { + continue; + } + let mut new_dirs = vec![root.clone(), root.join("upstream/emscripten")]; + // Also pick up emsdk's bundled python and node if present, so emcc's + // child processes find a matching toolchain. + for sub in ["python", "node"] { + if let Ok(entries) = std::fs::read_dir(root.join(sub)) { + for entry in entries.flatten() { + let bin = entry.path().join("bin"); + if bin.is_dir() { + new_dirs.push(bin); + } + } + } + } + let current = std::env::var_os("PATH").unwrap_or_default(); + let mut all = new_dirs; + all.extend(std::env::split_paths(¤t)); + if let Ok(joined) = std::env::join_paths(&all) { + std::env::set_var("PATH", joined); + return true; + } + } + false +} + +/// Minimum emscripten version we support. `-sSOURCE_PHASE_IMPORTS=1` +/// landed in 3.1.60; building against anything older will fail at link +/// time with an obscure flag error. We surface it as a warning up front. +const MIN_EMCC_VERSION: (u32, u32, u32) = (3, 1, 60); + +/// Warn (don't fail) if the discovered emcc is older than `MIN_EMCC_VERSION`. +/// +/// Parses the first line of `emcc --version` which looks like: +/// emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.60 (...) +/// +/// We're lenient — if we can't parse the version for any reason we just +/// skip the check rather than blocking the user. The actual build will +/// fail explicitly if the version is too old. +fn warn_if_emcc_too_old(emcc_path: &Path) { + let Ok(output) = Command::new(emcc_path).arg("--version").output() else { + return; + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(version) = parse_emcc_version(&stdout) else { + return; + }; + if version < MIN_EMCC_VERSION { + let (a, b, c) = version; + let (x, y, z) = MIN_EMCC_VERSION; + PBAR.warn(&format!( + "Detected emcc {a}.{b}.{c}, but wasm-pack expects at least \ + {x}.{y}.{z}. Older versions lack the `-sSOURCE_PHASE_IMPORTS=1` \ + flag and may fail. Upgrade with `emsdk install latest && \ + emsdk activate latest`." + )); + } else { + log::info!( + "emcc version OK ({}.{}.{}).", + version.0, + version.1, + version.2 + ); + } +} + +/// Extract the (major, minor, patch) tuple from `emcc --version` output. +/// +/// Scans whitespace-separated tokens on the first line and returns the +/// first that parses as `N.N.N` (with optional `-suffix` on the patch). +fn parse_emcc_version(stdout: &str) -> Option<(u32, u32, u32)> { + let first = stdout.lines().next()?; + for token in first.split_whitespace() { + if let Some(v) = parse_dotted_version(token) { + return Some(v); + } + } + None +} + +/// Parse `N.N.N` or `N.N.N-suffix` from a single token. Returns `None` +/// if any of the three components aren't all-digit (after stripping the +/// trailing suffix, if any, from the patch). +fn parse_dotted_version(token: &str) -> Option<(u32, u32, u32)> { + let mut parts = token.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch_tok = parts.next()?; + if parts.next().is_some() { + // Four or more dotted components — not a version we recognise. + return None; + } + let patch_digits: String = patch_tok + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if patch_digits.is_empty() { + return None; + } + let patch = patch_digits.parse::().ok()?; + Some((major, minor, patch)) +} + +/// Stable text returned from emcc-missing diagnostics. Pulled into a fn so +/// both branches of `step_check_for_emcc` share the same wording. +fn emcc_missing_install_message() -> String { + "emcc not found. The Emscripten SDK is required to build the \ + wasm32-unknown-emscripten target.\n\n\ + To install it:\n\ + git clone https://github.com/emscripten-core/emsdk.git ~/emsdk\n\ + cd ~/emsdk && ./emsdk install latest && ./emsdk activate latest\n\ + source ./emsdk_env.sh\n\n\ + Or follow the official guide: https://emscripten.org/docs/getting_started/downloads.html\n\n\ + Once installed, re-run `wasm-pack build`." + .to_string() +} + +/// Invoke `emcc` to link a staticlib into a bare `.wasm` (no JS yet). +/// +/// `--no-entry --oformat=bare` stops emcc after wasm-ld, before it would +/// generate any JS runtime. `-sEXPORTED_FUNCTIONS=` is populated by +/// scanning the staticlib for the symbols wasm-bindgen needs so they +/// survive dead-stripping. +/// +/// `emcc` is the resolved emcc binary path (via `which::which`), not the +/// bare string `"emcc"` — `Command::new("emcc")` doesn't honour `PATHEXT` +/// on Windows where emcc ships as `emcc.bat`. +fn emcc_link(emcc: &Path, lib_path: &str, out_wasm: &PathBuf) -> Result<()> { + if let Some(parent) = out_wasm.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating intermediate dir {parent:?}"))?; + } + + let exports = collect_wasm_bindgen_exports(lib_path)?; + let exports_joined = exports.join(","); + + let mut cmd = Command::new(emcc); + cmd.arg(lib_path) + .arg("--no-entry") + .arg("--oformat=bare") + .arg("-Wno-experimental") + .arg(format!("-sEXPORTED_FUNCTIONS={exports_joined}")) + .arg("-o") + .arg(out_wasm); + + let status = cmd.status().context("running emcc to link staticlib")?; + if !status.success() { + bail!("emcc link exited with status {status}"); + } + Ok(()) +} + +/// Scan a staticlib for the wasm-bindgen-relevant extern symbols using +/// `llvm-nm`. Returns names with the emscripten `_`-prefix convention +/// applied (emcc strips one leading underscore before passing to wasm-ld). +fn collect_wasm_bindgen_exports(lib_path: &str) -> Result> { + let llvm_nm = which::which("llvm-nm") + .or_else(|_| which::which("nm")) + .context("llvm-nm not found on PATH; required to discover wasm-bindgen export symbols")?; + + let output = Command::new(&llvm_nm) + .arg("--defined-only") + .arg("--extern-only") + .arg("--format=just-symbols") + .arg(lib_path) + .output() + .with_context(|| format!("running {llvm_nm:?} on {lib_path:?}"))?; + if !output.status.success() { + bail!("{llvm_nm:?} exited with status {}", output.status); + } + + // wasm-bindgen-known prefixes. Plus user-exported names — those have + // no leading underscore (a #[wasm_bindgen] pub fn foo emits `foo`), + // so we accept names that don't start with `_` and aren't mangled + // (no `_Z`/`_R`/`anon.` markers). System symbols like `__addtf3`, + // `__wasm_call_ctors`, `emscripten_*` etc. start with underscores + // (or `emscripten_`) and are excluded — emcc pulls them in itself. + let mut names: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + // llvm-nm emits per-object-file headers like `foo.o:` interspersed + // with the symbol list — skip them. + .filter(|s| !s.ends_with(':')) + .filter(|s| { + s.starts_with("__wbg_") + || s.starts_with("__wbindgen_") + || s.starts_with("__externref_") + || (!s.starts_with('_') && !s.starts_with("anon.") && !s.starts_with("emscripten_")) + }) + .map(|s| format!("_{s}")) + .collect(); + names.sort(); + names.dedup(); + Ok(names) +} + +/// Settings derived from a wasm-pack `--target` value, used to drive +/// `emcc --post-link` so the produced module matches what consumers of +/// that target expect. +struct EmccPostLinkSettings { + /// Comma-separated value for `-sENVIRONMENT` (e.g. `web,node`, `node`). + /// Restricts which environment-detection paths emcc bakes into the JS, + /// which both shrinks the output and avoids runtime probe noise. + environment: &'static str, + /// Whether to use source-phase `import source` for the wasm module. + /// Requires the host to support the proposal (modern bundlers, Node 22+, + /// Deno). We enable it for `--target module`. + source_phase_imports: bool, + /// File extension for the produced JS module. `mjs` everywhere — we + /// don't emit legacy CJS. + extension: &'static str, +} + +/// Map a wasm-pack `--target` value to a coherent set of emcc post-link +/// settings. All shapes are ESM (no CJS). `--target no-modules` is rejected +/// because emcc can't produce a non-modular global-namespace JS bundle. +fn emcc_post_link_settings_for(target: Target) -> Result { + // `ENVIRONMENT=web,node` is used for every target except `nodejs` so the + // produced bundle works both in browsers/bundlers and directly under + // Node — useful for tests, CLIs, and SSR. The `nodejs` target stays + // node-only since that's its explicit intent. + Ok(match target { + // `Bundler`, `Web`, and `Deno` collapse to the same emcc settings + // — `web,node` ENVIRONMENT, no source-phase imports, `.mjs` — + // because the differentiation between them happens earlier + // (wasm-bindgen produces target-specific JS glue) and emcc's + // post-link sees an already-finished module. + Target::Bundler | Target::Web | Target::Deno => EmccPostLinkSettings { + environment: "web,node", + source_phase_imports: false, + extension: "mjs", + }, + Target::Module => EmccPostLinkSettings { + environment: "web,node", + source_phase_imports: true, + extension: "mjs", + }, + Target::Nodejs => EmccPostLinkSettings { + environment: "node", + source_phase_imports: false, + extension: "mjs", + }, + Target::NoModules => bail!( + "`--target no-modules` is not supported for wasm32-unknown-emscripten builds. \ + The emscripten toolchain produces module-shaped output only. \ + Use `--target web`, `--target bundler`, or `--target module` instead." + ), + }) +} + +/// Translate a `BuildProfile` into the `-O*` flag we pass to +/// `emcc --post-link`. +/// +/// Optimization happens here (the final phase that touches the wasm) +/// because: +/// 1. Anything emcc-link emits is rewritten by wasm-bindgen anyway. +/// 2. emcc's `-O*` triggers wasm-opt internally with the correct +/// feature flags for the runtime's wasm features — no separate +/// wasm-opt install or flag wrangling needed. +/// +/// We cap release builds at `-O2`. `-O3` enables wasm-opt's +/// `--minify-imports-and-exports` pass, which renames exports to +/// single-letter names — but wasm-bindgen's generated JS glue references +/// the original names (`__wbindgen_start`, `rs_add`, etc.). The minified +/// wasm would fail at instantiation. `-O2` produces nearly the same +/// optimization quality without the renaming. +fn emcc_opt_level_for(profile: &BuildProfile) -> &'static str { + match profile { + BuildProfile::Release => "-O2", + BuildProfile::Profiling => "-O2", + BuildProfile::Dev => "-O0", + // Custom profiles could be anything. Pick a sane middle ground; + // users who want a specific level can set it via Cargo profile flags. + BuildProfile::Custom(_) => "-O2", + } +} + +/// Invoke `emcc --post-link` to merge the wasm-bindgen output with +/// emscripten's standard JS runtime, producing the final JS module. +/// +/// When `extern_pre` is provided, it's passed via `--extern-pre-js`. emcc +/// inlines that file verbatim *before* the modularize wrapper, which is +/// where ESM `import` statements must live. wasm-bindgen routes module +/// imports there because they can't legally appear inside emcc's +/// `--js-library` content (that gets evaluated inside the wrapper). +/// +/// `emcc` is the resolved emcc binary path (via `which::which`); see +/// `emcc_link` for the Windows-`PATHEXT` rationale. +fn emcc_post_link( + emcc: &Path, + in_wasm: &Path, + in_library: &Path, + extern_pre: Option<&Path>, + out_js: &Path, + settings: &EmccPostLinkSettings, + opt_level: &str, +) -> Result<()> { + let mut cmd = Command::new(emcc); + cmd.arg("--post-link") + .arg(in_wasm) + .arg("--js-library") + .arg(in_library) + .arg(opt_level) + .arg("-sMODULARIZE=1") + .arg("-sEXPORT_ES6=1") + .arg(format!("-sENVIRONMENT={}", settings.environment)) + .arg("-Wno-experimental"); + if settings.source_phase_imports { + cmd.arg("-sSOURCE_PHASE_IMPORTS=1"); + } + if let Some(path) = extern_pre { + cmd.arg("--extern-pre-js").arg(path); + } + cmd.arg("-o").arg(out_js); + + let status = cmd.status().context("running emcc --post-link")?; + if !status.success() { + bail!("emcc --post-link exited with status {status}"); + } + Ok(()) +} + +/// Append the emscripten factory declaration to wasm-bindgen's `.d.ts`. +/// +/// wasm-bindgen's emscripten-mode output emits an unexported +/// `interface BindgenModule { ... }` and a sibling class export per +/// `#[wasm_bindgen] pub struct`, but doesn't declare the default-exported +/// factory function that emscripten's `-sMODULARIZE -sEXPORT_ES6` emits +/// in the JS. Without this decoration, `import M from "./.mjs"` +/// type-checks against `any`. +/// +/// We append: +/// +/// ```ts +/// export type MainModule = BindgenModule; +/// declare function ModuleFactory(opts?: object): Promise; +/// export default ModuleFactory; +/// ``` +/// +/// We deliberately do NOT type emscripten runtime members (heap views, +/// `_initialize`, ccall/cwrap, etc.). Those exist as module-internal +/// variables in the emscripten runtime but aren't attached to the +/// factory's returned object unless explicitly exposed via +/// `-sEXPORTED_RUNTIME_METHODS`. wasm-pack doesn't request that today, +/// so claiming them in the .d.ts would be a lie. +fn decorate_bindgen_dts_for_emscripten(bindgen_dts: &Path) -> Result<()> { + const DECORATION: &str = r#" + +// --- wasm-pack: emscripten factory shape --- +// emcc's MODULARIZE/EXPORT_ES6 wraps the wasm-bindgen surface in an async +// factory. This declaration types `import M from "./.mjs"` so the +// produced module is usable from TypeScript. +export type MainModule = BindgenModule; +declare function ModuleFactory(opts?: object): Promise; +export default ModuleFactory; +"#; + + let mut existing = std::fs::read_to_string(bindgen_dts) + .with_context(|| format!("reading bindgen .d.ts at {bindgen_dts:?}"))?; + if !existing.ends_with('\n') { + existing.push('\n'); + } + existing.push_str(DECORATION); + std::fs::write(bindgen_dts, existing) + .with_context(|| format!("writing decorated .d.ts to {bindgen_dts:?}"))?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn parse_emcc_version_real_world() { + // Stock emsdk format. + let line = "emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.60 (abc123)"; + assert_eq!(parse_emcc_version(line), Some((3, 1, 60))); + + // Newer style with five-digit hash. + let line = "emcc (...) 5.0.7 (263db4cffa6f9fc2ec514a70abac81362ea41849)"; + assert_eq!(parse_emcc_version(line), Some((5, 0, 7))); + + // Pre-release suffix on patch. + let line = "emcc (...) 3.1.61-git (abc)"; + assert_eq!(parse_emcc_version(line), Some((3, 1, 61))); + } + + #[test] + fn parse_emcc_version_unparseable() { + // No version at all. + assert_eq!(parse_emcc_version(""), None); + // Empty input. + assert_eq!(parse_emcc_version("no numbers here"), None); + } + #[test] fn read_cargo_build_target_walks_up_to_workspace_root() { let tmp = tempfile::TempDir::new().unwrap(); @@ -573,7 +1276,7 @@ mod tests { std::fs::create_dir_all(root.join(".cargo")).unwrap(); std::fs::write( root.join(".cargo/config.toml"), - "[build]\ntarget = \"wasm64-unknown-unknown\"\n", + "[build]\ntarget = \"wasm32-unknown-emscripten\"\n", ) .unwrap(); // Crate lives two levels deeper with no config of its own. @@ -582,7 +1285,7 @@ mod tests { assert_eq!( read_cargo_build_target(&crate_path), - Some("wasm64-unknown-unknown".to_string()) + Some("wasm32-unknown-emscripten".to_string()) ); } @@ -600,22 +1303,24 @@ mod tests { std::fs::create_dir_all(crate_path.join(".cargo")).unwrap(); std::fs::write( crate_path.join(".cargo/config.toml"), - "[build]\ntarget = \"wasm64-unknown-unknown\"\n", + "[build]\ntarget = \"wasm32-unknown-emscripten\"\n", ) .unwrap(); // Crate-level config should win. assert_eq!( read_cargo_build_target(&crate_path), - Some("wasm64-unknown-unknown".to_string()) + Some("wasm32-unknown-emscripten".to_string()) ); } #[test] fn read_cargo_build_target_missing_returns_none() { let tmp = tempfile::TempDir::new().unwrap(); - // Use a deliberately-unreachable CARGO_HOME so the test is hermetic - // (otherwise it would race with developer state). + // We can't usefully exercise the $CARGO_HOME fallback in a unit test + // (would race with developer state), but we can assert the walk returns + // None when no config exists anywhere in the temp tree. + // Use a deliberately-unreachable CARGO_HOME so the test is hermetic. std::env::set_var("CARGO_HOME", tmp.path().join("nonexistent")); assert_eq!(read_cargo_build_target(tmp.path()), None); } diff --git a/src/command/generate.rs b/src/command/generate.rs index 609a0b53..e64bc8a3 100644 --- a/src/command/generate.rs +++ b/src/command/generate.rs @@ -7,7 +7,12 @@ use log::info; /// Executes the 'cargo-generate' command in the current directory /// which generates a new rustwasm project from a template. -pub fn generate(template: String, name: String, install_permitted: bool) -> Result<()> { +pub fn generate( + template: String, + name: String, + emscripten: bool, + install_permitted: bool, +) -> Result<()> { info!("Generating a new rustwasm project..."); let download = install::download_prebuilt_or_cargo_install( Tool::CargoGenerate, @@ -15,7 +20,7 @@ pub fn generate(template: String, name: String, install_permitted: bool) -> Resu "latest", install_permitted, )?; - generate::generate(&template, &name, &download)?; + generate::generate(&template, &name, emscripten, &download)?; let msg = format!("🐑 Generated new project at /{}", name); PBAR.info(&msg); diff --git a/src/command/mod.rs b/src/command/mod.rs index 328151f6..6456a84e 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -51,6 +51,11 @@ pub enum Command { default_value = crate::generate::DEFAULT_TEMPLATE )] template: String, + /// Scaffold a project targeting wasm32-unknown-emscripten instead + /// of the default wasm32-unknown-unknown. Implies a staticlib + + /// emcc pipeline. Requires emsdk on PATH. + #[clap(long = "emscripten", conflicts_with = "template")] + emscripten: bool, #[clap(long = "mode", short = 'm', default_value = "normal")] /// Should we install or check the presence of binary tools. [possible values: no-install, normal, force] mode: InstallMode, @@ -60,7 +65,7 @@ pub enum Command { /// 🎆 pack up your npm package and publish! Publish { #[clap(long = "target", short = 't', default_value = "bundler")] - /// Sets the target environment. [possible values: bundler, nodejs, web, no-modules] + /// Sets the target environment. [possible values: bundler, nodejs, web, no-modules, deno, module] target: String, /// The access level for the package to be published @@ -132,12 +137,22 @@ pub fn run_wasm_pack(command: Command) -> Result<()> { Command::Generate { template, name, + emscripten, mode, } => { info!("Running generate command..."); - info!("Template: {:?}", &template); info!("Name: {:?}", &name); - generate(template, name, mode.install_permitted()) + // `--emscripten` is shorthand for selecting the in-tree emscripten + // template subdirectory from the default wasm-pack repo. Users + // who pass `--template` keep full control; clap rejects the + // combination via `conflicts_with`. + let template = if emscripten { + crate::generate::EMSCRIPTEN_TEMPLATE.to_owned() + } else { + template + }; + info!("Template: {:?}", &template); + generate(template, name, emscripten, mode.install_permitted()) } Command::Publish { target, diff --git a/src/command/publish/mod.rs b/src/command/publish/mod.rs index 1b81a383..fe2668f7 100644 --- a/src/command/publish/mod.rs +++ b/src/command/publish/mod.rs @@ -43,7 +43,7 @@ pub fn publish( let out_dir = format!("{}/pkg", out_dir); let target = Select::new() .with_prompt("target[default: bundler]") - .items(&["bundler", "nodejs", "web", "no-modules"]) + .items(&["bundler", "nodejs", "web", "no-modules", "deno", "module"]) .default(0) .interact()? .to_string(); diff --git a/src/command/test.rs b/src/command/test.rs index 9fc6bd30..da811a03 100644 --- a/src/command/test.rs +++ b/src/command/test.rs @@ -202,6 +202,21 @@ impl Test { /// Execute this test command. pub fn run(mut self) -> Result<()> { + // `wasm-pack test` runs `#[wasm_bindgen_test]` cases via + // `wasm-bindgen-test-runner`, which expects a wasm-bindgen-compiled + // wasm binary. The emscripten target produces a fundamentally + // different artifact (a staticlib linked through emcc), so the + // runner can't drive it. Fail fast with a clear message until a + // dedicated emscripten test runner is available. + if self.target_triple.ends_with("-emscripten") { + bail!( + "`wasm-pack test` does not currently support the {} target. \ + Run your tests with `cargo test` directly, or target \ + wasm32-unknown-unknown for `wasm-pack test`.", + self.target_triple, + ); + } + let process_steps = self.get_process_steps(); let started = Instant::now(); diff --git a/src/generate.rs b/src/generate.rs index aeb64a4b..582c2520 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -10,9 +10,27 @@ use std::process::Command; /// Default git repository used by `wasm-pack new`. pub const DEFAULT_TEMPLATE: &str = "https://github.com/wasm-bindgen/wasm-pack"; +/// Marker passed when the user selects `wasm-pack new --emscripten`. +/// +/// Same repository as `DEFAULT_TEMPLATE`, but `generate()` extracts the +/// emscripten-specific subdirectory via cargo-generate's `--subfolder` +/// option so users don't have to know the layout. +pub const EMSCRIPTEN_TEMPLATE: &str = "https://github.com/wasm-bindgen/wasm-pack"; + +/// Subfolder inside `EMSCRIPTEN_TEMPLATE` that holds the emscripten template. +const EMSCRIPTEN_SUBFOLDER: &str = "wasm-pack-emscripten-template"; + /// Run `cargo generate` in the current directory to create a new -/// project from a template -pub fn generate(template: &str, name: &str, install_status: &install::Status) -> Result<()> { +/// project from a template. +/// +/// When `emscripten` is true, `--subfolder` selects the emscripten template +/// from the multi-template wasm-pack repo. +pub fn generate( + template: &str, + name: &str, + emscripten: bool, + install_status: &install::Status, +) -> Result<()> { let bin_path = install::get_tool_path(install_status, Tool::CargoGenerate)? .binary(&Tool::CargoGenerate.to_string())?; let mut cmd = Command::new(&bin_path); @@ -23,6 +41,11 @@ pub fn generate(template: &str, name: &str, install_status: &install::Status) -> cmd.arg("--git").arg(template); } cmd.arg("--name").arg(name); + // `SUBFOLDER` is a positional in cargo-generate's CLI; it must come + // after all named args. + if emscripten { + cmd.arg(EMSCRIPTEN_SUBFOLDER); + } println!( "{} Generating a new rustwasm project with name '{}'...", diff --git a/src/install/mod.rs b/src/install/mod.rs index e4a6910a..124f42bb 100644 --- a/src/install/mod.rs +++ b/src/install/mod.rs @@ -58,6 +58,25 @@ pub fn download_prebuilt_or_cargo_install( version: &str, install_permitted: bool, ) -> Result { + if let Tool::WasmBindgen = tool { + if let Ok(bin) = env::var("WASM_BINDGEN_BIN") { + let path = Path::new(&bin); + if path.is_file() { + info!( + "Using wasm-bindgen binary from WASM_BINDGEN_BIN: {}", + path.display() + ); + let download = Download::at(path.parent().unwrap()); + return Ok(Status::Found(download)); + } else { + bail!( + "WASM_BINDGEN_BIN is set to '{}' but the file does not exist", + bin + ); + } + } + } + // If the tool is installed globally and it has the right version, use // that. Assume that other tools are installed next to it. // diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index b2f51993..cb0e9325 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -540,22 +540,59 @@ impl CrateData { } } - /// Check that the crate the given path is properly configured. - pub fn check_crate_config(&self) -> Result<()> { - self.check_crate_type()?; - Ok(()) + /// Check that the crate is configured correctly for the chosen target. + /// + /// The emscripten target requires `staticlib`; every other wasm target + /// requires `cdylib`. Mismatches produce a clear diagnostic up front + /// instead of a downstream "cargo produced no .wasm" error. + pub fn check_crate_config(&self, emscripten: bool) -> Result<()> { + self.check_crate_type(emscripten) } - fn check_crate_type(&self) -> Result<()> { + fn check_crate_type(&self, emscripten: bool) -> Result<()> { let pkg = &self.data.packages[self.current_idx]; - let any_cdylib = pkg - .targets - .iter() - .filter(|target| target.kind.iter().any(|k| k == &TargetKind::CDyLib)) - .any(|target| target.crate_types.iter().any(|s| s == &CrateType::CDyLib)); - if any_cdylib { + let has = |needle: CrateType| { + pkg.targets + .iter() + .any(|t| t.crate_types.iter().any(|c| c == &needle)) + }; + let has_cdylib = has(CrateType::CDyLib); + let has_staticlib = has(CrateType::StaticLib); + + if emscripten { + if has_staticlib { + return Ok(()); + } + if has_cdylib { + bail!( + "Targeting wasm32-unknown-emscripten requires crate-type = [\"staticlib\"], \ + but this crate is configured for cdylib. emcc links the staticlib into the \ + final wasm; cdylib would skip emcc entirely.\n\n\ + Change your Cargo.toml to:\n\n\ + [lib]\n\ + crate-type = [\"staticlib\"]" + ) + } + bail!( + "Targeting wasm32-unknown-emscripten requires crate-type = [\"staticlib\"]. \ + Add the following to your Cargo.toml:\n\n\ + [lib]\n\ + crate-type = [\"staticlib\"]" + ) + } + + if has_cdylib { return Ok(()); } + if has_staticlib { + bail!( + "This crate is configured as a staticlib, which is only used by the \ + wasm32-unknown-emscripten target. For wasm32-unknown-unknown (the default), \ + use cdylib instead:\n\n\ + [lib]\n\ + crate-type = [\"cdylib\", \"rlib\"]" + ) + } bail!( "crate-type must be cdylib to compile to WebAssembly. Add the following to your \ Cargo.toml file:\n\n\ @@ -628,6 +665,7 @@ impl CrateData { scope: &Option, disable_dts: bool, target: Target, + emscripten: bool, ) -> Result<()> { let pkg_file_path = out_dir.join("package.json"); // Check if a `package.json` was already generated by wasm-bindgen, if so @@ -640,13 +678,28 @@ impl CrateData { } else { None }; - let npm_data = match target { - Target::Nodejs => self.to_commonjs(scope, disable_dts, existing_deps, out_dir), - Target::NoModules => self.to_nomodules(scope, disable_dts, existing_deps, out_dir), - Target::Bundler => self.to_esmodules(scope, disable_dts, existing_deps, out_dir), - Target::Web => self.to_web(scope, disable_dts, existing_deps, out_dir), - // Deno does not need package.json - Target::Deno => return Ok(()), + + // Emscripten emits a single ESM module (`.mjs`) + bare wasm, + // regardless of which wasm-pack `--target` is selected. The + // conventional wasm-pack manifest layout (`.js` + `_bg.wasm` + // + `sideEffects: ["./snippets/*"]`) doesn't match. Use a + // tailored variant. + let npm_data = if emscripten { + // Deno historically skips package.json — keep that for parity. + if matches!(target, Target::Deno) { + return Ok(()); + } + self.to_emscripten(scope, disable_dts, existing_deps, out_dir) + } else { + match target { + Target::Nodejs => self.to_commonjs(scope, disable_dts, existing_deps, out_dir), + Target::NoModules => self.to_nomodules(scope, disable_dts, existing_deps, out_dir), + Target::Bundler => self.to_esmodules(scope, disable_dts, existing_deps, out_dir), + Target::Web => self.to_web(scope, disable_dts, existing_deps, out_dir), + Target::Module => self.to_web(scope, disable_dts, existing_deps, out_dir), + // Deno does not need package.json + Target::Deno => return Ok(()), + } }; let npm_json = format!("{}\n", serde_json::to_string_pretty(&npm_data)?); @@ -826,6 +879,81 @@ impl CrateData { }) } + /// Package manifest for the emscripten pipeline. + /// + /// Differs from `to_web` in three places: + /// - `main` is `.mjs` (the ESM module emcc emits) + /// - `files` lists `.wasm`, `.mjs`, optional `.d.ts` + /// (no `_bg.wasm`, no `_bg.js`) + /// - `sideEffects` omits `./snippets/*` because emscripten builds + /// don't generate JS snippets the way wasm-bindgen's default + /// pipeline does + fn to_emscripten( + &self, + scope: &Option, + disable_dts: bool, + dependencies: Option>, + out_dir: &Path, + ) -> NpmPackage { + let name_prefix = self.name_prefix(); + let main = format!("{}.mjs", name_prefix); + let wasm = format!("{}.wasm", name_prefix); + let mut files = vec![wasm, main.clone()]; + + let dts_file = if !disable_dts { + let dts = format!("{}.d.ts", name_prefix); + files.push(dts.clone()); + Some(dts) + } else { + None + }; + + // Include any user-supplied LICENSE-* sidecars, mirroring npm_data. + if let Ok(entries) = fs::read_dir(out_dir) { + for entry in entries.flatten() { + if let Ok(name) = entry.file_name().into_string() { + if name.starts_with("LICENSE") && name != "LICENSE" { + files.push(name); + } + } + } + } + + let pkg = &self.data.packages[self.current_idx]; + let npm_name = match scope { + Some(s) => format!("@{}/{}", s, pkg.name), + None => pkg.name.to_string(), + }; + let keywords = if !pkg.keywords.is_empty() { + Some(pkg.keywords.clone()) + } else { + None + }; + + self.check_optional_fields(); + + NpmPackage::ESModulesPackage(ESModulesPackage { + name: npm_name, + ty: "module".into(), + collaborators: pkg.authors.clone(), + description: pkg.description.clone(), + version: pkg.version.to_string(), + license: self.license(), + repository: pkg.repository.clone().map(|url| Repository { + ty: "git".to_string(), + url, + }), + files, + main, + homepage: pkg.homepage.clone(), + types: dts_file, + // No snippets in the emscripten flow. + side_effects: vec![], + keywords, + dependencies, + }) + } + fn to_nomodules( &self, scope: &Option, diff --git a/src/wasm_opt.rs b/src/wasm_opt.rs index 8dd81d30..2d1a3015 100644 --- a/src/wasm_opt.rs +++ b/src/wasm_opt.rs @@ -29,7 +29,14 @@ pub fn run(cache: &Cache, out_dir: &Path, args: &[String], install_permitted: bo let tmp = path.with_extension("wasm-opt.wasm"); let mut cmd = Command::new(&wasm_opt_path); - cmd.arg(&path).arg("-o").arg(&tmp).args(args); + cmd.arg(&path).arg("-o").arg(&tmp); + // Enable the full Wasm 3.0 feature set so that modules using + // exception-handling, bulk-memory, tail-call, GC, etc. validate. The + // user's configured args are appended last so they can still override. + for feature in WASM_3_FEATURES { + cmd.arg(format!("--enable-{feature}")); + } + cmd.args(args); child::run(cmd, "wasm-opt")?; std::fs::rename(&tmp, &path)?; } @@ -37,6 +44,28 @@ pub fn run(cache: &Cache, out_dir: &Path, args: &[String], install_permitted: bo Ok(()) } +/// Features that comprise the Wasm 3.0 standard. Passed to `wasm-opt` so that +/// any module conforming to Wasm 3.0 validates. +const WASM_3_FEATURES: &[&str] = &[ + // Wasm 2.0 + "multivalue", + "reference-types", + "bulk-memory", + "sign-ext", + "nontrapping-float-to-int", + "mutable-globals", + "simd", + // Wasm 3.0 + "tail-call", + "exception-handling", + "gc", + "memory64", + "multimemory", + "extended-const", + "relaxed-simd", + "threads", +]; + /// Attempts to find `wasm-opt` in `PATH` locally, or failing that downloads a /// precompiled binary. /// diff --git a/tests/all/emscripten.rs b/tests/all/emscripten.rs new file mode 100644 index 00000000..08b5bfa4 --- /dev/null +++ b/tests/all/emscripten.rs @@ -0,0 +1,353 @@ +//! Integration tests for `wasm-pack build` against the +//! `wasm32-unknown-emscripten` target. +//! +//! Strategy: +//! 1. Detect whether `emcc` is reachable. If not, skip with an explanatory +//! message — contributors without emsdk can still run the rest of the +//! suite. CI installs emsdk via `setup-emsdk`. +//! 2. For each supported wasm-pack `--target` value (bundler / web / +//! module / nodejs / deno) build the `emscripten_hello_world` fixture +//! and exercise the full #[wasm_bindgen] surface from Node by: +//! - importing the produced `.mjs` ESM +//! - installing a `globalThis.rs_test_doubler` for the Rust→JS path +//! - awaiting the emscripten factory +//! - calling every exported function/class and asserting results +//! 3. Verify `--target no-modules` is rejected with a clear message. +//! 4. Verify `wasm-pack test` rejects the emscripten target. + +use crate::utils; +use assert_cmd::prelude::*; +use std::path::Path; +use std::process::Command; + +/// Returns true if `emcc` is reachable (either on PATH or via `$EMSDK`). +fn emcc_available() -> bool { + if which::which("emcc").is_ok() { + return true; + } + if let Ok(emsdk) = std::env::var("EMSDK") { + return Path::new(&emsdk).join("upstream/emscripten/emcc").exists(); + } + false +} + +// Escape hatch: setting `WASM_BINDGEN_BIN=/path/to/wasm-bindgen` in the +// environment makes wasm-pack drive that binary instead of installing the +// version pinned by the fixture's lockfile. Useful for validating +// unreleased wasm-bindgen fixes from a sibling checkout against the current +// wasm-pack. Plumbed through `wasm-pack` itself (see src/install/mod.rs); +// these tests don't need to do anything special — the env var flows +// through `fixture.wasm_pack()` because it doesn't strip the parent env. + +/// Optional `$EMSCRIPTEN_BIN` escape hatch pointing at a specific `emcc` +/// (e.g. an emscripten `main` checkout with patches not yet in a tagged +/// release). When set, returns the binary path; callers are expected to +/// prepend the containing directory to the spawned command's `PATH` so +/// wasm-pack's `which::which("emcc")` resolution finds it first. Currently +/// unused but kept as a debugging hook for testing future emcc fixes +/// against the current wasm-pack. +#[allow(dead_code)] +fn patched_emcc_bin() -> Option { + let p = std::path::PathBuf::from(std::env::var("EMSCRIPTEN_BIN").ok()?); + p.is_file().then_some(p) +} + +/// Prepend `dir` to `$PATH` on `cmd`. Companion to `patched_emcc_bin` for +/// injecting an alternative emcc directory ahead of the inherited PATH. +/// Uses `std::env::join_paths` so the platform-correct separator (`;` on +/// Windows, `:` elsewhere) is applied. +#[allow(dead_code)] +fn prepend_path(cmd: &mut Command, dir: &Path) { + let existing = std::env::var_os("PATH").unwrap_or_default(); + let mut entries = vec![dir.to_path_buf()]; + entries.extend(std::env::split_paths(&existing)); + let joined = std::env::join_paths(entries).expect("PATH entries should be valid"); + cmd.env("PATH", joined); +} + +/// Skip the calling test (with an explanatory message) if emcc isn't +/// available. CI is expected to install emsdk before running tests. +macro_rules! skip_without_emcc { + () => { + if !emcc_available() { + eprintln!( + "skipping: emcc not found on PATH and $EMSDK is unset. \ + Install emsdk and `source emsdk_env.sh` to enable these tests." + ); + return; + } + }; +} + +/// Node driver that exercises every export of the fixture. Returns the +/// driver as a JS source string ready to feed to `node --input-type=module`. +/// +/// Each check prints `PASS ` or `FAIL : got , want ` and +/// sets `process.exitCode = 1` on any failure. The full surface is driven in +/// one node invocation so we catch interactions between different codegen +/// paths (e.g. heap reallocation invalidating cached views). +fn make_node_driver(mjs_path: &Path) -> String { + // JSON-encode the path so backslashes (Windows) and unusual characters + // can't break out of the JS string literal. + let mjs_json = serde_json::to_string(&format!("file://{}", mjs_path.display())) + .expect("path should serialise"); + format!( + r#" + globalThis.rs_test_doubler = (n) => n * 2; + const {{ default: M }} = await import({mjs}); + const m = await M(); + + function expect(name, got, want) {{ + const ok = JSON.stringify(got) === JSON.stringify(want); + console.log(`${{ok ? 'PASS' : 'FAIL'}} ${{name}}: got ${{JSON.stringify(got)}}${{ok ? '' : `, want ${{JSON.stringify(want)}}`}}`); + if (!ok) process.exitCode = 1; + }} + + expect('rs_add', m.rs_add(17, 25), 42); + expect('rs_greet', m.rs_greet('world'), 'hello, world!'); + expect('rs_make_adder', m.rs_make_adder(5)(10), 15); + expect('rs_sum', m.rs_sum(Float64Array.from([1, 2, 3, 4])), 10); + expect('rs_xor', m.rs_xor(Uint8Array.from([1, 2, 4])), 7); + expect('rs_divide ok', m.rs_divide(20, 4), 5); + + try {{ + m.rs_divide(20, 0); + expect('rs_divide throws', 'did not throw', 'threw'); + }} catch (e) {{ + const msg = String(e.message || e); + expect('rs_divide throws', msg.includes('division by zero') ? 'threw' : msg, 'threw'); + }} + + const c = new m.Counter(10); + expect('Counter ctor', c.value, 10); + expect('Counter.increment', c.increment(5), 15); + expect('Counter.value', c.value, 15); + + expect('rs_double_via_js', m.rs_double_via_js(21), 42); + + // `rs_hostname` exercises `#[wasm_bindgen(module = "node:os")]` — + // wasm-bindgen routes the import through `library_bindgen.extern-pre.js`. + // We don't assert the exact value (hostnames vary across CI runners), + // just that the call doesn't throw and returns a non-empty string. + const hn = m.rs_hostname(); + expect('rs_hostname returns string', typeof hn, 'string'); + expect('rs_hostname non-empty', hn.length > 0, true); + + // `js_namespace = console` — calling a method on a global namespace. + // We stub `console.log` to capture the call and restore it after. + const origLog = console.log; + let captured = null; + console.log = (s) => {{ captured = s; }}; + m.rs_log('namespaced log'); + console.log = origLog; + expect('rs_log via console.log', captured, 'namespaced log'); + + // `js_namespace = posix` + `module = "node:path"` — namespace on an + // ESM-imported binding, not on globalThis. + expect('rs_path_posix_join', m.rs_path_posix_join('a', 'b'), 'a/b'); + + // Class export with `js_namespace = ["app", "math"]` — the class + // attaches at `Module.app.math.Calc` rather than `Module.Calc`. + expect('Calc lives under app.math', typeof m.app?.math?.Calc, 'function'); + const calc = new m.app.math.Calc(21); + expect('Calc.double', calc.double(), 42); + "#, + mjs = mjs_json, + ) +} + +/// Drive the test's Node smoke check against the produced `.mjs`. +fn assert_module_runs_in_node(pkg_dir: &Path, module_name: &str) { + let mjs = pkg_dir.join(format!("{module_name}.mjs")); + assert!(mjs.exists(), "expected {mjs:?} to exist"); + + let output = Command::new("node") + .arg("--input-type=module") + .arg("-e") + .arg(make_node_driver(&mjs)) + .output() + .expect("failed to spawn node"); + + assert!( + output.status.success(), + "node test failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +/// Helper that does a full `wasm-pack build --target ` against the +/// fixture and runs the comprehensive Node driver. `wasm-pack` reads the +/// fixture's lockfile and downloads the matching `wasm-bindgen` CLI from +/// crates.io's release artifacts. +fn run_build_and_smoke(target: &str) { + skip_without_emcc!(); + let fixture = utils::fixture::emscripten_hello_world(); + fixture + .wasm_pack() + .arg("build") + .arg("--target") + .arg(target) + .assert() + .success(); + assert_module_runs_in_node(&fixture.path.join("pkg"), "em_hello_world"); +} + +#[test] +fn emscripten_build_bundler() { + run_build_and_smoke("bundler"); +} + +#[test] +fn emscripten_build_web() { + run_build_and_smoke("web"); +} + +#[test] +fn emscripten_build_module_uses_source_phase_imports() { + skip_without_emcc!(); + let fixture = utils::fixture::emscripten_hello_world(); + // `--no-opt` works around an emcc limitation: its bundled acorn JS + // optimizer doesn't yet parse `import source` syntax at -O2+ (the + // emitted JS crashes its own optimizer). `acorn-import-phases` has + // landed on emscripten `main`; drop --no-opt once it's in a tagged + // release. + fixture + .wasm_pack() + .arg("build") + .arg("--target") + .arg("module") + .arg("--no-opt") + .assert() + .success(); + + let pkg = fixture.path.join("pkg"); + let mjs = pkg.join("em_hello_world.mjs"); + let body = std::fs::read_to_string(&mjs).unwrap(); + // The `module` target is the one that motivates source-phase imports. + assert!( + body.contains("import source"), + "--target module output should use `import source` for the wasm; got:\n{}", + &body[..body.len().min(2000)], + ); + + assert_module_runs_in_node(&pkg, "em_hello_world"); +} + +#[test] +fn emscripten_build_nodejs() { + run_build_and_smoke("nodejs"); +} + +#[test] +fn emscripten_build_deno() { + run_build_and_smoke("deno"); +} + +#[test] +fn emscripten_build_no_modules_is_rejected() { + skip_without_emcc!(); + let fixture = utils::fixture::emscripten_hello_world(); + fixture + .wasm_pack() + .arg("build") + .arg("--target") + .arg("no-modules") + .assert() + .failure() + .stderr(predicates::str::contains( + "`--target no-modules` is not supported for wasm32-unknown-emscripten", + )); +} + +#[test] +fn emscripten_package_json_points_at_mjs() { + skip_without_emcc!(); + let fixture = utils::fixture::emscripten_hello_world(); + fixture + .wasm_pack() + .arg("build") + .arg("--target") + .arg("web") + .assert() + .success(); + + let pkg_json: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(fixture.path.join("pkg/package.json")).unwrap(), + ) + .unwrap(); + assert_eq!(pkg_json["main"], "em_hello_world.mjs"); + assert_eq!(pkg_json["type"], "module"); + assert_eq!(pkg_json["types"], "em_hello_world.d.ts"); + let files = pkg_json["files"].as_array().unwrap(); + let names: Vec<&str> = files.iter().map(|v| v.as_str().unwrap()).collect(); + assert!( + names.contains(&"em_hello_world.mjs"), + "expected .mjs in files: {names:?}" + ); + assert!( + names.contains(&"em_hello_world.wasm"), + "expected .wasm in files: {names:?}" + ); + assert!( + !names + .iter() + .any(|n| n.ends_with("_bg.wasm") || n.ends_with("_bg.js")), + "emscripten pkg should not list `_bg.*` artifacts: {names:?}" + ); +} + +#[test] +fn emscripten_dts_is_decorated_with_factory_shape() { + skip_without_emcc!(); + let fixture = utils::fixture::emscripten_hello_world(); + fixture + .wasm_pack() + .arg("build") + .arg("--target") + .arg("web") + .assert() + .success(); + + let dts = std::fs::read_to_string(fixture.path.join("pkg/em_hello_world.d.ts")).unwrap(); + // wasm-bindgen's own surface should be present untouched. + assert!( + dts.contains("interface BindgenModule"), + "bindgen-emitted BindgenModule interface should be preserved" + ); + // wasm-pack appends the factory shape on top. + for needle in [ + "export type MainModule = BindgenModule", + "declare function ModuleFactory(opts?: object): Promise", + "export default ModuleFactory", + ] { + assert!( + dts.contains(needle), + "expected decorated .d.ts to contain {needle:?}; got:\n{dts}" + ); + } + // We must NOT claim runtime members that aren't actually on the + // returned Module object (would be a typing lie). + for forbidden in ["EmscriptenRuntime", "HEAPU8", "HEAP8"] { + assert!( + !dts.contains(forbidden), + "decorated .d.ts must not claim {forbidden:?} (not exposed on the factory return); got:\n{dts}" + ); + } +} + +#[test] +fn emscripten_test_command_is_rejected() { + // No emcc gating — this path doesn't actually invoke emcc, just + // verifies the early-rejection logic in `wasm-pack test`. + let fixture = utils::fixture::emscripten_hello_world(); + fixture + .wasm_pack() + .arg("test") + .arg("--node") + .assert() + .failure() + .stderr(predicates::str::contains( + "does not currently support the wasm32-unknown-emscripten target", + )); +} diff --git a/tests/all/main.rs b/tests/all/main.rs index a7cdb8bf..8bdd9db4 100644 --- a/tests/all/main.rs +++ b/tests/all/main.rs @@ -14,6 +14,7 @@ extern crate wasm_pack; mod build; mod download; +mod emscripten; mod generate; mod license; mod lockfile; diff --git a/tests/all/manifest.rs b/tests/all/manifest.rs index af8e6725..8c97f6fc 100644 --- a/tests/all/manifest.rs +++ b/tests/all/manifest.rs @@ -44,7 +44,7 @@ fn it_checks_has_cdylib_default_path() { // Ensure that there is a `Cargo.lock`. fixture.cargo_check(); let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); - assert!(crate_data.check_crate_config().is_err()); + assert!(crate_data.check_crate_config(false).is_err()); } #[test] @@ -53,14 +53,14 @@ fn it_checks_has_cdylib_provided_path() { // Ensure that there is a `Cargo.lock`. fixture.cargo_check(); let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); - crate_data.check_crate_config().unwrap(); + crate_data.check_crate_config(false).unwrap(); } #[test] fn it_checks_has_cdylib_wrong_crate_type() { let fixture = fixture::bad_cargo_toml(); let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); - assert!(crate_data.check_crate_config().is_err()); + assert!(crate_data.check_crate_config(false).is_err()); } #[test] @@ -69,7 +69,7 @@ fn it_recognizes_a_map_during_depcheck() { // Ensure that there is a `Cargo.lock`. fixture.cargo_check(); let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); - crate_data.check_crate_config().unwrap(); + crate_data.check_crate_config(false).unwrap(); } #[test] @@ -79,7 +79,7 @@ fn it_creates_a_package_json_default_path() { let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); assert!(crate_data - .write_package_json(&out_dir, &None, false, Target::Bundler) + .write_package_json(&out_dir, &None, false, Target::Bundler, false) .is_ok()); let package_json_path = &fixture.path.join("pkg").join("package.json"); fs::metadata(package_json_path).unwrap(); @@ -119,7 +119,7 @@ fn it_creates_a_package_json_provided_path() { let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); assert!(crate_data - .write_package_json(&out_dir, &None, false, Target::Bundler) + .write_package_json(&out_dir, &None, false, Target::Bundler, false) .is_ok()); let package_json_path = &fixture.path.join("pkg").join("package.json"); fs::metadata(package_json_path).unwrap(); @@ -149,7 +149,13 @@ fn it_creates_a_package_json_provided_path_with_scope() { let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); assert!(crate_data - .write_package_json(&out_dir, &Some("test".to_string()), false, Target::Bundler,) + .write_package_json( + &out_dir, + &Some("test".to_string()), + false, + Target::Bundler, + false + ) .is_ok()); let package_json_path = &fixture.path.join("pkg").join("package.json"); fs::metadata(package_json_path).unwrap(); @@ -179,7 +185,7 @@ fn it_creates_a_pkg_json_with_correct_files_on_node() { let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); assert!(crate_data - .write_package_json(&out_dir, &None, false, Target::Nodejs) + .write_package_json(&out_dir, &None, false, Target::Nodejs, false) .is_ok()); let package_json_path = &out_dir.join("package.json"); fs::metadata(package_json_path).unwrap(); @@ -213,7 +219,7 @@ fn it_creates_a_pkg_json_with_correct_files_on_nomodules() { let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); assert!(crate_data - .write_package_json(&out_dir, &None, false, Target::NoModules) + .write_package_json(&out_dir, &None, false, Target::NoModules, false) .is_ok()); let package_json_path = &out_dir.join("package.json"); fs::metadata(package_json_path).unwrap(); @@ -247,7 +253,7 @@ fn it_creates_a_package_json_with_correct_files_when_out_name_is_provided() { let crate_data = manifest::CrateData::new(&fixture.path, Some("index".to_owned())).unwrap(); wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); assert!(crate_data - .write_package_json(&out_dir, &None, false, Target::Bundler) + .write_package_json(&out_dir, &None, false, Target::Bundler, false) .is_ok()); let package_json_path = &fixture.path.join("pkg").join("package.json"); fs::metadata(package_json_path).unwrap(); @@ -280,7 +286,7 @@ fn it_creates_a_pkg_json_in_out_dir() { let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); assert!(crate_data - .write_package_json(&out_dir, &None, false, Target::Bundler) + .write_package_json(&out_dir, &None, false, Target::Bundler, false) .is_ok()); let package_json_path = &fixture.path.join(&out_dir).join("package.json"); @@ -295,7 +301,7 @@ fn it_creates_a_package_json_with_correct_keys_when_types_are_skipped() { let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); assert!(crate_data - .write_package_json(&out_dir, &None, true, Target::Bundler) + .write_package_json(&out_dir, &None, true, Target::Bundler, false) .is_ok()); let package_json_path = &out_dir.join("package.json"); fs::metadata(package_json_path).unwrap(); @@ -351,7 +357,7 @@ fn it_creates_a_package_json_with_npm_dependencies_provided_by_wasm_bindgen() { ) .unwrap(); assert!(crate_data - .write_package_json(&out_dir, &None, true, Target::Bundler) + .write_package_json(&out_dir, &None, true, Target::Bundler, false) .is_ok()); let package_json_path = &out_dir.join("package.json"); fs::metadata(package_json_path).unwrap(); @@ -388,7 +394,7 @@ fn it_creates_a_package_json_with_npm_dependencies_provided_by_wasm_bindgen() { fn it_errors_when_wasm_bindgen_is_not_declared() { let fixture = fixture::bad_cargo_toml(); let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); - assert!(crate_data.check_crate_config().is_err()); + assert!(crate_data.check_crate_config(false).is_err()); } #[test] @@ -423,7 +429,7 @@ fn it_sets_homepage_field_if_available_in_cargo_toml() { wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); crate_data - .write_package_json(&out_dir, &None, true, Target::Bundler) + .write_package_json(&out_dir, &None, true, Target::Bundler, false) .unwrap(); let pkg = utils::manifest::read_package_json(&fixture.path, &out_dir).unwrap(); @@ -439,7 +445,7 @@ fn it_sets_homepage_field_if_available_in_cargo_toml() { wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); crate_data - .write_package_json(&out_dir, &None, true, Target::Bundler) + .write_package_json(&out_dir, &None, true, Target::Bundler, false) .unwrap(); let pkg = utils::manifest::read_package_json(&fixture.path, &out_dir).unwrap(); @@ -478,7 +484,7 @@ fn it_sets_keywords_field_if_available_in_cargo_toml() { wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); crate_data - .write_package_json(&out_dir, &None, true, Target::Bundler) + .write_package_json(&out_dir, &None, true, Target::Bundler, false) .unwrap(); let pkg = utils::manifest::read_package_json(&fixture.path, &out_dir).unwrap(); @@ -496,7 +502,7 @@ fn it_sets_keywords_field_if_available_in_cargo_toml() { wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); crate_data - .write_package_json(&out_dir, &None, true, Target::Bundler) + .write_package_json(&out_dir, &None, true, Target::Bundler, false) .unwrap(); let pkg = utils::manifest::read_package_json(&fixture.path, &out_dir).unwrap(); @@ -509,7 +515,7 @@ fn it_does_not_error_when_wasm_bindgen_is_declared() { // Ensure that there is a `Cargo.lock`. fixture.cargo_check(); let crate_data = manifest::CrateData::new(&fixture.path, None).unwrap(); - crate_data.check_crate_config().unwrap(); + crate_data.check_crate_config(false).unwrap(); } #[test] @@ -595,7 +601,7 @@ fn it_lists_license_files_in_files_field_of_package_json() { wasm_pack::command::utils::create_pkg_dir(&out_dir).unwrap(); license::copy_from_crate(&crate_data, &fixture.path, &out_dir).unwrap(); crate_data - .write_package_json(&out_dir, &None, false, Target::Bundler) + .write_package_json(&out_dir, &None, false, Target::Bundler, false) .unwrap(); let package_json_path = &fixture.path.join("pkg").join("package.json"); diff --git a/tests/all/utils/fixture.rs b/tests/all/utils/fixture.rs index fae0c55c..1b7723f4 100644 --- a/tests/all/utils/fixture.rs +++ b/tests/all/utils/fixture.rs @@ -449,6 +449,211 @@ pub fn js_hello_world() -> Fixture { fixture } +/// Fixture targeting wasm32-unknown-emscripten with a broad #[wasm_bindgen] +/// surface — each export exercises a distinct codegen path so a regression +/// in any one of them is caught: +/// - `rs_add` — primitive args/returns (baseline) +/// - `rs_greet` — string passing / cachedTextEncoder / $heap +/// - `rs_make_adder` — closure construction / CLOSURE_DTORS / externref +/// - `rs_sum` — typed slice → HEAPF64 / HEAP_DATA_VIEW +/// - `rs_xor` — byte slice → HEAPU8 +/// - `rs_divide` — Result error propagation +/// - `Counter` — class with constructor + method + getter +/// - `rs_double_via_js` — Rust calling out to a JS-provided import +/// +/// Crate config: +/// - `crate-type = ["staticlib"]` (emcc consumes the .a) +/// - `.cargo/config.toml` selects the emscripten target so the user +/// doesn't need to pass `-- --target wasm32-unknown-emscripten` +pub fn emscripten_hello_world() -> Fixture { + let fixture = Fixture::new(); + + // The emscripten output-mode fixes landed in wasm-bindgen 0.2.122 + // (https://github.com/wasm-bindgen/wasm-bindgen/pull/5156); pin to that + // release so the descriptor format used by the macro matches the CLI + // wasm-pack installs from crates.io. + let cargo_toml = r#"[package] +authors = ["The wasm-pack developers"] +description = "emscripten + wasm-bindgen integration test" +license = "WTFPL" +name = "em-hello-world" +repository = "https://github.com/wasm-bindgen/wasm-pack.git" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib"] + +[dependencies] +wasm-bindgen = "=0.2.122" +js-sys = "0.3" +"#; + + fixture + .readme() + .file("Cargo.toml", cargo_toml) + .file( + ".cargo/config.toml", + r#" +[build] +target = "wasm32-unknown-emscripten" + +[target.wasm32-unknown-emscripten] +rustflags = [ + "-Cllvm-args=-enable-emscripten-cxx-exceptions=0", + "-Cpanic=abort", + "-Crelocation-model=static", +] + "#, + ) + .file( + "src/lib.rs", + r#" +use js_sys::Function; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn rs_add(a: i32, b: i32) -> i32 { + a + b +} + +#[wasm_bindgen] +pub fn rs_greet(name: &str) -> String { + format!("hello, {name}!") +} + +#[wasm_bindgen] +pub fn rs_make_adder(n: i32) -> Function { + let f = Closure:: i32>::new(move |x| x + n); + let ret = f.as_ref().unchecked_ref::().clone(); + f.forget(); + ret +} + +#[wasm_bindgen] +pub fn rs_sum(values: &[f64]) -> f64 { + values.iter().sum() +} + +#[wasm_bindgen] +pub fn rs_xor(bytes: &[u8]) -> u8 { + bytes.iter().fold(0u8, |acc, &b| acc ^ b) +} + +#[wasm_bindgen] +pub fn rs_divide(num: i32, den: i32) -> Result { + if den == 0 { + Err(JsError::new("division by zero")) + } else { + Ok(num / den) + } +} + +#[wasm_bindgen] +pub struct Counter { + value: i32, +} + +#[wasm_bindgen] +impl Counter { + #[wasm_bindgen(constructor)] + pub fn new(initial: i32) -> Counter { + Counter { value: initial } + } + + pub fn increment(&mut self, by: i32) -> i32 { + self.value += by; + self.value + } + + #[wasm_bindgen(getter)] + pub fn value(&self) -> i32 { + self.value + } +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = globalThis, js_name = rs_test_doubler)] + fn js_doubler(n: i32) -> i32; +} + +#[wasm_bindgen] +pub fn rs_double_via_js(n: i32) -> i32 { + js_doubler(n) +} + +// `#[wasm_bindgen(module = "...")]` ESM imports — wasm-bindgen emits these +// to `library_bindgen.extern-pre.js` (an --extern-pre-js sidecar) so they +// live at module top-level above emcc's modularize wrapper. The library +// functions inlined into the wrapper close over the imported bindings +// lexically. +#[wasm_bindgen(module = "node:os")] +extern "C" { + #[wasm_bindgen(js_name = hostname)] + fn node_os_hostname() -> String; +} + +#[wasm_bindgen] +pub fn rs_hostname() -> String { + node_os_hostname() +} + +// `js_namespace = console` — single-level namespace on a global object. +// Resolves at JS call site as `console.log(...)`. +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console, js_name = log)] + fn console_log(msg: &str); +} + +/// Calls `console.log(...)` from Rust. The integration test stubs out +/// `console.log` to capture the call. +#[wasm_bindgen] +pub fn rs_log(msg: &str) { + console_log(msg); +} + +// `js_namespace` paired with `module = "..."`. The namespace resolves on +// the imported binding, not on `globalThis`. +#[wasm_bindgen(module = "node:path")] +extern "C" { + #[wasm_bindgen(js_namespace = posix, js_name = join)] + fn node_path_posix_join(a: &str, b: &str) -> String; +} + +/// Calls `posix.join(...)` on `node:path`. Exercises the +/// `js_namespace + module = "..."` combination, where the namespace +/// resolves against the imported module's exports rather than a global. +#[wasm_bindgen] +pub fn rs_path_posix_join(a: &str, b: &str) -> String { + node_path_posix_join(a, b) +} + +// Class export with `js_namespace = ["app", "math"]` — the class attaches +// at `Module.app.math.Calc` rather than `Module.Calc`. The matching +// `js_namespace` on the impl is required (the macro can't see the struct's +// attrs across invocations). +#[wasm_bindgen(js_namespace = ["app", "math"])] +pub struct Calc { + value: i32, +} + +#[wasm_bindgen(js_namespace = ["app", "math"])] +impl Calc { + #[wasm_bindgen(constructor)] + pub fn new(initial: i32) -> Calc { + Calc { value: initial } + } + pub fn double(&self) -> i32 { + self.value * 2 + } +} + "#, + ); + fixture +} + pub fn js_hello_world_with_custom_profile(profile_name: &str) -> Fixture { let fixture = Fixture::new(); fixture diff --git a/wasm-pack-emscripten-template/.cargo/config.toml b/wasm-pack-emscripten-template/.cargo/config.toml new file mode 100644 index 00000000..7ed7416c --- /dev/null +++ b/wasm-pack-emscripten-template/.cargo/config.toml @@ -0,0 +1,19 @@ +# Make `cargo build` (and `wasm-pack build`) target emscripten by default. +# Override with `--target` on the command line if needed. +[build] +target = "wasm32-unknown-emscripten" + +# Compile-time settings the emscripten target needs to produce a clean +# staticlib for emcc to link: +# * panic=abort — emcc + wasm-bindgen don't currently support panic=unwind +# across the FFI boundary. Override at your own risk. +# * relocation-model=static — the staticlib is linked directly into the +# final wasm by emcc's wasm-ld step; PIC isn't needed. +# * enable-emscripten-cxx-exceptions=0 — disables LLVM's emscripten-style +# C++ exception lowering, which would require a runtime we don't ship. +[target.wasm32-unknown-emscripten] +rustflags = [ + "-Cllvm-args=-enable-emscripten-cxx-exceptions=0", + "-Cpanic=abort", + "-Crelocation-model=static", +] diff --git a/wasm-pack-emscripten-template/Cargo.toml b/wasm-pack-emscripten-template/Cargo.toml new file mode 100644 index 00000000..474d50d3 --- /dev/null +++ b/wasm-pack-emscripten-template/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "{{project-name}}" +version = "0.1.0" +authors = ["{{authors}}"] +edition = "2021" + +# Emscripten consumes a static library — it links the Rust object code into +# the final wasm itself. `cdylib` would skip emcc entirely. +[lib] +crate-type = ["staticlib"] + +[dependencies] +# 0.2.122 is the floor that ships the emscripten output-mode fixes +# (wasm-bindgen#5156); older releases will produce broken JS/wasm under +# the emscripten target. +wasm-bindgen = "0.2.122" + +[profile.release] +# Tell `rustc` to optimize for small code size; emcc will further optimize at +# link time via wasm-opt. +opt-level = "s" diff --git a/wasm-pack-emscripten-template/LICENSE-APACHE b/wasm-pack-emscripten-template/LICENSE-APACHE new file mode 100644 index 00000000..11069edd --- /dev/null +++ b/wasm-pack-emscripten-template/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/wasm-pack-emscripten-template/LICENSE-MIT b/wasm-pack-emscripten-template/LICENSE-MIT new file mode 100644 index 00000000..a15266ca --- /dev/null +++ b/wasm-pack-emscripten-template/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2018 {{authors}} + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/wasm-pack-emscripten-template/README.md b/wasm-pack-emscripten-template/README.md new file mode 100644 index 00000000..8fc256bb --- /dev/null +++ b/wasm-pack-emscripten-template/README.md @@ -0,0 +1,56 @@ +
+ +

{{project-name}}

+ + A wasm-bindgen package targeting wasm32-unknown-emscripten. + +
+ +## Prerequisites + +This template targets the emscripten toolchain in addition to Rust. Install +[emsdk](https://emscripten.org/docs/getting_started/downloads.html) and run +`source /emsdk_env.sh` before building. + +## Build + +```sh +wasm-pack build +``` + +That produces a `pkg/` directory containing: + +- `{{project-name}}.mjs` — ESM module factory +- `{{project-name}}.wasm` — emscripten-linked wasm +- `{{project-name}}.d.ts` — TypeScript declarations +- `package.json` — npm package metadata + +## Use + +```js +import Module from "./pkg/{{project-name}}.mjs"; + +const m = await Module(); +m.greet("world"); +``` + +## Why emscripten? + +The emscripten target unlocks standard-library APIs that aren't available +under `wasm32-unknown-unknown`: + +- `std::time::{Instant, SystemTime}` +- `std::env::{current_dir, vars}` +- `std::fs` (in-memory MEMFS) +- `std::collections::HashMap` with default random state +- `rand::random()` via emscripten's `getentropy` +- POSIX-style I/O and many libc APIs + +The cost is a heavier runtime (~10-50 KB JS overhead). If you only need +pure Rust + `js-sys`/`web-sys`, the default `wasm32-unknown-unknown` target +remains the right choice — use the regular `wasm-pack-template` instead. + +## License + +Licensed under either of [Apache-2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) +at your option. diff --git a/wasm-pack-emscripten-template/cargo-generate.toml b/wasm-pack-emscripten-template/cargo-generate.toml new file mode 100644 index 00000000..fa7644d7 --- /dev/null +++ b/wasm-pack-emscripten-template/cargo-generate.toml @@ -0,0 +1,2 @@ +[template] +cargo_generate_version = ">=0.9.0" diff --git a/wasm-pack-emscripten-template/src/lib.rs b/wasm-pack-emscripten-template/src/lib.rs new file mode 100644 index 00000000..6c4da91e --- /dev/null +++ b/wasm-pack-emscripten-template/src/lib.rs @@ -0,0 +1,12 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console)] + fn log(s: &str); +} + +#[wasm_bindgen] +pub fn greet(name: &str) { + log(&format!("Hello, {name}, from {{project-name}}!")); +}