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
- [ ]
just1.25 or newer (cargo install just,brew install just,winget install Casey.Justor 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.
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.
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"
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
shsyntax —$(...),&&chains with redirects, orexport. Move the logic into a script recipe in Node or Python. justcannot findwasm-bindgenon 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.
justdoes 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,justaccepts 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.
Related
- Setting up CI/CD for Rust Wasm projects — the pipeline that calls
just ci. - Setting up Wasm toolchains on Windows — the other half of making the file work everywhere.
- Building Rust Wasm without wasm-pack — the individual steps these recipes run.
- Reducing Wasm bundle size with wasm-opt — choosing the optimize recipe’s flags.
← Back to Cross-Platform Build Automation