This documentation is part of the "Projects with Books" initiative at zenOSmosis.
The source code for this project is available on GitHub.
WASM RPC Client
Loading…
WASM RPC Client
Relevant source files
- extensions/muxio-rpc-service/Cargo.toml
- extensions/muxio-tokio-rpc-client/src/lib.rs
- extensions/muxio-wasm-rpc-client/Cargo.toml
- extensions/muxio-wasm-rpc-client/src/lib.rs
- extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs
- extensions/muxio-wasm-rpc-client/src/static_lib/static_client.rs
The WASM RPC Client provides a WebAssembly-compatible implementation of the RPC transport layer for browser environments. It bridges Rust code compiled to WASM with JavaScript’s WebSocket API, enabling bidirectional RPC communication between WASM clients and native servers.
This page focuses on the client-side WASM implementation. For native Tokio-based clients, see Tokio RPC Client. For server-side implementations, see Tokio RPC Server. For the RPC abstraction layer, see RPC Framework.
Sources: extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:1-182 extensions/muxio-wasm-rpc-client/Cargo.toml:1-30
Architecture Overview
The WASM RPC client bridges Rust WASM code with JavaScript’s WebSocket API. Unlike the Tokio client which manages its own WebSocket connection, RpcWasmClient relies on JavaScript glue code to handle WebSocket events and delegates to Rust for RPC protocol processing.
Diagram: WASM Client Architecture and Data Flow
graph TB
subgraph "Browser JavaScript"
WS[WebSocket]
WRITE_BYTES["static_muxio_write_bytes()"]
APP["Web Application"]
end
subgraph "WASM Module"
TLS["MUXIO_STATIC_RPC_CLIENT_REF\nRefCell<Option<Arc<RpcWasmClient>>>"]
CLIENT["RpcWasmClient"]
DISP["Arc<Mutex<RpcDispatcher>>"]
EP["Arc<RpcServiceEndpoint<()>>"]
EMIT["emit_callback: Arc<dyn Fn(Vec<u8>)>"]
CONN["is_connected: Arc<AtomicBool>"]
end
subgraph "Core Layer"
MUXIO["muxio::rpc::RpcDispatcher\nmuxio::frame"]
end
APP -->|new WebSocket| WS
WS -->|onopen| TLS
WS -->|onmessage bytes| TLS
WS -->|onerror/onclose| TLS
TLS -->|handle_connect| CLIENT
TLS -->|read_bytes bytes| CLIENT
TLS -->|handle_disconnect| CLIENT
CLIENT --> DISP
CLIENT --> EP
CLIENT --> EMIT
CLIENT --> CONN
EMIT -->|invoke| WRITE_BYTES
WRITE_BYTES -->|websocket.send| WS
DISP --> MUXIO
EP --> MUXIO
The architecture consists of three layers:
- JavaScript Layer : Manages WebSocket lifecycle (
onopen,onmessage,onclose) and forwards events to WASM - WASM Bridge Layer :
RpcWasmClientwithemit_callbackfor outbound data and lifecycle methods for inbound events - Core RPC Layer :
RpcDispatcherfor request/response correlation andRpcServiceEndpoint<()>for handling incoming calls
Sources: extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:16-35 extensions/muxio-wasm-rpc-client/src/static_lib/static_client.rs:1-11 extensions/muxio-wasm-rpc-client/src/static_lib/static_client.rs:25-36
RpcWasmClient Structure
The RpcWasmClient struct manages bidirectional RPC communication in WASM environments. It implements RpcServiceCallerInterface for outbound calls and uses RpcServiceEndpoint<()> for inbound request handling.
Diagram: RpcWasmClient Class Structure
classDiagram
class RpcWasmClient {
-Arc~Mutex~RpcDispatcher~~ dispatcher
-Arc~RpcServiceEndpoint~()~~ endpoint
-Arc~dyn Fn(Vec~u8~)~ emit_callback
-RpcTransportStateChangeHandler state_change_handler
-Arc~AtomicBool~ is_connected
+new(emit_callback) RpcWasmClient
+handle_connect() async
+read_bytes(bytes: &[u8]) async
+handle_disconnect() async
+is_connected() bool
+get_endpoint() Arc~RpcServiceEndpoint~()~~
-dispatcher() Arc~Mutex~RpcDispatcher~~
-emit() Arc~dyn Fn(Vec~u8~)~
}
class RpcServiceCallerInterface {<<trait>>\n+get_dispatcher() Arc~Mutex~RpcDispatcher~~\n+get_emit_fn() Arc~dyn Fn(Vec~u8~)~\n+is_connected() bool\n+set_state_change_handler(handler) async}
class RpcDispatcher {
+read_bytes(bytes: &[u8]) Result~Vec~u32~, FrameDecodeError~
+respond(response, chunk_size, callback) Result
+is_rpc_request_finalized(id: u32) Option~bool~
+delete_rpc_request(id: u32) Option~RpcRequest~
+fail_all_pending_requests(error)
}
class RpcServiceEndpoint {+get_prebuffered_handlers() Arc\n+register_prebuffered_handler()}
RpcWasmClient ..|> RpcServiceCallerInterface
RpcWasmClient --> RpcDispatcher
RpcWasmClient --> RpcServiceEndpoint
| Field | Type | Purpose |
|---|---|---|
dispatcher | Arc<Mutex<RpcDispatcher<'static>>> | Manages request/response correlation via request_id and stream multiplexing |
endpoint | Arc<RpcServiceEndpoint<()>> | Dispatches incoming RPC requests to registered handlers by METHOD_ID |
emit_callback | Arc<dyn Fn(Vec<u8>) + Send + Sync> | Callback invoked to send bytes to JavaScript’s static_muxio_write_bytes() |
state_change_handler | Arc<Mutex<Option<Box<dyn Fn(RpcTransportState) + Send + Sync>>>> | Optional callback for Connected/Disconnected state transitions |
is_connected | Arc<AtomicBool> | Lock-free connection status tracking |
Sources: extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:16-35 extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:154-181
Connection Lifecycle
The WASM client relies on JavaScript to manage the WebSocket connection. Three lifecycle methods must be called from JavaScript glue code in response to WebSocket events:
stateDiagram-v2
[*] --> Disconnected : new()
Disconnected --> Connected : handle_connect()
Connected --> Processing : read_bytes(data)
Processing --> Connected
Connected --> Disconnected : handle_disconnect()
Disconnected --> [*]
note right of Connected
is_connected = true
state_change_handler(Connected)
end note
note right of Processing
1. read_bytes() into dispatcher
2. process_single_prebuffered_request()
3. respond() with results
end note
note right of Disconnected
is_connected = false
state_change_handler(Disconnected)
fail_all_pending_requests()
end note
handle_connect
Called when JavaScript’s WebSocket onopen event fires. Sets is_connected to true via AtomicBool::store() and invokes the registered state_change_handler with RpcTransportState::Connected.
Sources: extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:38-44
read_bytes
The core message processing method, called when JavaScript’s WebSocket onmessage event fires. Implements a three-stage pipeline to avoid holding the dispatcher lock during expensive async operations.
Diagram: read_bytes Three-Stage Pipeline
sequenceDiagram
participant JS as "JavaScript\nonmessage"
participant RB as "read_bytes()"
participant Disp as "Mutex<RpcDispatcher>"
participant Proc as "process_single_prebuffered_request()"
participant Handler as "User Handler"
JS->>RB: read_bytes(bytes: &[u8])
rect rgb(240, 240, 240)
Note over RB,Disp: Stage 1: Synchronous Reading (lines 56-81)
RB->>Disp: lock().await
RB->>Disp: dispatcher.read_bytes(bytes)
Disp-->>RB: Ok(request_ids: Vec<u32>)
loop "for id in request_ids"
RB->>Disp: is_rpc_request_finalized(id)
Disp-->>RB: Some(true)
RB->>Disp: delete_rpc_request(id)
Disp-->>RB: Some(RpcRequest)
end
Note over RB: Lock dropped here
end
rect rgb(245, 245, 245)
Note over RB,Handler: Stage 2: Async Processing (lines 83-103)
loop "for (request_id, request)"
RB->>Proc: process_single_prebuffered_request()
Proc->>Handler: handler(context, request)
Handler-->>Proc: Result<Vec<u8>, RpcServiceError>
Proc-->>RB: RpcResponse
end
RB->>RB: join_all(response_futures).await
end
rect rgb(240, 240, 240)
Note over RB,JS: Stage 3: Synchronous Sending (lines 105-120)
RB->>Disp: lock().await
loop "for response"
RB->>Disp: dispatcher.respond(response, chunk_size, callback)
Disp->>RB: emit_callback(chunk)
RB->>JS: static_muxio_write_bytes(chunk)
end
Note over RB: Lock dropped here
end
Stage 1 (lines 56-81) : Acquires dispatcher lock via Mutex::lock().await, calls dispatcher.read_bytes(bytes) to decode frames, identifies finalized requests using is_rpc_request_finalized(), extracts them with delete_rpc_request(), then releases lock.
Stage 2 (lines 83-103) : Without holding any locks, calls process_single_prebuffered_request() for each request. This invokes user handlers asynchronously and collects RpcResponse results using join_all().
Stage 3 (lines 105-120) : Re-acquires dispatcher lock, calls dispatcher.respond() for each response, which invokes emit_callback synchronously to send chunks via static_muxio_write_bytes().
This three-stage design prevents deadlocks by releasing the lock during handler execution and enables concurrent request processing.
Sources: extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:48-121
handle_disconnect
Called when JavaScript’s WebSocket onclose or onerror events fire. Uses AtomicBool::swap() to atomically set is_connected to false, invokes the state_change_handler with RpcTransportState::Disconnected, and calls dispatcher.fail_all_pending_requests() with FrameDecodeError::ReadAfterCancel to terminate all in-flight RPC calls.
Sources: extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:124-134
Static Client Pattern
For simplified JavaScript integration, the WASM client provides a static client pattern using thread-local storage via MUXIO_STATIC_RPC_CLIENT_REF. This eliminates the need to pass client instances through JavaScript’s FFI boundary.
Diagram: Static Client Initialization and Access Flow
graph TB
subgraph "JavaScript"
INIT["init()"]
CALL["callRpcMethod()"]
end
subgraph "WASM Exports"
INIT_EXPORT["#[wasm_bindgen]\ninit_static_client()"]
RPC_EXPORT["#[wasm_bindgen]\nexported_rpc_fn()"]
end
subgraph "Static Client Layer"
TLS["MUXIO_STATIC_RPC_CLIENT_REF\nthread_local!\nRefCell<Option<Arc<RpcWasmClient>>>"]
WITH["with_static_client_async()"]
GET["get_static_client()"]
end
subgraph "Client"
CLIENT["Arc<RpcWasmClient>"]
end
INIT --> INIT_EXPORT
INIT_EXPORT -->|cell.borrow_mut| TLS
TLS -.->|stores| CLIENT
CALL --> RPC_EXPORT
RPC_EXPORT --> WITH
WITH -->|cell.borrow .clone| TLS
TLS -.->|retrieves| CLIENT
WITH -->|FnOnce Arc<RpcWasmClient>| CLIENT
init_static_client
Initializes MUXIO_STATIC_RPC_CLIENT_REF thread-local storage with Arc<RpcWasmClient>. The function is idempotent—subsequent calls have no effect. The client is constructed with RpcWasmClient::new(|bytes| static_muxio_write_bytes(&bytes)) to bridge outbound data to JavaScript.
Sources: extensions/muxio-wasm-rpc-client/src/static_lib/static_client.rs:25-36 extensions/muxio-wasm-rpc-client/src/static_lib/static_client.rs:9-11
with_static_client_async
Primary method for interacting with the static client from #[wasm_bindgen] exported functions. Retrieves Arc<RpcWasmClient> from MUXIO_STATIC_RPC_CLIENT_REF.with(), invokes the provided closure, and converts the result to a JavaScript Promise via future_to_promise().
| Parameter | Type | Description |
|---|---|---|
f | FnOnce(Arc<RpcWasmClient>) -> Fut + 'static | Closure receiving client reference |
Fut | Future<Output = Result<T, String>> + 'static | Future returned by closure |
T | Into<JsValue> | Result type convertible to JavaScript value |
| Returns | Promise | JavaScript promise resolving to T or rejecting with error string |
If the static client has not been initialized, the promise rejects with "RPC client not initialized".
Sources: extensions/muxio-wasm-rpc-client/src/static_lib/static_client.rs:54-72
get_static_client
Returns the current static client if initialized, otherwise returns None. Useful for conditional logic or direct access without promise conversion.
Sources: extensions/muxio-wasm-rpc-client/src/static_lib/static_client.rs:79-81
JavaScript Integration
The WASM client requires JavaScript glue code to bridge WebSocket events to WASM function calls. The integration relies on the emit_callback mechanism for outbound data and lifecycle methods for inbound events.
Diagram: JavaScript-WASM Bridge
graph TB
subgraph "JavaScript WebSocket Events"
OPEN["ws.onopen"]
MESSAGE["ws.onmessage"]
ERROR["ws.onerror"]
CLOSE["ws.onclose"]
end
subgraph "WASM Exported Functions"
WASM_CONNECT["handle_connect()"]
WASM_READ["read_bytes(event.data)"]
WASM_DISCONNECT["handle_disconnect()"]
end
subgraph "WASM Emit Path"
EMIT["emit_callback(bytes: Vec<u8>)"]
STATIC_WRITE["static_muxio_write_bytes(&bytes)"]
end
subgraph "JavaScript Bridge"
WRITE_FN["muxioWriteBytes(bytes)"]
end
OPEN -->|await| WASM_CONNECT
MESSAGE -->|await read_bytes new Uint8Array| WASM_READ
ERROR -->|await| WASM_DISCONNECT
CLOSE -->|await| WASM_DISCONNECT
EMIT -->|invoke| STATIC_WRITE
STATIC_WRITE -->|#[wasm_bindgen]| WRITE_FN
WRITE_FN -->|websocket.send bytes| MESSAGE
The JavaScript layer must:
- Create and manage a
WebSocketinstance - Forward
onopenevents toawait handle_connect() - Forward
onmessagedata toawait read_bytes(new Uint8Array(event.data)) - Forward
onerror/oncloseevents toawait handle_disconnect() - Implement
muxioWriteBytes()function to receive data fromstatic_muxio_write_bytes()and callwebsocket.send(bytes)
The emit_callback is constructed with |bytes| static_muxio_write_bytes(&bytes) when creating the client, which bridges to the JavaScript muxioWriteBytes() function.
Sources: extensions/muxio-wasm-rpc-client/src/static_lib/static_client.rs:1-8 extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:27-34
Making RPC Calls
The WASM client implements RpcServiceCallerInterface, enabling the same call patterns as the Tokio client. All methods defined using RpcMethodPrebuffered trait in service definitions are available via call_rpc_buffered().
Diagram: Outbound RPC Call Flow
sequenceDiagram
participant WASM as "WASM Code"
participant Method as "Add::call()"
participant Caller as "RpcServiceCallerInterface"
participant Disp as "RpcDispatcher"
participant Emit as "emit_callback"
participant JS as "static_muxio_write_bytes()"
participant WS as "WebSocket"
WASM->>Method: Add::call(&client, request).await
Method->>Method: encode_request(params)\nwith METHOD_ID
Method->>Caller: call_rpc_buffered(RpcRequest)
Caller->>Disp: get_dispatcher().lock()
Disp->>Disp: assign request_id
Disp->>Disp: encode frames
Disp->>Emit: get_emit_fn()(bytes)
Emit->>JS: static_muxio_write_bytes(&bytes)
JS->>WS: websocket.send(bytes)
WS->>JS: onmessage(response_bytes)
JS->>Caller: read_bytes(response_bytes)
Caller->>Disp: decode frames
Disp->>Disp: match request_id
Disp->>Method: decode_response(bytes)
Method-->>WASM: Result<Response, RpcServiceError>
Call Mechanics
From WASM code, RPC calls follow this pattern:
- Obtain
Arc<RpcWasmClient>viawith_static_client_async()or direct reference - Call service methods:
SomeMethod::call(&client, request).await - The trait implementation calls
client.call_rpc_buffered()which:- Serializes the request with
bitcode::encode() - Attaches
METHOD_IDconstant in theRpcHeader - Invokes
dispatcher.call()with a uniquerequest_id - Emits encoded frames via
emit_callback
- Serializes the request with
- Awaits response correlation by
request_idin the dispatcher - Returns
Result<DecodedResponse, RpcServiceError>
Sources: extensions/muxio-wasm-rpc-client/src/lib.rs:6-9 extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:154-167
graph TB
subgraph "JavaScript"
WS["WebSocket.onmessage"]
end
subgraph "read_bytes()
Pipeline"
STAGE1["Stage 1:\ndelete_rpc_request(id)"]
STAGE2["Stage 2:\nprocess_single_prebuffered_request()"]
STAGE3["Stage 3:\ndispatcher.respond()"]
end
subgraph "RpcServiceEndpoint<()>"
HANDLERS["get_prebuffered_handlers()\nHashMap<u32, Box<Handler>>"]
LOOKUP["lookup by METHOD_ID"]
end
subgraph "User Handler"
HANDLER["async fn(context: (), request: Vec<u8>)\n-> Result<Vec<u8>, RpcServiceError>"]
end
WS -->|bytes| STAGE1
STAGE1 -->|Vec< u32, RpcRequest >| STAGE2
STAGE2 --> HANDLERS
HANDLERS --> LOOKUP
LOOKUP --> HANDLER
HANDLER -->|Result| STAGE2
STAGE2 -->|Vec<RpcResponse>| STAGE3
STAGE3 -->|emit_callback| WS
Handling Incoming RPC Calls
The WASM client supports bidirectional RPC by handling incoming calls from the server. The RpcServiceEndpoint<()> dispatches requests to registered handlers by METHOD_ID.
Diagram: Inbound RPC Request Processing
sequenceDiagram
participant Code as "User Code"
participant Client as "RpcWasmClient"
participant EP as "RpcServiceEndpoint<()>"
participant Map as "HashMap<u32, Handler>"
Code->>Client: get_endpoint()
Client-->>Code: Arc<RpcServiceEndpoint<()>>
Code->>EP: register_prebuffered_handler::<Method>(handler)
EP->>Map: insert(Method::METHOD_ID, Box<handler>)
Note over Map: Handler stored for Method::METHOD_ID
Registering Handlers
Handlers are registered with the RpcServiceEndpoint<()> obtained via get_endpoint():
Diagram: Handler Registration Flow
When an incoming request arrives in read_bytes():
- Stage 1 :
dispatcher.delete_rpc_request(id)extracts theRpcRequestcontainingMETHOD_IDin its header - Stage 2 :
process_single_prebuffered_request()looks up the handler viaget_prebuffered_handlers()usingMETHOD_ID - Handler executes:
handler(context: (), request.rpc_prebuffered_payload_bytes) - Stage 3 :
dispatcher.respond()serializes the response and invokesemit_callback
The context type for WASM client handlers is () since there is no per-connection state in WASM environments.
Sources: extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:86-120 extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:141-143
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connected : handle_connect()
Connected --> Disconnected : handle_disconnect()
state Connected {
[*] --> Ready
Ready --> Processing : read_bytes()
Processing --> Ready
}
note right of Connected
is_connected = true
emit state_change_handler(Connected)
end note
note right of Disconnected
is_connected = false
emit state_change_handler(Disconnected)
fail_all_pending_requests()
end note
State Management
The WASM client tracks connection state using an AtomicBool and provides optional state change notifications.
State Change Handler
Applications can register a callback to receive notifications when the connection state changes:
| State | Trigger | Actions |
|---|---|---|
Connected | handle_connect() called | Handler invoked with RpcTransportState::Connected |
Disconnected | handle_disconnect() called | Handler invoked with RpcTransportState::Disconnected, all pending requests failed |
Sources: extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:168-180 extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:22-23
Dependencies
The WASM client has minimal dependencies focused on WASM/JavaScript interop:
| Dependency | Version | Purpose |
|---|---|---|
wasm-bindgen | 0.2.100 | JavaScript/Rust FFI bindings via #[wasm_bindgen] |
wasm-bindgen-futures | 0.4.50 | Convert Rust Future to JavaScript Promise via future_to_promise() |
js-sys | 0.3.77 | JavaScript standard library types (Promise, Uint8Array) |
tokio | workspace | Async runtime (only tokio::sync::Mutex used, not the executor) |
futures | workspace | Future composition (join_all() for concurrent request processing) |
async-trait | workspace | Async trait implementations (#[async_trait] for RpcServiceCallerInterface) |
muxio | workspace | Core multiplexing (RpcDispatcher, RpcSession, frame encoding) |
muxio-rpc-service | workspace | RPC trait definitions (RpcMethodPrebuffered, METHOD_ID) |
muxio-rpc-service-caller | workspace | RpcServiceCallerInterface trait |
muxio-rpc-service-endpoint | workspace | RpcServiceEndpoint, process_single_prebuffered_request() |
tracing | workspace | Logging macros (tracing::error!) |
Note: While tokio is included, the WASM client does not use Tokio’s executor. Only synchronization primitives like tokio::sync::Mutex are used, which work in single-threaded WASM environments.
Sources: extensions/muxio-wasm-rpc-client/Cargo.toml:11-22
Thread Safety and Concurrency
The WASM client is designed for single-threaded WASM environments but uses thread-safe primitives for API consistency with native code:
| Primitive | Purpose | WASM Behavior |
|---|---|---|
Arc<T> | Reference counting for shared ownership | Works in single-threaded context, no actual atomics needed |
tokio::sync::Mutex<RpcDispatcher> | Guards dispatcher state during frame encoding/decoding | Never contends (single-threaded), provides interior mutability |
Arc<AtomicBool> | Lock-free is_connected tracking | load()/store()/swap() operations work without OS threads |
Send + Sync bounds | Trait bounds on callbacks and handlers | Satisfied for API consistency, no actual thread migration |
The three-stage read_bytes() pipeline ensures the dispatcher lock is held only during:
- Stage 1:
read_bytes(),is_rpc_request_finalized(),delete_rpc_request()(lines 58-81) - Stage 3:
respond()calls (lines 108-119)
Lock is not held during Stage 2’s async handler execution (lines 85-103), enabling concurrent request processing via join_all().
Sources: extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:48-121 extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:7-14
Comparison with Tokio Client
| Feature | WASM Client | Tokio Client |
|---|---|---|
| WebSocket Management | Delegated to JavaScript | Built-in with tokio-tungstenite |
| Event Model | Callback-based (onopen, onmessage, etc.) | Async stream-based |
| Connection Initialization | handle_connect() | connect() |
| Data Reading | read_bytes() called from JS | read_loop() task |
| Async Runtime | None (WASM environment) | Tokio |
| State Tracking | AtomicBool + manual calls | Automatic with connection task |
| Bidirectional RPC | Yes, via RpcServiceEndpoint | Yes, via RpcServiceEndpoint |
| Static Client Pattern | Yes, via thread_local | Not applicable |
Both clients implement RpcServiceCallerInterface, ensuring identical call patterns and service definitions work across both environments.
Sources: extensions/muxio-wasm-rpc-client/src/rpc_wasm_client.rs:16-35