- NodeAddr abstraction replacing SocketAddr across distribution crate (~30 files) - Core wasm32 compatibility: web-time, cfg-gated threads, wire encoding extraction - WebSocket transport (browser) with GatewayControl protocol - WebSocket gateway (native) with session routing and control protocol - BrowserRuntime API: JS actor support, connect/spawn/send/tick - Dashboard: extracted HTML to static files, gossip protocol panel with membership event log, visual fixes, click-to-filter, worker-colored rows Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
21 KiB
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
- Overview & Motivation
- What Was Built
- Development Phases
- NodeAddr Abstraction
- Core Wasm Compatibility
- WebSocket Transport (Browser)
- WebSocket Gateway (Native)
- Enhanced Browser Runtime
- Demo & Examples
- Design Decisions & Tradeoffs
- Dashboard Improvements
- 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
NodeAddrabstraction now so the distribution layer can eventually support WebSocket and WebRTC peers natively. -
Both Rust and JS actors. Rust-compiled actors work by defining
ActorInterfaceimpls in the wasm crate (as the existing Counter/Relay do). JS actors work viajs_sys::Functioncallback 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-timebehindcfg(wasm32)forInstant- cfg-gated
RuntimeHandle,run(), thread imports - Extracted
encode_wire_envelope/decode_wire_envelopeinto coresrc/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, implementsTransporttraitGatewayControl— 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— mirrorsTcpAcceptorpattern (non-blocking accept, WS upgrade, non-blocking read loop, dead connection cleanup)WsGateway— routes inbound envelopes to runtime, handles control protocol, createsSessionTransportroutes for cluster → browser forwarding- Sync
tungstenitein dedicated thread — no tokio dependency
Phase 5 — Enhanced browser runtime
Replaced the hardcoded wasm MVP with a generic runtime:
JsActor— wrapsjs_sys::Functionas anActorInterfaceimplJsActorCtx— bridge object passed to JS handlers (send,self_addr)BrowserRuntime— JS-facing API:connect(),spawn_js_actor(),send_json(),tick(),create_inbox(),try_recv()JsMessagecodec for JSON-based communication- Legacy
SwactorRuntimepreserved for backwards compatibility
Phase 6 — Demo and testing
examples/ws_gateway.rs— native gateway node with echo actorcrates/wasm/www/index.html— browser demo with connection UI, actor spawning, message sending, and log panel
4. NodeAddr Abstraction
The Type
// 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:
fn require_tcp(addr: &NodeAddr) -> Result<SocketAddr, Error> {
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,DirectoryEntryfield typemessages.rs—PingReq.target_addr,JoinRequest.addr,FindNodeResponse,FindValueResponseswim/node.rs—SwimNode.self_addr, allNodeActionvariantsswim/probe.rs—SwimActionvariants,ProbePhase, target selectionswim/member_list.rs—MemberEntry.addr,apply()signatureswim/dissemination.rs—membership_update()functionkademlia/routing_table.rs—NodeEntry.addr,insert()signaturekademlia/lookup.rs—LookupAction,NodeLookup.knownnode.rs—DistributedNodeConfig.listen_addr, all handler methodstransport.rs—TcpTransport::new(),send_to()snapshot.rs—addr_str()- All 12 test files in
crates/distribution/tests/ crates/simulation/src/distribution/sim.rscrates/runtime-dashboard/examples/dashboard_demo.rs
5. Core Wasm Compatibility
Time abstraction
// 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
// 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<RuntimeHandle, Error> { ... }
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:
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:
#[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<RefCell<WsInner>> internally — safe because wasm32
is single-threaded. unsafe impl Send + Sync matches the pattern used by
Runtime's existing unsafe impl Sync for RefCell<Vec<Worker>>.
Control Protocol
Control messages use a reserved null address ([0u8; 32]) and the type tag
"swactor::GatewayControl":
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:
- Non-blocking TCP accept
- WebSocket handshake (briefly blocking per new client)
- Switch to non-blocking for reads
- Read binary frames from all sessions
- 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:
- Stores
addr → session_idmapping - Creates a
SessionTransportroute in theTransportRouter - Cluster actors sending to that address hit the route, which forwards via the WebSocket session
8. Enhanced Browser Runtime
JsActor
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::<JsMessage>() → 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:
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)
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:
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<RefCell> in WsTransport |
wasm32 is single-threaded. Arc<Mutex> 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
fillTexty-position toMath.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 persistentworkerNodesmap; headers and handlers are created once, text updated in-place - Legend wrapping — added
white-space: nowrap; overflow: hidden; text-overflow: ellipsisto worker legend - Performance — data fingerprinting (
JSON.stringifycomparison) skips redundant redraws; differential actor table updates;requestAnimationFramethrottling
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<GossipInfo> 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
# 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
# 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
# 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)
# 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