# In-Browser Runtime via WebAssembly — Development History > Covers the work to make swactor run in the browser with WebSocket > connectivity to a native cluster via a gateway pattern. > > *Branch: `in-browser`* --- ## Table of Contents 1. [Overview & Motivation](#1-overview--motivation) 2. [What Was Built](#2-what-was-built) 3. [Development Phases](#3-development-phases) 4. [NodeAddr Abstraction](#4-nodeaddr-abstraction) 5. [Core Wasm Compatibility](#5-core-wasm-compatibility) 6. [WebSocket Transport (Browser)](#6-websocket-transport-browser) 7. [WebSocket Gateway (Native)](#7-websocket-gateway-native) 8. [Enhanced Browser Runtime](#8-enhanced-browser-runtime) 9. [Demo & Examples](#9-demo--examples) 10. [Design Decisions & Tradeoffs](#10-design-decisions--tradeoffs) 11. [Dashboard Improvements](#11-dashboard-improvements) 12. [Known Gaps & Future Work](#12-known-gaps--future-work) --- ## 1. Overview & Motivation Before this work, swactor had a minimal wasm crate (`crates/wasm/`) that could run hardcoded Counter/Relay actors locally via `tick()` — no networking, no JS-defined actors, no connection to a cluster. The goal: **make a browser node that can run actors locally AND connect to a native cluster** via WebSocket, with the architecture designed so the browser could eventually become a full cluster peer (WebRTC P2P). Two design constraints guided the approach: - **Start thin, design for full.** Use a gateway pattern for v1 connectivity (browser ↔ WebSocket ↔ native node), but introduce the `NodeAddr` abstraction now so the distribution layer can eventually support WebSocket and WebRTC peers natively. - **Both Rust and JS actors.** Rust-compiled actors work by defining `ActorInterface` impls in the wasm crate (as the existing Counter/Relay do). JS actors work via `js_sys::Function` callback wrappers. --- ## 2. What Was Built | Component | Location | Action | Key Changes | |-----------|----------|--------|-------------| | NodeAddr abstraction | `crates/distribution/` | Modified (~30 files) | `SocketAddr` → `NodeAddr` across distribution, simulation, dashboard | | Core wasm compat | `src/` | Modified (4 files) | `web-time` abstraction, cfg-gated threads, wire encoding extraction | | WebSocket transport | `crates/wasm/src/` | Created (3 files) | `WsTransport`, `GatewayControl` protocol, `JsActor` wrapper | | WebSocket gateway | `crates/gateway/` | Created (new crate) | `WsAcceptor`, `WsGateway`, `SessionTransport` | | Browser runtime | `crates/wasm/src/lib.rs` | Rewritten | `BrowserRuntime` API with JS actor support | | Demo | `examples/`, `crates/wasm/www/` | Created | Gateway example, HTML demo page | | Dashboard improvements | `crates/runtime-dashboard/static/` | Created + Modified | Static file extraction, gossip panel, visual fixes, interactivity | | Gossip accessors | `crates/distribution/src/` | Modified (5 files) | `SwimProbe`/`SwimNode`/`DisseminationQueue`/`DistributedNode` accessors, snapshot types | --- ## 3. Development Phases ### Phase 1 — NodeAddr abstraction in distribution crate Replaced `SocketAddr` with an extensible `NodeAddr` enum across ~30 files: distribution crate (15 source files + 12 test files), simulation crate, and dashboard example. The main challenge was that `NodeAddr` is `Clone` but not `Copy` (unlike `SocketAddr`), requiring ~25 `.clone()` additions. ### Phase 2 — Core wasm compatibility Made the core `swactor` crate compile for `wasm32-unknown-unknown`: - `web-time` behind `cfg(wasm32)` for `Instant` - cfg-gated `RuntimeHandle`, `run()`, thread imports - Extracted `encode_wire_envelope`/`decode_wire_envelope` into core `src/transport.rs` - Distribution crate re-exports the shared wire encoding functions ### Phase 3 — WebSocket transport (browser side) Created browser-side transport wrapping `web_sys::WebSocket`: - `WsTransport` — buffers outbound messages until connection opens, decodes inbound binary frames, implements `Transport` trait - `GatewayControl` — control protocol for actor registration/resolution/keepalive - Same length-prefixed binary wire format as TCP ### Phase 4 — WebSocket gateway (native side) Created `crates/gateway/` — a native-side WebSocket acceptor + gateway: - `WsAcceptor` — mirrors `TcpAcceptor` pattern (non-blocking accept, WS upgrade, non-blocking read loop, dead connection cleanup) - `WsGateway` — routes inbound envelopes to runtime, handles control protocol, creates `SessionTransport` routes for cluster → browser forwarding - Sync `tungstenite` in dedicated thread — no tokio dependency ### Phase 5 — Enhanced browser runtime Replaced the hardcoded wasm MVP with a generic runtime: - `JsActor` — wraps `js_sys::Function` as an `ActorInterface` impl - `JsActorCtx` — bridge object passed to JS handlers (`send`, `self_addr`) - `BrowserRuntime` — JS-facing API: `connect()`, `spawn_js_actor()`, `send_json()`, `tick()`, `create_inbox()`, `try_recv()` - `JsMessage` codec for JSON-based communication - Legacy `SwactorRuntime` preserved for backwards compatibility ### Phase 6 — Demo and testing - `examples/ws_gateway.rs` — native gateway node with echo actor - `crates/wasm/www/index.html` — browser demo with connection UI, actor spawning, message sending, and log panel --- ## 4. NodeAddr Abstraction ### The Type ```rust // crates/distribution/src/types.rs #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum NodeAddr { Tcp(SocketAddr), // Future: Ws(String), WebRtc(String) } ``` All distribution APIs (`SwimNode`, `DistributedNode`, `RoutingTable`, etc.) now accept and return `NodeAddr`. The `TcpTransport` pattern-matches `NodeAddr::Tcp(addr)` when connecting: ```rust fn require_tcp(addr: &NodeAddr) -> Result { match addr { NodeAddr::Tcp(sa) => Ok(*sa), } } ``` ### Scope of changes The refactor touched every file in the distribution crate that previously used `SocketAddr`: - `types.rs` — `NodeRecord.addr`, `DirectoryEntry` field type - `messages.rs` — `PingReq.target_addr`, `JoinRequest.addr`, `FindNodeResponse`, `FindValueResponse` - `swim/node.rs` — `SwimNode.self_addr`, all `NodeAction` variants - `swim/probe.rs` — `SwimAction` variants, `ProbePhase`, target selection - `swim/member_list.rs` — `MemberEntry.addr`, `apply()` signature - `swim/dissemination.rs` — `membership_update()` function - `kademlia/routing_table.rs` — `NodeEntry.addr`, `insert()` signature - `kademlia/lookup.rs` — `LookupAction`, `NodeLookup.known` - `node.rs` — `DistributedNodeConfig.listen_addr`, all handler methods - `transport.rs` — `TcpTransport::new()`, `send_to()` - `snapshot.rs` — `addr_str()` - All 12 test files in `crates/distribution/tests/` - `crates/simulation/src/distribution/sim.rs` - `crates/runtime-dashboard/examples/dashboard_demo.rs` --- ## 5. Core Wasm Compatibility ### Time abstraction ```rust // src/lib.rs pub(crate) mod time { #[cfg(not(target_arch = "wasm32"))] pub(crate) use std::time::Instant; #[cfg(target_arch = "wasm32")] pub(crate) use web_time::Instant; } ``` `src/runtime.rs` and `src/worker.rs` import `crate::time::Instant` instead of `std::time::Instant`. The `web-time` crate provides a browser-compatible `Instant` backed by `performance.now()`. ### cfg-gated thread code ```rust // src/runtime.rs #[cfg(not(target_arch = "wasm32"))] use std::thread::{self, JoinHandle}; #[cfg(not(target_arch = "wasm32"))] pub struct RuntimeHandle { ... } #[cfg(not(target_arch = "wasm32"))] pub fn run(self) -> Result { ... } ``` Same pattern in `src/worker.rs` for `Worker::run()`. On wasm32, only `tick()` is available. ### Wire encoding extraction `encode_wire_envelope` and `decode_wire_envelope` were moved from `crates/distribution/src/transport.rs` into core `src/transport.rs` so both the distribution crate and the wasm crate can share them. The distribution crate re-exports: ```rust pub use swactor::transport::encode_wire_envelope; pub use swactor::transport::decode_wire_envelope; ``` ### Worker module visibility On wasm32, the worker module is `pub(crate)` (not `pub`) since external code shouldn't depend on thread-specific worker internals: ```rust #[cfg(not(target_arch = "wasm32"))] pub mod worker; #[cfg(target_arch = "wasm32")] pub(crate) mod worker; ``` --- ## 6. WebSocket Transport (Browser) ``` crates/wasm/src/ ├── ws_transport.rs — WsTransport (Transport impl over web_sys::WebSocket) ├── protocol.rs — GatewayControl enum, CONTROL_TYPE_TAG, NULL_ADDRESS └── js_actor.rs — JsActor, JsActorCtx, JsMessage ``` ### WsTransport Wraps `web_sys::WebSocket` with outbound buffering and inbound envelope decoding: ``` Browser JS WsTransport Gateway ────────── ─────────── ─────── ┌─ Connecting ─┐ rt.connect(url) ──►│ buffer sends │──── WS handshake ────► └──────────────┘ ┌─── Open ─────┐ rt.send_json() ───►│ encode + send│──── binary frame ────► │ │ │ decode inbound│◄─── binary frame ──── └──────────────┘ rt.tick() ───► drain_inbound() → deliver_raw() ``` The transport uses `Rc>` internally — safe because wasm32 is single-threaded. `unsafe impl Send + Sync` matches the pattern used by `Runtime`'s existing `unsafe impl Sync` for `RefCell>`. ### Control Protocol Control messages use a reserved null address (`[0u8; 32]`) and the type tag `"swactor::GatewayControl"`: ```rust pub enum GatewayControl { RegisterActor { addr: [u8; 32] }, UnregisterActor { addr: [u8; 32] }, ResolveActor { name: String }, ActorResolved { name: String, addr: [u8; 32] }, Ping, Pong, } ``` --- ## 7. WebSocket Gateway (Native) ``` crates/gateway/src/ ├── lib.rs — WsGateway, SessionTransport, control protocol handler └── ws_acceptor.rs — WsAcceptor (mirrors TcpAcceptor), WsSession, SessionId ``` ### WsAcceptor Follows the same non-blocking pattern as `TcpAcceptor` in the distribution crate: 1. Non-blocking TCP accept 2. WebSocket handshake (briefly blocking per new client) 3. Switch to non-blocking for reads 4. Read binary frames from all sessions 5. Dead connection cleanup in reverse index order ### WsGateway ``` Browser WsGateway Runtime / Cluster ─────── ───────── ────────────────── WireEnvelope ─────► route by dest: │ ├─ control msg? → handle_control() │ ├─ RegisterActor → add route │ ├─ Ping → send Pong │ └─ ... │ └─ regular msg → codec_registry.receive() └─ runtime.deliver_raw() ◄───── WireEnvelope ◄── SessionTransport.send() │ └─ TransportRouter lookup hits SessionTransport for browser-owned address ``` When a browser registers an actor, the gateway: 1. Stores `addr → session_id` mapping 2. Creates a `SessionTransport` route in the `TransportRouter` 3. Cluster actors sending to that address hit the route, which forwards via the WebSocket session --- ## 8. Enhanced Browser Runtime ### JsActor ```rust pub struct JsActor { handler: js_sys::Function, // (ctx, type_tag, msg_json) => void } impl ActorInterface for JsActor { type Incoming = JsMessage; type Response = (); fn handle(&mut self, ctx: &Ctx, msg: JsMessage) { ... } } ``` The handler receives a `JsActorCtx` bridge that exposes `send(dest_hex, type_tag, msg_json)` and `self_addr()` to JavaScript. ### BrowserRuntime API ``` JavaScript BrowserRuntime (Rust/wasm) ────────── ────────────────────────── new BrowserRuntime() ──► creates Runtime + CodecRegistry + TransportRouter rt.connect(url) ──► WsTransport::connect(url) rt.spawn_js_actor(fn) ──► JsActor wrapper → rt.spawn() → register w/ gateway rt.send_json(hex, tag, j)──► JsMessage → rt.send_to() rt.tick() ──► drain WS inbound → deliver_raw → rt.tick() rt.create_inbox() ──► rt.new_inbox::() → hex address rt.try_recv(hex) ──► inbox.try_recv() → JSON string or undefined rt.is_connected() ──► WsTransport::is_connected() rt.actor_count() ──► rt.stats().actors.len() ``` ### JsMessage A unified message type for all JS actors: ```rust pub struct JsMessage { pub type_tag: String, // wire protocol type tag (or "local") pub payload: String, // JSON payload } ``` Registered in the `CodecRegistry` with type tag `"swactor::JsMessage"` and a simple UTF-8 codec. --- ## 9. Demo & Examples ### Gateway example (`examples/ws_gateway.rs`) ```bash cargo run --example ws_gateway --features transport ``` Starts a native node on `ws://127.0.0.1:9000` with an echo actor. The gateway accepts WebSocket connections and bridges messages between browser clients and the runtime. ### Demo HTML page (`crates/wasm/www/index.html`) Build and serve: ```bash cd crates/wasm && wasm-pack build --target web --out-dir www/pkg cd www && python3 -m http.server 8080 # Open http://localhost:8080 ``` Features: connection panel with status indicator, JS actor spawning with click-to-copy addresses, message sending, and a timestamped event log. --- ## 10. Design Decisions & Tradeoffs | Decision | Rationale | |----------|-----------| | Gateway pattern for v1 | Avoids STUN/TURN complexity; one TCP connection per browser; simple to debug. `NodeAddr` abstraction leaves room for direct WebRTC P2P later. | | Sync tungstenite (no tokio) | Matches existing `TcpAcceptor` pattern in the distribution crate. Keeps the dependency tree small. The gateway runs a poll loop on a dedicated thread. | | `NodeAddr` enum (not trait) | Enums are exhaustive, serializable, and cheap to match. Adding a variant (e.g., `Ws(String)`) is a compiler-guided refactor. | | Same wire format over WS as TCP | No protocol translation — a `WireEnvelope` is the same bytes on both transports. Simplifies debugging and testing. | | `Rc` in WsTransport | wasm32 is single-threaded. `Arc` would compile but add unnecessary overhead. `unsafe impl Send+Sync` is the standard pattern for wasm-bindgen types. | | JSON for JS actor messages | The browser's native format. Binary codecs could be added later via `CodecRegistry`. | | Preserved legacy `SwactorRuntime` | The old Counter/Relay API still works. New code uses `BrowserRuntime`. | --- ## 11. Dashboard Improvements Alongside the in-browser work, the runtime dashboard received significant improvements: extraction to static files, visual bug fixes, interactivity, and a new gossip protocol panel on the distribution page. ### Static file extraction The dashboard HTML was originally embedded as Rust string constants (`actors_html.rs`, `dashboard_html.rs`, `distribution_html.rs`). These were extracted to standalone files under `crates/runtime-dashboard/static/`: ``` static/ ├── css/shared.css — shared dark-theme styles, stat cards, nav ├── js/shared.js — SSE helper, color palette, colorWithAlpha() ├── index.html — overview page (worker chart, actor table) ├── actors.html — actors page (worker bars, depth chart, actor list) └── distribution.html — distribution page (graph, gossip, membership) ``` The server now serves these as static files instead of embedding them. ### Visual fixes and performance - **Bar chart label clipping** — clamped `fillText` y-position to `Math.max(12, chartH - h - 4)` so labels for tall bars stay visible - **Worker details click flakiness** — `innerHTML = ''` every 200ms destroyed DOM elements and their click handlers. Replaced with persistent `workerNodes` map; headers and handlers are created once, text updated in-place - **Legend wrapping** — added `white-space: nowrap; overflow: hidden; text-overflow: ellipsis` to worker legend - **Performance** — data fingerprinting (`JSON.stringify` comparison) skips redundant redraws; differential actor table updates; `requestAnimationFrame` throttling ### Actors page interactivity - **Click-to-filter** — clicking a worker bar in the chart filters the actor table to that worker's actors (click again to clear) - **Depth chart colors** — changed from green-red heatmap to purple/indigo palette to distinguish from worker bars - **Worker-colored rows** — actor rows are tinted with a translucent version of their worker's color via `colorWithAlpha(hex, 0.08)` ### Gossip protocol panel (distribution page) Exposed SWIM gossip internals through new Rust accessors and snapshot types, then added a dedicated panel to the distribution page. **Rust changes** — new accessor methods on `SwimProbe` (`tick()`, `sequence()`, `phase_name()`, `probe_target()`, `suspicion_timers()`, `config()`), `SwimNode` (`probe()`, `dissemination()`), `DisseminationQueue` (`pending_entries()`), and `DistributedNode` (`swim_node()`). New snapshot structs: `GossipInfo`, `SuspicionInfo`, `DisseminationInfo`, `GossipConfig`. Added `gossip: Option` to `DistributionNodeSnapshot` with `#[serde(default)]` for backward compatibility. **UI panel** shows: - **Phase badge** — IDLE (green), PINGING (yellow), INDIRECT (orange) - **Counters** — protocol round, probes sent, incarnation, pending updates - **Suspicion timers** — table with progress bars toward timeout - **Membership events** — persistent rolling log of state transitions (new/alive/suspect/dead/gone) built by diffing consecutive snapshots client-side, with timestamps and node addresses - **Recently probed** — list of recent probe targets - **Config** — human-readable protocol parameters All node IDs are displayed as socket addresses (via a lookup map built from the members list) rather than raw hex. When running solo with no peers, the panel shows "No peers — gossip inactive" instead of zeros. --- ## 12. Known Gaps & Future Work | Gap | Effort | Impact | |-----|--------|--------| | `ResolveActor` control message not implemented | Low | Browser can't discover actors by name | | No WebSocket reconnection logic | Medium | Browser must refresh on disconnect | | No authentication on WS connections | Medium | Any client can register actors | | Gateway doesn't participate in SWIM | Medium | Browser actors aren't in the cluster directory | | No back-pressure from WS to actors | Low | Fast sender can overwhelm browser | | WebRTC P2P (direct browser↔browser) | High | Eliminates gateway bottleneck | | `NodeAddr::Ws` variant in distribution | Medium | Browser as full cluster peer without gateway | | wasm-pack integration test | Low | Automated browser test in CI | --- ## Verifying ### Native build + tests ```bash # Full workspace build cargo build # All tests (excluding flaky simulation MT test) cargo test --workspace --exclude simulation # Distribution tests specifically (134 tests) cargo test -p distribution # Gateway crate builds cargo build -p swactor-gateway ``` ### Wasm32 target ```bash # Core crate compiles for wasm32 cargo build --target wasm32-unknown-unknown -p swactor --no-default-features --features "no_random,transport" # Wasm crate compiles for wasm32 cargo build --target wasm32-unknown-unknown -p swactor-wasm ``` ### Dashboard ```bash # Build dashboard (includes static files) cargo build -p runtime-dashboard # Run the distribution demo (9-node churn simulation with dashboard) cargo run --example dashboard_demo -p runtime-dashboard --features distribution # Open http://localhost:3000 → Overview, Actors, Distribution pages # Verify distribution page gossip panel: # - Solo node: "No peers — gossip inactive" # - With peers: phase badge, counters, membership event log ``` ### Gateway example (manual) ```bash # Terminal 1: start gateway cargo run --example ws_gateway --features transport # Terminal 2: build wasm + serve demo page cd crates/wasm && wasm-pack build --target web --out-dir www/pkg cd www && python3 -m http.server 8080 # Open http://localhost:8080 and click Connect ```