Skip to content

Add browser JS support using WASM build#5

Open
pirate wants to merge 23 commits into
riyaneel:developmentfrom
pirate:wasm-browser-support
Open

Add browser JS support using WASM build#5
pirate wants to merge 23 commits into
riyaneel:developmentfrom
pirate:wasm-browser-support

Conversation

@pirate
Copy link
Copy Markdown

@pirate pirate commented May 9, 2026

Fixes #4

Summary

  • Add a wasm32 Tachyon ring for browser-local JavaScript/Rust communication while keeping the native Rust FFI path gated to non-wasm targets.
  • Add a browser entry for the existing @tachyon-ipc/core package through the package browser field, leaving Node main and types resolution unchanged.
  • Share the JS Bus API wrapper between Node and browser so guard lifecycle, recv copying, batching, close semantics, and API compatibility live in one place.
  • Keep example-page-only Rust echo/benchmark behavior under examples/browser_wasm/rust; the bindings package only exposes reusable transport/API pieces.
  • Add a browser WASM example with a direct-doorbell RTT benchmark and documentation in the root README, Node binding README, docs index, and examples index.
  • Add raw-CDP Chromium browser tests next to the Node tests, plus CI coverage using /usr/bin/chromium, and a Rust wasm32 target check.

Notes

The browser transport cannot use POSIX shared memory or UNIX sockets, so socketPath is a page-local endpoint key. The message ring keeps Tachyon's 64-byte header, type_id, alignment, and skip-marker semantics. Browser receives are non-blocking; direct doorbell calls are used instead of polling or browser task scheduling.

The browser test runner does not depend on Playwright. It uses raw Chrome DevTools Protocol from a Node 24 driver with the built-in WebSocket client and discovers browsers locally from CHROMIUM_BIN, /usr/bin/chromium, Linux Chromium aliases, macOS Chrome/Canary/Chromium apps, and cached ms-playwright Chromium installs.

Validation

  • cargo check --manifest-path bindings/rust/Cargo.toml -p tachyon-ipc
  • RUSTC=$(rustup which rustc) cargo check --manifest-path bindings/rust/Cargo.toml -p tachyon-ipc --target wasm32-unknown-unknown
  • RUSTC=$(rustup which rustc) cargo check --manifest-path examples/browser_wasm/rust/Cargo.toml --target wasm32-unknown-unknown
  • npm run lint in bindings/node
  • npm run format:check in bindings/node
  • npm run build:ts && npm run copy:wasm in bindings/node
  • CHROMIUM_BIN='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' npm run test:browser in bindings/node
  • bash ci/vendor.sh node && npm run build:native followed by npm test in bindings/node
  • npm run build in examples/browser_wasm
  • Headful Chrome browser benchmark: p50 122.1 ns, p90 122.1 ns, p99 146.5 ns, mean 113.3 ns, throughput 8.83M RTT/sec on my M1 Max MacBook Pro
  • Native Rust benchmark: p50 166 ns, throughput 2.78M RTT/sec on my M1 Max MacBook Pro
image

@pirate pirate marked this pull request as ready for review May 9, 2026 19:12
@pirate pirate changed the title Add browser WASM transport Add browser JS support using WASM build May 9, 2026
@riyaneel riyaneel self-assigned this May 9, 2026
@riyaneel riyaneel added the feature New feature or request label May 9, 2026
Comment thread bindings/node/test/browser_wasm.spec.mjs Dismissed
@pirate pirate force-pushed the wasm-browser-support branch from 7345313 to 424e1e2 Compare May 9, 2026 19:25
@pirate pirate marked this pull request as draft May 9, 2026 19:27
@pirate
Copy link
Copy Markdown
Author

pirate commented May 9, 2026

don't merge/review just yet @riyaneel, I'm going to reduce the duplication of the node/browser versions by making them both extend a base class, instead of re-implementing the whole surface twice.

@pirate pirate force-pushed the wasm-browser-support branch 2 times, most recently from a18fa0e to 9b3a9e2 Compare May 9, 2026 19:33
@pirate
Copy link
Copy Markdown
Author

pirate commented May 9, 2026

If you merge this I recommend renaming bindings/node -> bindings/js because it now covers both node and browser in the same package. I'll leave that to do separately to avoid blowing up this PR diff (unless you want me to include it).

@pirate pirate force-pushed the wasm-browser-support branch 2 times, most recently from 1c4352e to e8d3138 Compare May 9, 2026 19:43
@riyaneel riyaneel marked this pull request as ready for review May 9, 2026 20:09
@pirate
Copy link
Copy Markdown
Author

pirate commented May 9, 2026

ok ready for review

@riyaneel riyaneel self-requested a review May 9, 2026 20:30
@riyaneel
Copy link
Copy Markdown
Owner

Hi,

Thanks for taking the time to put this PR together.

I have reviewed the code, and the quality of the JS/TS integration is impressive. The BusBase refactoring to unify the API surface across native Node and browser WASM is clean, and the ArrayBuffer detachment trick via structuredClone to emulate C++ memory safety is a great solution. The custom CDP test runner also shows a solid understanding of browser internals. I completely agree with your note about renaming bindings/node to bindings/js.

That being said, after looking closely at the SPSC ring buffer implementation, there is a fundamental architectural blocker. I cannot accept re-implementing the core in Rust. To integrate browser WASM support, the existing C++ core must be compiled using Emscripten.

Here is the reasoning behind this decision:

  • Fuzzing and Validation guarantees: Tachyon relies on a centralized C++ core. This core is continuously fuzzed and strictly validated against memory corruption and race conditions. Maintaining a secondary lock-free implementation in Rust throws away these guarantees. I cannot risk silent memory corruption by maintaining parallel engine implementations just to bypass a compiler pipeline.
  • ABI and Alignment Violations: The proposed Rust implementation violates Tachyon's ABI. The C++ core enforces a 64-byte alignment for cache line mapping (MsgViewLayout). Allocating the arena via a Rust vec only guarantees a 1-byte alignment, which breaks the memory layout when casting pointers. Additionally, relying on usize on a wasm32 target restricts pointers and capacities to 32 bits. Requesting an arena larger than 4GB would result in silent integer overflows and memory corruption instead of a clean failure.

The safest way to bring WASM support to the browser is to use the C++ core as the single source of truth. I can easily isolate POSIX-specific system calls by using #if defined(__EMSCRIPTEN__) and falling back to std::aligned_alloc(64, size) on the WASM heap. The blocking futex calls can also be bypassed to yield immediately, ensuring the browser main thread is not blocked. The JS side remains exactly the same, but it gets the pointer by calling the fuzzed C API.

On the JavaScript side, your architecture is solid, but there are three specific bugs in the unified wrapper that need to be addressed:

  • Memory Leak: Using this as the unregisterToken in the FinalizationRegistry creates a strong cyclic reference. The GC will never clean up the WasmBus instances.
  • Buffer Typing: Casting the WASM Uint8Array to Buffer will crash at runtime when a browser user calls Node-specific methods. The guards must expose Uint8Array | Buffer strictly.
  • Latency Overhead: Throwing an Error in BusBase.recv() when the ring is empty generates highly expensive V8 stack traces. Empty rings are a normal state in a non-blocking poll and must simply return null or undefined.

I genuinely want to keep your JS/TS wrapper, tests, and examples. If you are open to collaborating on the Emscripten pivot, I want to push the necessary C++ patches directly to this branch so you can connect the Emscripten module to your JS interface.

Let me know what you think and how you would like to proceed.

@pirate
Copy link
Copy Markdown
Author

pirate commented May 13, 2026

all good! happy to pivot to emscripten, give me a few days to refactor / feel free to push directly to this PR in the meantime. will fix those bugs as well if you don't get to them first.

I know the browser side well but I don't know nearly as much about low level C++ / Rust / memory layout optimization, so I'm not surprised there were a few bugs in my v1. It's a good learning experience, thanks to being open to contributions!

I do think it may be worth still considering a wasm32 target for now, and leave wasm64 for the future because it's not really stable/commonly used yet.

@riyaneel riyaneel changed the base branch from main to development May 13, 2026 17:43
@riyaneel
Copy link
Copy Markdown
Owner

Appreciate your openness to this pivot. Adapting to the Emscripten pipeline is the right move for the project's long-term stability, and it will guarantee that your JS wrapper operates on top of a fully sanitized core.

You make a valid point regarding the wasm32 target. The wasm64 specification is not universally mature or enabled by default across all JS engines yet. Compiling the Emscripten build to wasm32 is the correct decision for maximum compatibility.

However, because size_t and pointers will be limited to 32 bits, we must enforce a hard capacity limit at the API boundary. The JavaScript Bus.listen() constructor and the C++ initialization must explicitly reject any capacity request larger than 2GB with a clean error exception. This guarantees we never trigger a silent integer overflow or memory wrapping on large allocations.

I will take care of the C++ and build pipeline. I will implement the #if defined(__EMSCRIPTEN__) paths in shm.cpp and arena.cpp, set up the CMake toolchain for Emscripten, and ensure the C API is properly exported. I will push these commits directly to your branch in the coming days.

@riyaneel riyaneel force-pushed the wasm-browser-support branch 2 times, most recently from 19cd752 to 1ed98db Compare May 14, 2026 00:22
@riyaneel riyaneel force-pushed the wasm-browser-support branch from 1ed98db to cb468f1 Compare May 15, 2026 03:02
@riyaneel
Copy link
Copy Markdown
Owner

Hi @pirate,

I've just pushed the commits to stabilize the core and the Emscripten toolchain. Everything is set up.

As we discussed, I've implemented the 2GB capacity limit in both the shared memory allocation and the C API to protect against 32-bit index overflows on the wasm32 heap. For the zero-copy integration, I added tachyon_bus_get_shm_ptr which will give you the raw offset you need to map your Uint8Array directly onto the WASM memory buffer.

Also renamed bindings/node to bindings/js. You can use npm run build:wasm to trigger the full compilation. The core is ready and you can finalize the TypeScript bridge refactor.

Let me know if you hit a wall.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now that the browser core uses C++, should we switch this to C++ or leave the example using rust for the demo?

might be cool to keep rust to show that WASM really is language agnostic?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but to make it work while keeping the core as the single source of truth, we just need to compile this Rust example using the wasm32-unknown-emscripten target instead of wasm32-unknown-unknown.

Cargo will use emcc as the linker and the rust bindings will link directly against the Emscripten compiled core, generating a single WASM module. Both JS and Rust using the exact same fuzzed/sanitized C API.

@pirate
Copy link
Copy Markdown
Author

pirate commented May 15, 2026

Awesome, nice work! LGTM, excited to use it

@riyaneel
Copy link
Copy Markdown
Owner

Let me know when you wire up the TS wrapper to the new Emscripten module and patch the three JS bugs we discussed. For the demo, just tell me if you want to try setting up the Emscripten linker for Rust. Ping me when you feel that's ready for the final review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants