Skip to content

feat(bb.js): generated bb.js-api package with an in-process wasm transport - #25433

Open
charlielye wants to merge 25 commits into
cl/ipc-bb-bbjs-migratefrom
cl/bbjs-api-codegen
Open

feat(bb.js): generated bb.js-api package with an in-process wasm transport#25433
charlielye wants to merge 25 commits into
cl/ipc-bb-bbjs-migratefrom
cl/bbjs-api-codegen

Conversation

@charlielye

@charlielye charlielye commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Stacked on #25362 (cl/ipc-bb-bbjs-migrate).

bb.js reached bb's wasm through ~1,400 lines of hand-written plumbing (comlink workers, WASI stubs, a heap allocator, pako inflation, base64-embedded modules) next to a generated client. This PR moves "how a client reaches a service module" into ipc-codegen/ipc-runtime, generates the bb API as its own package, and makes bb.js a facade over it.

ipc-runtime: @aztec-foundation/ipc-runtime/wasm

A generic backend for any wasi reactor implementing the FFI contract (<svc>_ipc_ffi_entry + <svc>_ipc_ffi_alloc/_free, found by suffix when not named):

  • WasmFfiBackend (async; main instance in a worker, wasi threads on further workers) and WasmFfiBackendSync, implementing the runtime's IpcClientAsync/IpcClientSync so the generated AsyncApi/SyncApi sit on them unchanged.
  • Node and browser entries selected by export condition; WebAssembly.compileStreaming loading with gzip support (browsers that cache compiled wasm start warm on a repeat visit); memory created to the module's declared shape (a threads build gets a shared memory even for one thread; maximum probed down, iOS-sized in browsers); a small WASI shim (clock, random, stdout/stderr to the logger, environ).
  • hostImports for modules whose platform layer imports functions beyond WASI (bb: logstr, throw_or_abort_impl, env_hardware_concurrency).
  • Workers are spawned through factories holding the literal new Worker(new URL(..., import.meta.url), { type: 'module' }), the expression bundlers need to see to bundle a worker's dependency graph.

ipc-codegen

  • --server --ffi now emits the FFI entry itself: C++ <svc>_ffi.{hpp,cpp} (the service defines one hook, ipc_ffi_dispatcher()), Rust <svc>_ffi.rs with an export_<svc>_ffi!(path, Handler) macro over a Handler + Default type. The exported symbols carry the service name (bb_ipc_ffi_entry) so libraries of several services can be statically linked into one binary; the Rust/Zig client ffi_backend files are generated to match instead of copied templates. The echo example exercises all three languages: a C++ ffi_test binary, a Rust in-process round trip (FfiBackend → own entry), and the Zig ffi_check.
  • TS --package gains the wasm transport (--package-transports uds,shm,wasm, --package-wasm-module, --package-wasm-threads-module, --package-wasm-host-imports): the package ships wasm/, a browser entry, its own worker scripts, createWasmBackend(Sync) and <Svc>Service.wasm(). main/types fields added for CommonJS consumers' resolution.
  • The client package owns backend selection: <Svc>Service.create(options) (and <Svc>ServiceSync.create) picks the spawned process when the binary resolves, else the wasm module; backend: 'process' | 'wasm' | <object> forces one with no fallback (asking for more wasm threads than the host can give is an error, not a downgrade); createBackend/createBackendSync expose the same policy to facades. A spawned process receives threads as HARDWARE_CONCURRENCY and RAYON_NUM_THREADS. One entry per host with its own types: node (everything), browser (wasm only), react-native (no built-in backend: a native package calls registerBackend, or the app passes one).
  • --out and --client are implied by --package (<package>/src/generated; the package is the client package); the bootstraps drop both lines.
  • Contract documented in SCHEMA_SPEC.md ("FFI entry"); flags in the README.

bb

  • Warmup command: runs the prover's hot loops once (a 2^14 Pippenger MSM over 2^i·G points, batch inversion, Poseidon2) so a tiering wasm engine has optimized them before real work. Exposed in bb.js as Barretenberg.new({ warmup: true }).
  • The generated bb_ffi.cpp provides bb_ipc_ffi_entry/_alloc/_free; c_bind.cpp shrinks to the dispatcher hook. barretenberg-rs regenerates against the new name (its FFI tests pass against libbb-external.a). The generated source is listed explicitly in CMake because the module's configure-time glob cannot see a build-time output on a clean tree.

bb.js

  • New gitignored workspace barretenberg/ts/bb.js-api (@aztec-foundation/bb.js-api), generated by barretenberg/ts/bootstrap.sh like bb-avm-sim/cdb (build_bb_js_api stages the two wasm builds and the native bb). bb.js depends on it for types, AsyncApi/SyncApi, curve constants and the wasm backends.
  • bb.js maps its BackendType options onto the package's createBackend/createBackendSync (bb's thread defaults, shm ring sizes and process lifetime settings live in that mapping); its own uds/shm/wasm backend files are gone. bb resolves through bb.js-api's per-platform packages (findBbBinary is re-exported from there); bb.js no longer bundles build/<platform>/bb, only the LMDB NAPI module (findNapiBinary). The facades are unchanged.
  • Deleted: src/barretenberg_wasm/**, bb_backends/{wasm,node/native_socket,node/native_shm,node/native_shm_async}.ts, scripts/generate.sh, scripts/copy_wasm.sh, the comlink dependency and the base64 wasm embedding in the browser postprocess.

Behaviour to note

  • Browser bundles no longer inline the wasm as base64: the modules are assets referenced with new URL(..., import.meta.url) (streaming compile + browser code cache). This needs the same bundler support the worker scripts already required (Vite: optimizeDeps.exclude for bb.js and bb.js-api). wasmPath/BB_WASM_PATH still override the module.
  • The bb binary's published home is now bb.js-api's per-platform packages (bb.js used to bundle its own copy). @aztec-foundation/bb (bb-cli) is under separate review.

Testing

  • ipc-runtime: yarn test (23 tests, incl. new module-source tests).
  • ipc-codegen echo example: C++ ffi_test, Rust cargo test --features ffi, Zig ffi_check, ts_package over uds and shm (incl. the create() default-policy check); schema_visitor tests. bb-avm-sim (process-only) and wsdb regenerate and build.
  • bb: bbapi_tests --gtest_filter='CBind.*' (incl. new WarmupSucceeds); both wasm builds export the three prefixed FFI symbols; barretenberg-rs cargo test --release --features ffi (72 tests) against libbb-external.a.
  • bb.js jest from dest: existing suites against native bb (uds + shm via the NAPI addon) and with the binary hidden (wasm fallback); new bb_backends/wasm.test.ts covering Wasm and WasmWorker at 4 threads, warmup: true, the sync backend, and error propagation. yarn formatting clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG

charlielye and others added 25 commits September 9, 2026 11:07
…sport

ipc-runtime gains a generic wasm FFI backend (@aztec-foundation/ipc-runtime/wasm)
for any wasi reactor implementing the ipc-codegen FFI contract; ipc-codegen emits
the FFI entry itself for --server --ffi (C++ and Rust) and a wasm transport for
generated TS packages; bb gets a Warmup command and the generated bb_ffi.cpp
replaces the hand-written entry; bb.js consumes the generated
@aztec-foundation/bb.js-api package and drops its own wasm plumbing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
The package shell imports its bindings from ./generated, so a --package
invocation only ever had one valid --out. Default it to <package>/src/generated,
reject a conflicting value, and drop the redundant line from every bootstrap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
bb_ipc_ffi_entry/_alloc/_free instead of one fixed name, so libraries of several
services can be statically linked into one binary. The Rust and Zig client
ffi_backend files are generated (link_name) instead of copied templates, the TS
package passes the names to the wasm host, and the host discovers the single
*_ipc_ffi_entry export when not told.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
…ps its bundled bb

The client package now owns how the service is reached. <Svc>Service.create
picks the spawned process when the binary resolves and the wasm module
otherwise; `backend:` forces one (no fallback, and more wasm threads than the
host can give is an error rather than a silent downgrade) or takes a backend
object of the consumer's own; createBackend/createBackendSync expose the same
policy to facades. One entry per host (node, browser, react-native), each with
its own types, offers only the backends that exist there; the react-native
entry has none built in and takes a registered or passed backend. A spawned
process receives `threads` as HARDWARE_CONCURRENCY and RAYON_NUM_THREADS.

bb.js maps its BackendType options onto that policy, resolves bb through
bb.js-api's per-platform packages (the binary's one published home) and keeps
bundling only the LMDB NAPI module. --client is dropped from the package
bootstraps, which imply it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
…age has

A package without the wasm transport was told it had a browser backend and got
a dangling clause in the synchronous-form sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
cross_copy_bb_js now stages bb.js-api's binaries too, so it installs and builds
in barretenberg/ts like bb-avm-sim-cross-copy does; the two had no ordering
between them. bb.js's build also ran the API package's generate-and-compile
twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
A worker runs a thread to completion inside wasi_thread_start, so round-robin
over a fixed pool of threads-1 workers only worked because bb spawns exactly
that many: any module spawning more would have queued two threads on one worker,
and the second would never run. Create a worker when the module asks for a
thread and drop it when the thread exits, so the workers follow the module's own
pool and a module that never spawns costs nothing.

The thread worker now serializes init against start rather than relying on the
parent to await readiness first.

Adds engine tests over a hand-built module: on-demand creation, the single-
threaded refusal, following the module's shared-memory declaration, and
narrowing an over-large memory maximum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
…call

Each call allocated a request buffer and a slot pair in the module, freed both,
and built three JS views over module memory. Hold one buffer that grows to fit
and cache the views, rebuilding them only when a call grows memory. The
cheapest call bb offers (blake2s of one byte) goes 1.455 -> 1.254 us; a call
doing real work is unchanged, since the saving is fixed per call.

The response still comes back in a buffer the module allocated, as the FFI
contract says: measured, that malloc is a small part of the fixed cost, and
taking it out would change the contract for every language.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
… override

The node and browser entries were near-identical: fourteen re-exports and three
factory functions each, differing only in the platform they bind and how they
spawn a worker. They now share entry.ts and are twenty lines apiece.

The allocator pair was both discovered from the entry's own name and passable as
an option, threaded through five files to reach the instance. Only the discovery
is kept; `entry` stays, since a binary linking two services is the reason its
name is service-prefixed at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
The platform-specific files were distinguished by a suffix and interleaved with
the shared ones. Each host now has a directory holding the same four file names,
so the split is visible in the tree rather than read off filenames.

Each host also gets its own export subpath with its own types, so code already
committed to one host imports it by name. That removes the shim by which the
node worker handle also accepted a web Worker, which existed only because the
`types` condition resolved to node for everyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
The choice was already the caller's — a worker by default, the calling thread
with `worker: false`, and always the calling thread for the synchronous backend
— but nothing said so. Name it on the option and give the generated README a
section, for packages that have a wasm backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
A module either imports its memory or defines and exports one. bb imports, so
the host always used the memory it had made itself; a Rust cdylib exports its
own, and the host would have read and written a memory the module never touches.
Adopt the exported one after instantiation, and refuse to thread such a module,
since every instance would have a memory to itself.

Found by giving the echo example a wasm reactor: the Rust crate already emits
the FFI entry, so a cdylib on wasm32-wasip1 is the whole build. The TS package
now offers uds, shm and wasm from that one crate, and its test drives the
reactor worker-hosted, on the calling thread, and synchronously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
The package resolved a default thread count from the host's parallelism without
the cap the engine then applies, so on a large machine it reported 160 threads
for a run that used 32. Export the cap from the runtime and use it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
Only a real application/wasm response reaches WebAssembly.compileStreaming as a
network response, which is the precondition for a browser caching the compiled
code between visits; a precompressed module has to be inflated into a synthetic
response first, and forfeits that. npm tarballs are gzipped either way, so this
costs nothing on install, and on a host that compresses the wire size is the
same or better.

It does assume the host compresses: most CDNs compress application/wasm, nginx
does not unless it is added to gzip_types. wasmPath (or BB_WASM_PATH) takes a
gzipped copy for hosts that cannot, so the choice stays the consumer's.

With base64 embedding already gone, nothing now inlines a module into a bundle:
both are assets, and only the one chosen is fetched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
…ntime

Two generated packages differed by one or two lines per file across six files:
binary resolution, process spawning, thread resolution, module selection and the
backend-selection policy were the same code with different literals, regenerated
per package. A fix to any of them would have had to reach every package by
regeneration; today's two bugs were both in the runtime, where one fix covers
everyone.

ipc-runtime gains findServiceBinary, spawnServiceBackend(Sync), runServiceBinary,
serviceProcessEnv and pickServiceBackend over a ServiceBinary description, and
resolveWasmThreads/chooseWasmModule in the wasm entry. The package now supplies
what only it can: that description, the module and worker URLs, which are
relative to its own files and must be literals a bundler can see, and the typed
service classes.

platform.ts 68 -> 29, process.ts 100 -> 37, bin.ts 19 -> 5, and the selection
policy loses the `!true` an interpolated flag had left in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
…ging out

Two more things were generated that had no reason to be. The React Native
backend registry is a global-symbol map keyed by service name, identical in
every package: it is now ipc-runtime's, reachable through a host-neutral
./registry subpath so a React Native entry does not pull in the node transports
or the wasm engine to find it. And prepare_arch_packages.sh was 63 lines of
shell per package that differed only in two names, both of which it can read
from the package's own package.json; it is now an ipc-runtime bin.

react-native.ts 84 -> 54, and scripts/prepare_arch_packages.sh is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
A schema-driven IPC generator had a --curve-constants flag that read a JSON of
BN254, Grumpkin and secp moduli and generators and emitted them as TypeScript.
That is one service's domain knowledge sitting in a tool that otherwise knows
nothing about any service. The flag, its JSON and the generated module are gone.

bb.js now carries them as source, written as hex so they read as the constants
they are, with a test asserting they match `bb msgpack curve_constants` — which
computes the same values from the curve definitions bb is compiled against. The
old JSON was hand-maintained with nothing checking it; it was correct, but by
luck rather than by construction.

Also makes the arch-package staging script Node rather than shell: yarn shims a
bin through node, so a .sh bin failed with ERR_UNKNOWN_FILE_EXTENSION and the
staging step silently did nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
Generation only ever wrote files, so a change to what it emits left the old
output behind: dropping --curve-constants left curve_constants.ts in every
checkout's src/generated, still compiled and still exported. CI runners reuse
working directories, so this is not only a local annoyance.

Each run now records what it produced and deletes anything the previous run
produced that this one did not. Generated packages also compile from a clean
dest, since tsc would otherwise keep the output of a source that is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
IMsgpackBackend, IMsgpackBackendSync and IMsgpackBackendAsync were a second set
of names for ipc-runtime's IpcClientAsync and IpcClientSync, which is what the
backends have been for a while; they were never exported, so the three internal
users now name the runtime types directly.

asyncMap and writeBenchmark have no callers anywhere and are not part of the
package's exports, so nothing could reach them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
The comment said bb monitors parent death and so must be unref'd. bb does not:
ipc-runtime's C++ installs the watch (prctl on Linux, a kqueue NOTE_EXIT
watcher on macOS) and the generated serve() calls it, so this is true of every
service spawned this way, not of bb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
unref was meant to say "you never have to destroy this for node to exit". Both
transports already implemented that honestly — the uds client refs the socket
while a call is outstanding and unrefs when the last one lands, and the shm
client acquires a thread-safe-function reference for the same span. The child
process handle did not: it was unref'd at spawn, before anything was connected,
so during a slow start nothing held the loop and node could exit mid-startup,
silently. The child is now unref'd once the backend is connected, after which
the transports' own refcounting covers every call.

That also collapses unrefStdio into unref. It existed because unref'ing the
child leaves its stdout/stderr pipes holding the loop, so the guarantee did not
hold whenever a logger was attached. The pipes are now unref'd at the same
moment, and the one cost — a trailing log line lost if the process exits
mid-write — is stated on the remaining option.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
Four folders held one file each, and the one named after the API held only
tests. Now: retry moves next to its single caller in crs, proof and the test
Timer become plain modules, the Chonk test joins the rest of the barretenberg
tests, and bb_backends loses the stutter to become backends. The platform-split
directories stay directories — the browser build finds them by rewriting
"/node/" in import paths.

Two dead things go with it: index.html loaded a simple_test.js that has never
existed in this tree, alongside the simple_test script pointing at a missing
src/examples, and bbapi/exception_handling.test.ts asserted the same
srsInitSrs failure the wasm backend test already covers, so its synchronous
half moves there and the file goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
Nothing has ever been tagged // POSTPROCESS ESM ONLY, so the pass that stripped
those lines only ever read files. What remains is the one real problem — a
single import.meta.url, which is a syntax error in CommonJS — and a note saying
what may not be written in code this build compiles, since blanking it makes
the expression evaluate rather than fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
The captureStackTrace call removed exactly one frame — its own constructor —
and the comment claimed it maintained a stack trace it was not responsible for;
super() captures that either way. Nothing has ever caught the type in this
repository, so what the class earns is its name: a log line that says the
failure came from bb rather than from bb.js.

Also records an asymmetry a caller would otherwise meet by surprise. Only the
native backends raise it. bb's wasm build compiles with BB_NO_EXCEPTIONS, so a
failing command aborts into the host's throw hook and arrives as a plain Error
with the same message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
Every generated TS API took an optional `createError` so a consumer could name the
error a failed command raises. Only bb.js ever passed one, to get BBApiException,
and the option was threaded through six option interfaces and four constructors in
every generated package, with 'createError' listed in each Omit<> alongside it.

Generate the class instead: api_types.ts declares one named for the service's error
response (BbErrorResponse -> BbError), and the async and sync APIs throw it. Every
package now has a typed error with no configuration, and the factory is gone.

bb.js re-exports it, aliased to BBApiException so existing callers keep compiling.

The new test pins the contract, which is per-failure-site rather than per-backend: a
handler that reports with BBAPI_ERROR returns an error frame and raises BbError on
every backend, while one that reports with throw_or_abort raises BbError on native
but a plain Error on wasm, where BB_NO_EXCEPTIONS compiles the dispatcher's catch
away and the throw reaches the host hook instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant