Viscosity
Review
Part XI · Viscosity in Rust · Day 48

190. The npm package

There are two ways to give npm users a Rust program: a native binary for every kind of computer, or one WebAssembly file for all of them. @viscosity/wasm takes the second, and its package.json, its licences and a test that installs it like a stranger show what that means.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Compare shipping native binaries through npm with shipping one .wasm, and say what each costs.
  • Read a conditional exports map and predict which file Node, a bundler and TypeScript load.
  • Explain why a statically linked .wasm must carry its dependencies' licences.
  • Describe how smoke.mjs tests the package as a user would get it.

Two ways to ship Rust

Chapter 119 packed the TypeScript CLI for npm: one JavaScript bundle, dist/viscosity.mjs, that runs on any computer with Node 18. Easy, because JavaScript is the same everywhere. The Rust engine is faster (chapter 192), but a compiled Rust program is not the same everywhere. cargo build --release gave me a 2,785,400-byte ELF executable for x86-64 Linux, linked against glibc. A Mac can’t run it, and neither can an ARM laptop.

Tools written in Rust, Go or C++ solve this in one of two ways, and you have used both without noticing. Look in this book’s own node_modules. The bundler esbuild 0.27.7 lists 26 optionalDependencies, @esbuild/darwin-arm64 to @esbuild/win32-x64, and each of those packages declares the os and cpu it runs on. npm compares them with the machine and skips the rest. Here only @esbuild/linux-x64 was installed: one 11,120,788-byte binary[1].

Try itOne binary per platform
use std::env::consts::{ARCH, OS};

fn main() {
    // esbuild 0.27.7's platform
    // packages, named "<os>-<cpu>".
    let all = [
        "aix-ppc64", "android-arm",
        "android-arm64", "android-x64",
        "darwin-arm64", "darwin-x64",
        "freebsd-arm64", "freebsd-x64",
        "linux-arm", "linux-arm64",
        "linux-ia32", "linux-loong64",
        "linux-mips64el", "linux-ppc64",
        "linux-riscv64", "linux-s390x",
        "linux-x64", "netbsd-arm64",
        "netbsd-x64", "openbsd-arm64",
        "openbsd-x64",
        "openharmony-arm64",
        "sunos-x64", "win32-arm64",
        "win32-ia32", "win32-x64",
    ];
    // Rust and Node name things apart.
    let os = match OS {
        "macos" => "darwin",
        "windows" => "win32",
        o => o,
    };
    let cpu = match ARCH {
        "x86_64" => "x64",
        "aarch64" => "arm64",
        "x86" => "ia32",
        a => a,
    };
    let want = format!("{os}-{cpu}");
    println!("this machine: {want}");
    let mine: Vec<_> = all
        .iter()
        .filter(|p| **p == want)
        .collect();
    println!(
        "npm installs {:?} of {}",
        mine,
        all.len()
    );
}
Open in Rust Playground ↗

The other way is to compile once to WebAssembly and let the JavaScript engine run it. wasm-bindgen’s guide offers its Node target “perhaps as an alternative to a native module”[6].

Intuition

Travelling abroad, you can pack a plug adapter for every country you might visit, or bring a device that runs on the batteries sold everywhere. The adapters give full power, but you need the right one. The batteries work anywhere, at a little less power.

Definition

Native binaries vs WebAssembly: a native build is compiled for one operating system and CPU, so it is the fastest and can use threads and files, but you ship (and build, and test) one per platform. A .wasm is one file for every platform, run by the host’s JavaScript engine. It is slower, and on wasm32-unknown-unknown the standard library can’t reach the host: std::fs returns errors and std::thread::spawn panics[4].

One package, two kinds of glue

@viscosity/wasm takes the second road. Chapter 189’s build left two folders, dist/node (CommonJS glue that loads synchronously) and dist/web (an ES module with init()). The package.json decides who gets which, with an exports map. Trimmed:

"exports": {
  ".": {
    "node": {
      "types": "./dist/node/….d.ts",
      "default": "./dist/node/….js"
    },
    "browser": {
      "types": "./dist/web/….d.ts",
      "default": "./dist/web/….js"
    },
    "types": "./dist/web/….d.ts",
    "default": "./dist/web/….js"
  },
  "./web": …, "./node": …,
  "./viscosity_wasm_bg.wasm": …
}

Each key is a condition. Node sets "node" and either "import" or "require"; a bundler building for the web uses "browser". Key order is significant: the first key whose condition is active wins, "default" always matches and so comes last, and a nested object that matches nothing falls back to its parent’s next key[2]. TypeScript reads the same map and always matches "types"[3]. Step through five importers:

Step throughWhich file does @viscosity/wasm give you?
The conditional exports of @viscosity/wasm, and the file each kind of importer resolves torequire() in Nodeconditions: node, require, default"node": {"types":dist/node/v….d.ts"default":dist/node/v….js"browser": {"types":dist/web/v….d.ts"default":dist/web/v….js"types":dist/web/v….d.ts"default":dist/web/v….js→ ./dist/node/viscosity_wasm.js
Node tries the keys of "." in order. "node" is active, so it goes in. "types" is not a condition Node sets, so it is skipped; "default" always matches. Result: the nodejs glue, CommonJS.
1/6

What is in the box

Packed at commit 288e497, the package is 13 files, 997 KiB, and 2,840 KiB once unpacked. Most of it is two copies of the same module: dist/node/viscosity_wasm_bg.wasm (1,373,454 bytes) and dist/web/viscosity_wasm_bg.wasm (1,373,390). Each glue file loads the module sitting next to it, so sharing one would mean editing generated code. The build doesn’t, and the price is 1.4 MB of disk.

There is no bin. @viscosity/cli installs a viscosity command; @viscosity/wasm is a library, for programs that want to call compile themselves: an editor plugin, a bundler plugin, this book.

The third big file is THIRD_PARTY_LICENSES.txt, 130,597 bytes. The TypeScript CLI has one too, because its bundle contains acorn and sucrase. The .wasm is the same situation, larger. It statically links 71 third-party Rust crates: oxc’s parser, indexmap, rustc-hash, the ICU4X Unicode crates, wasm-bindgen itself. Their code is inside the file you hand out, so their licence notices must go with it. build.mjs asks cargo tree which crates are linked for wasm32-unknown-unknown (proc macros, which run only at build time, are left out), finds each crate’s licence files through cargo metadata, and writes every distinct text once. One snag: oxc’s crates are published without their licence file. The package carries copies of oxc’s own files in licenses/, and the script uses them as a fallback for any oxc crate.

Installed like a stranger would

Chapter 119 found three bugs by installing the TypeScript CLI in an empty folder. smoke.mjs does that for this package, every time: npm pack, install the tarball into a fresh directory, then compile every corpus program through the installed package, 40 JavaScript and 20 Python, in three ways, and compare code and explain with what the TypeScript CLI prints. Finally it type-checks a small consumer under both the Node and the browser conditions. My run, at commit 288e497 (repeated with the same results after the version became 0.0.1), trimmed:

packed viscosity-wasm-0.0.1.tgz:
  13 files, 997 KiB (2840 KiB unpacked)
✓ nodejs target, require():
  {"programs":60,"mismatches":0}
✓ nodejs target, import:
  {"programs":60,"mismatches":0}
✓ web target, init(bytes):
  {"programs":60,"mismatches":0}
✓ types (node, nodenext)
✓ types (browser, bundler)

The web variant runs in Node too, by passing the .wasm bytes to init() instead of a URL. The package isn’t published: like the CLI, everything up to npm publish has been walked.

Under the hood

The build itself relies on a WebAssembly package. Binaryen is written in C++ and publishes native releases for five platforms, plus a Node build: “A port of wasm-opt to JavaScript+WebAssembly … on any platform that Node.js runs on”[5]. The npm binaryen package that build.mjs finds is that port. Its bin/wasm-opt is one 11 MB file: a JavaScript loader with the module embedded in it. That’s why npm install was enough on the build machine, whatever it was. The cost showed in chapter 189: about 25 seconds for each -O3 run.

Your turn

ExerciseResolving conditional exports

Write resolve, Node’s rule for a conditional "exports" value. A File is the answer. For Conds, try the entries in the map’s order: an entry applies if its key is "default" or is in conds. If it applies, resolve its value; if that gives a file, return it, and if not, carry on with the next entry. Return None if nothing matches.

// A value in an "exports" map: a file,
// or conditions tried in this order.
enum Target {
    File(&'static str),
    Conds(Vec<(&'static str, Target)>),
}
use Target::*;

// The file `t` gives an importer whose
// active conditions are `conds`.
fn resolve(
    t: &Target,
    conds: &[&str],
) -> Option<&'static str> {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • Native builds are fastest but per platform. esbuild ships 26 packages, and npm installs the one whose os and cpu match.
  • A .wasm is one file for every platform, run by the JS engine, without threads or files on wasm32-unknown-unknown.
  • An exports map is tried in key order: first active condition wins, "default" last, nested misses fall through.
  • A statically linked .wasm must carry its crates’ licences: 71 of them here.
  • smoke.mjs installs the tarball and checks 60 programs three ways against the TS CLI.

Check your understanding

0/6
  1. 1.

    esbuild lists 26 platform packages. How many end up on a typical machine after npm install?

  2. 2.

    Why does @viscosity/wasm need no os, cpu or platform packages?

  3. 3.

    The exports map lists "node", then "browser", then "types", then "default". A bundler building for the web asks with ["browser", "import"]. What does it get?

  4. 4.

    What does the .wasm statically linking 71 crates mean for the package?

  5. 5.

    Why does smoke.mjs install the packed tarball into an empty folder instead of importing dist/ directly?

  6. 6.

    Which is a real cost of the WebAssembly route, compared with native binaries?

References

  1. [1] npm, Inc. (GitHub) and the npm CLI contributors. package.json: optionalDependencies, os and cpu. npm Docs; source docs/lib/content/configuring-npm/package-json.md in github.com/npm/cli. docs.npmjs.com/cli/configuring-npm/package-json (checked 2026-10-31)
  2. [2] The Node.js contributors. Node.js documentation: Modules: Packages (package entry points, conditional exports). Node.js v22 documentation; source doc/api/packages.md in github.com/nodejs/node. nodejs.org/docs/latest-v22.x/api/packages.html#conditional-exports (checked 2026-10-31)
  3. [3] The TypeScript team (Microsoft). TypeScript Handbook: Modules Reference (package.json "exports"). typescriptlang.org documentation; source packages/documentation/copy/en/modules-reference/Reference.md in github.com/microsoft/TypeScript-Website. www.typescriptlang.org/docs/handbook/modules/reference.html (checked 2026-10-31)
  4. [4] The Rust Project Developers. The rustc book: wasm32-unknown-unknown. The rustc book, Platform Support; source src/doc/rustc/src/platform-support/wasm32-unknown-unknown.md in github.com/rust-lang/rust. doc.rust-lang.org/rustc/platform-support/wasm32-unknown-unknown.html (checked 2026-10-31)
  5. [5] The Binaryen contributors (WebAssembly Community Group). Binaryen: README (wasm-opt). github.com/WebAssembly/binaryen. github.com/WebAssembly/binaryen (checked 2026-10-31)
  6. [6] The wasm-bindgen contributors. The wasm-bindgen Guide. wasm-bindgen documentation (github.com/wasm-bindgen/wasm-bindgen, formerly rustwasm/wasm-bindgen; the README links the rendered guide at this URL). wasm-bindgen.github.io/wasm-bindgen/ (checked 2026-10-31)