Driving Wasm Builds with a justfile

This page answers one task: replace a pile of shell scripts and README instructions with a single justfile that every developer and every CI job uses to build, optimize, test and serve a WebAssembly project — on any operating system.

Prerequisites

  • [ ] just 1.25 or newer (cargo install just, brew install just, winget install Casey.Just or a release binary).
  • [ ] A WebAssembly project with more than one build step — compile, bind, optimize — which is nearly all of them.
  • [ ] The toolchain itself installed or containerised, as in pinning Wasm toolchain versions.

Why a Wasm project outgrows cargo build

A native Rust project can often live with cargo build and cargo test. A WebAssembly project rarely can, because producing something a browser can load is a pipeline: compile to wasm32-unknown-unknown, run wasm-bindgen to generate the JavaScript glue, run wasm-opt to shrink the binary, maybe precompress it, then copy it where the web app expects it. Testing has its own variants — Node, a headless browser, a WASI runtime. Serving needs specific headers. Each of those commands has flags that matter, and each developer ends up with a slightly different shell history.

make is the traditional answer and works well on Linux and macOS. On Windows it is awkward: it is not installed by default, its tab-sensitivity trips people up, and recipes written for sh break under cmd. npm scripts are cross-platform but turn multi-step recipes into unreadable one-liners and cannot easily take parameters. just sits between them: a command runner — not a build system — with readable syntax, arguments, dependencies between recipes, and a configurable shell so the same file works under PowerShell.

Three ways to name a Wasm build pipeline make handles dependencies on files but is awkward on Windows; npm scripts are cross-platform but poor at multi-step recipes and arguments; just is a cross-platform command runner with readable multi-line recipes and parameters. make file-timestamp dependencies tabs required, sh-specific recipes not installed on Windows strong on Unix, weak elsewhere npm scripts runs everywhere Node does one line per script, escaping pain arguments need -- and cross-env fine for two or three commands just readable multi-line recipes parameters with defaults per-OS shell, same file everywhere suits a multi-step Wasm pipeline

Step 1 — a first justfile

Put the file at the repository root. Recipes are names followed by a colon; the indented lines below are the commands.

# justfile
set windows-shell := ["pwsh.exe", "-NoLogo", "-Command"]

crate   := "app"
out     := "web/pkg"
profile := "release"

# list recipes when you run plain `just`
default:
    @just --list

# compile the crate to a raw .wasm
compile:
    cargo build --target wasm32-unknown-unknown --profile {{profile}}

# generate JS glue for the browser
bind: compile
    wasm-bindgen target/wasm32-unknown-unknown/{{profile}}/{{crate}}.wasm \
        --out-dir {{out}} --target web --no-typescript

# shrink the module
optimize: bind
    wasm-opt -Oz --strip-debug {{out}}/{{crate}}_bg.wasm -o {{out}}/{{crate}}_bg.wasm

build: optimize

just build now runs compile, bind and optimize in order, stopping at the first failure. The variables at the top are the knobs a developer is most likely to change, and they can be overridden on the command line: just profile=dev build.

The windows-shell setting is the line that makes the file portable. On Windows, recipes run in PowerShell; on macOS and Linux, in sh. As long as recipe lines are simple commands — which in a Wasm pipeline they almost always are — the same text works under both.

Step 2 — add parameters for variants

Wasm projects tend to need a few build variants: a size-optimized one for shipping, a speed-optimized one for benchmarks, a debug one with names intact. Recipe parameters express that without duplicating recipes.

# just release        → -Oz, stripped
# just release O3     → -O3, stripped
release level="Oz": compile bind
    wasm-opt -{{level}} --strip-debug --strip-producers \
        {{out}}/{{crate}}_bg.wasm -o {{out}}/{{crate}}_bg.wasm
    @echo "size: $(wc -c < {{out}}/{{crate}}_bg.wasm) bytes"

# keep names and DWARF for local debugging
debug:
    cargo build --target wasm32-unknown-unknown
    wasm-bindgen target/wasm32-unknown-unknown/debug/{{crate}}.wasm \
        --out-dir {{out}} --target web --keep-debug --debug

The @ prefix stops just from echoing the command itself, which keeps output readable for lines that only print. The $(wc -c < …) substitution is sh syntax; for a recipe that must also run on Windows, prefer a small cross-platform tool or a script recipe, covered next.

How recipes chain for a release build just release runs its dependencies first — compile, then bind — and then its own body, which runs wasm-opt at the level passed as a parameter and reports the final size. Any failing step stops the chain. just release O3 parameter level = O3 compile cargo build to wasm32 bind wasm-bindgen --target web wasm-opt -O3 strip debug and producers size report bytes printed

Step 3 — script recipes for logic that is not a single command

When a recipe needs a loop or a conditional, write it as a script recipe with a shebang. just writes the body to a temporary file and runs it with the named interpreter, so you can use Python or Node for the portable parts instead of fighting two shells.

# precompress every .wasm and .js in the output with brotli and gzip
compress:
    #!/usr/bin/env node
    const fs = require("fs"), zlib = require("zlib"), path = require("path");
    for (const f of fs.readdirSync("{{out}}")) {
      if (!/\.(wasm|js)$/.test(f)) continue;
      const p = path.join("{{out}}", f), buf = fs.readFileSync(p);
      fs.writeFileSync(p + ".br", zlib.brotliCompressSync(buf, {
        params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }));
      fs.writeFileSync(p + ".gz", zlib.gzipSync(buf, { level: 9 }));
      console.log(f.padEnd(24), buf.length, "→", fs.statSync(p + ".br").size, "br");
    }

Node is a good choice for a web project because every developer already has it, and it behaves identically on every operating system. The same precompressed files are what compressing Wasm with Brotli for delivery expects the server to find.

Step 4 — test, serve and check from the same file

Put every command a developer runs regularly in the file, not only the build. That turns the justfile into executable documentation: just --list is the answer to “how do I work on this project?”.

# unit tests in Node and in a headless browser
test:
    wasm-pack test --node
    wasm-pack test --headless --firefox

# serve the web app with the headers threads need
serve: build
    npx http-server web -p 8080 --brotli --gzip \
        -H "Cross-Origin-Opener-Policy: same-origin" \
        -H "Cross-Origin-Embedder-Policy: require-corp"

# everything CI checks, in the order CI checks it
ci: build compress test
    @echo "ci: ok"
Which recipe for which moment The recipes in the justfile mapped to when a developer or CI runs them, and how long each takes on a warm cache for a mid-sized crate. recipe who runs it when warm time just debug developer every edit while debugging ~4 s just build developer before trying it in the browser ~9 s just release O3 developer benchmarking a change ~25 s just test developer, CI before pushing ~40 s just ci CI every push ~70 s

The serve recipe depends on build, so it is impossible to serve a stale module by forgetting to rebuild. The headers it sets are the ones explained in configuring COOP/COEP headers for SharedArrayBuffer.

Step 5 — run the same recipes in CI

The payoff comes when CI calls the same recipes. There is then exactly one definition of what a build is, and a CI failure can be reproduced locally by running the same command.

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: extractions/setup-just@v2
      - uses: dtolnay/rust-toolchain@stable
        with: { targets: wasm32-unknown-unknown }
      - uses: taiki-e/install-action@v2
        with: { tool: "wasm-bindgen-cli@0.2.93,wasm-pack@0.13.0" }
      - run: just ci

Running the matrix across all three operating systems once is the cheapest way to find the recipe line that only works in sh. After that, most projects keep only Linux in the per-push matrix and run the others nightly.

Expected output

$ just
Available recipes:
    bind
    build
    ci
    compile
    compress
    debug
    default  # list recipes when you run plain `just`
    optimize
    release level="Oz"
    serve
    test
$ just release
cargo build --target wasm32-unknown-unknown --profile release
    Finished `release` profile [optimized] target(s) in 0.21s
wasm-bindgen target/wasm32-unknown-unknown/release/app.wasm --out-dir web/pkg --target web --no-typescript
wasm-opt -Oz --strip-debug --strip-producers web/pkg/app_bg.wasm -o web/pkg/app_bg.wasm
size: 148213 bytes

Keep the CI job’s own logic to installing tools and calling one recipe. Anything more — an extra flag, a copy step, an environment variable — belongs in the justfile, otherwise CI and local builds drift apart again one convenient shortcut at a time.

Gotchas

  • A recipe works on Linux and fails on Windows with a parse error. It uses sh syntax — $(...), && chains with redirects, or export. Move the logic into a script recipe in Node or Python.
  • just cannot find wasm-bindgen on Windows. Cargo’s bin directory is not on the PowerShell path in a fresh session. Restart the terminal after installing, or call tools by full path in the recipe.
  • Recipes always run, even when nothing changed. just does not track file timestamps — it is a command runner, not a build system. Cargo’s own incremental build makes this cheap for compilation; for slow post-processing steps, check timestamps in a script recipe if it matters.
  • Tabs versus spaces. Unlike make, just accepts either — but not a mixture within one recipe.

Performance note

just itself adds a few milliseconds per recipe; the pipeline’s cost is entirely the tools it calls. The practical speed gain is elsewhere: on the project above, new contributors went from needing roughly an hour of README-reading to a first successful local build in under ten minutes, because the only instruction was “install the toolchain, then run just build”.

Frequently Asked Questions

Should the justfile replace Cargo or npm? No. It sits on top of them. Cargo still handles compilation and dependencies, npm still handles JavaScript packages; just names the sequences of commands that use them.

Can recipes call the Docker build instead of host tools? Yes, and a common pattern is two recipes — build with host tools for iteration and build-docker for releases — as in building Wasm in Docker.

How do I load environment variables for a recipe? Add set dotenv-load at the top of the file and just reads a .env file into the environment of every recipe — useful for local server ports or API keys used by a dev server.

← Back to Cross-Platform Build Automation