packages/wasmbuilder/src/build.nu — the wasm build driver (library API).
The pipeline, end to end and fully local:
.nu ──nurlc──▶ LLVM IR ──wb_prepare_ir_for_wasi──▶ wasm32 IR ──[zig] cc --target=wasm32-wasi + runtime.wasm.o──▶ .wasm
Link flags: -Wl,--gc-sections The DEFAULT since 2026-07: drops the unreachable part of the NURL runtime from every module — ~25 % of the bytes and ~80 % of a JIT's load-the-module floor. An old note here said NURL closures (function-table indices) could not survive the table renumbering, "proven on nurlc.wasm >150 fns"; re-tested 2026-07-27 at exactly that scale — a --gc-sections nurlc.wasm SELF-COMPILES byte-identically to the native compiler under both the reference wasmtime and the pure-NURL wt, and the closure corpus (test_05/test_06) passes. Wherever that trap came from, today's wasm-ld relocates address-taken functions correctly through GC. --no-gc-sections / . opts no_gc_sections remains as the escape hatch; a trap that appears only without it would be a table-renumbering bug — please report it. (nurlapi's -Wl,--allow-undefined is replaced by per-declare "wasm-import-module"="env" IR attributes — zig cc's driver rejects the linker flag; see wasi_ir.nu. Same semantics: undefined symbols become wasm imports the host runtime resolves.)
Library entry points (embed these — no subprocess, no HTTP): ( wb_build_file nu_path out_wasm opts ) → !v String ( wb_build_source source filename opts ) → !( Vec u ) String
Other packages import src/build.nu (which pulls wasi_ir.nu + toolchain.nu); the CLI in main.nu is a thin wrapper over the same calls.
: WbOpts: WbOpts {
s opt // clang opt level: `-O0`..`-O3`, `-Oz` (nurlapi uses -O1)
b host_imports // pass --ffi-host-imports (auto-dropped on old nurlc)
b asyncify // wasm-opt asyncify wrap for canvas programs (needs binaryen)
b keep_ll // leave the rewritten .ll next to the .wasm
b quiet // suppress progress lines on stderr
s extra_obj // extra object(s), space-separated, linked in (`` = none) — e.g.
// a generated kernels_static.o so `& c` symbols resolve
// statically instead of becoming env imports
s extra_cflags // extra compile/link flags, space-separated (`` = none)
// — e.g. `-msimd128` for wasm SIMD
s asyncify_imports // comma-separated async import names (`` = none);
// e.g. `env.wgpu_download` for the WebGPU backend's async readback
b threads // build for wasi-threads: shared memory, atomics, and the
// guest-side pthread implementation in runtime.c. The module
// then imports `wasi.thread-spawn` and exports
// `wasi_thread_start` + `__stack_pointer`, so it needs a
// runtime that implements the proposal (packages/wasmtime
// does). Off by default: a shared memory is a different
// memory model, not a free upgrade.
b no_gc_sections // link with --no-gc-sections instead of the default
// --gc-sections — the escape hatch if a call_indirect trap
// ever points at table renumbering again (see the link-flags
// note at the top of this file). Costs ~25 % module size and
// most of a JIT runtime's module-load floor.
}
Build options. opt is a BORROWED view (literal or string_data of a caller-owned String) — WbOpts is passed by value, so it must not own heap fields.
@ string_split_borrow s flags → ( Vec String )Split a space-separated flag string into owned Strings (caller frees). NOTE: the Vec Strings LEAK by design at the single link call site (a handful of tiny strings once per build) to keep the argv s views alive through process_run.
@ wb_opts_default → WbOpts@ wb_build_file s nu_path s out_wasm WbOpts opts → !v StringCompile one .nu file to out_wasm. The heavy lifting shared by the CLI and wb_build_source.
@ wb_build_source s source s filename WbOpts opts → !( Vec u ) StringCompile NURL source (a string) to wasm bytes — the embedding API swarm-mcp's kernel path uses. Single-file sources only (imports resolve against the installed stdlib; a multi-file package should go through wb_build_file on its entry point instead).
packages/wasmbuilder/src/toolchain.nu — locate (or provision) everything a local wasm32-wasi build needs, using only what the installed NURL toolchain already ships.
The wasm pipeline needs three things beyond nurlc itself:
The toolchain's bundled zig cc (at $NURL_HOME/zig/zig, preferred by nurl.sh for native builds too) does this out of the box — zig carries its own wasi-libc and wasm-ld, so NO wasi-sdk is needed.
stdlib ships runtime.c, so we compile it on first use and cache the object keyed by the SOURCE HASH — a toolchain upgrade that changes runtime.c automatically invalidates the cache. No version skew.
Resolution order for the wasm compiler: $NURL_ZIG → $NURL_HOME/zig/zig → zig on PATH → $WASI_CLANG (a wasi-sdk clang, for setups that already have one) → download a pinned, sha256-verified zig release into $NURL_HOME/zig (opt out with NURL_WASM_NO_DOWNLOAD=1).
Everything is namespaced wb_/__wb_ — NURL has one flat function namespace and this module is meant to be imported by other packages (swarm-mcp, nurl-mcp).
@ wb_is_windows → b@ wb_nurl_home → String$NURL_HOME, defaulting to <home>/.nurl — the same layout tools/install-toolchain.sh and get-nurl.{sh,ps1} produce.
@ wb_stdlib_dir → StringThe directory holding the stdlib C sources (runtime.c & friends). $NURL_STDLIB is the toolchain PREFIX (import strings like stdlib/core/string.nu resolve against it), so the C sources live at $NURL_STDLIB/stdlib/. Fall back to $NURL_HOME/stdlib for direct runs.
@ wb_find_nurlc → !String StringLocate nurlc: $NURLC → $NURL_HOME/bin/nurlc → nurlc on PATH. The $NURL_HOME/bin shim is preferred over PATH because it exports NURL_STDLIB, keeping stdlib import resolution correct even when wasmbuilder itself was started without the shims.
: WbCompiler: WbCompiler {
String cmd
b is_zig
}
WbCompiler.cmd is the executable; is_zig=T means invoke as cmd cc … (zig's clang driver), F means a wasi-sdk clang invoked as cmd ….
@ wb_compiler_free WbCompiler c → v@ wb_find_compiler b allow_download → !WbCompiler String@ wb_cache_dir → StringCache root: $NURL_HOME/build/wasmbuilder (created on demand).
@ wb_tu_text s dir s name ( Vec String ) seen String out → vCompile <stdlib>/<src_c> for wasm32-wasi and cache the object keyed by the source's content hash: runtime.c changes with the toolchain, so an upgrade automatically produces a fresh object that matches it. Returns the cached object path. Append the text of one translation unit to out: the file name under dir, then every file it pulls in with a quoted #include, depth-first and each visited once (seen also breaks include cycles). A name that does not resolve to a readable file contributes nothing — the compiler will report it far better than a cache key can.
This is what the object cache below is keyed on. It used to hash the one named file, and stdlib/runtime.c is a three-line aggregator: every line of the runtime lives in the files it includes. The key was therefore blind to every change that matters — an edit to runtime_core.c silently reused the object compiled before it, and the module kept the old runtime until something happened to touch the aggregator itself.
@ wb_ensure_wasm_obj_feat WbCompiler cc s src_c s feat → !String Stringfeat names a feature-flavoured build of the same source (` = the plain one, threads` = -matomics -mbulk-memory). It is part of the cache key, because an object built without the atomics feature cannot be linked into a module that has it.
@ wb_ensure_wasm_obj WbCompiler cc s src_c → !String StringThe plain (no extra features) object — what every non-threads build wants.
wasmbuilder — compile NURL to wasm32-wasi locally, with zero setup beyond the installed toolchain.
wasmbuilder program.nu # → program.wasm wasmbuilder program.nu -o out/app.wasm # explicit output wasmbuilder program.nu -O z # size-optimised link wasmbuilder program.nu --emit-ll # keep the rewritten .ll too wasmbuilder --doctor # explain what is resolved
Run the result with any wasm runtime — the reference wasmtime, or the pure-NURL one: nurlpkg install wasmtime && wt run program.wasm.
The first build on a machine without a bundled zig downloads a pinned, sha256-verified zig 0.16.0 into $NURL_HOME/zig (set NURL_WASM_NO_DOWNLOAD=1 to forbid that and fail instead).
@ main → ipackages/wasmbuilder/src/wasi_ir.nu — LLVM-IR → wasm32-wasi rewriter.
nurlc emits IR targeting the host (x86_64/arm64, 64-bit pointers, glibc FFI declares). This module retargets that IR for wasm32-wasi:
@main → @__main_argc_argv (wasi-libc crt entry shape)wasm32-unknown-wasi triplewasm32 ABI uses i32 (malloc/strlen/memcpy/… — trunc/zext wrappers)
so the stdlib's unconditional declares link; the stubs return error sentinels and are unreachable behind posix_const runtime gates
@ inside c"…" string constants first so a program thatitself EMITS IR (nurlc.wasm!) doesn't get its data corrupted
Extracted verbatim from nurlapi/main.nu (the /build_wasm handler), which is the production-proven pipeline behind play.nurl-lang.org — only the function names are prefixed (wb_/__wb_) for library use.
@ wb_prepare_ir_for_wasi String ir → String@ wb_prepare_ir_for_wasi_opts String ir b threads → Stringthreads = the module is being built for wasi-threads, where runtime.c DEFINES the pthread surface on top of atomics. Stubbing it here would rename those call sites away from the real implementation, so the pthread family is left alone in that build.