web-rpc 0.0.8

Bi-directional RPC for the Web
Documentation

CI Crates.io api-docs MIT licensed

web-rpc

Bidirectional RPC for browsing contexts, web workers, and message channels. Inspired by Google's tarpc: define a service as a trait, annotate, and the macro generates the client, the server, and a typed JavaScript endpoint for the other end of the connection. Payloads are encoded with postcard; values wrapped in Post<T> or Transfer<T> cross as JavaScript values through postMessage. Put the trait definition in a shared crate so both ends share it.

#[web_rpc::service]
pub trait Calculator {
    fn add(&self, left: u32, right: u32) -> u32;
}

The macro generates CalculatorClient, CalculatorService, a Calculator trait you implement on the server side, and a CALCULATOR_DESCRIPTION const describing the trait:

struct CalculatorImpl;
impl Calculator for CalculatorImpl {
    fn add(&self, left: u32, right: u32) -> u32 { left + right }
}

Wire up over a MessageChannel (or a Worker / MessagePort):

let channel = web_sys::MessageChannel::new().unwrap();
// web-rpc uses the transport it is handed and never manages its lifecycle: start the ports
// yourself, and terminate your own workers.
channel.port1().start();
channel.port2().start();

let (server_iface, client_iface) = futures_util::future::join(
    web_rpc::Interface::new(channel.port1()),
    web_rpc::Interface::new(channel.port2()),
).await;

let server = web_rpc::Builder::new(server_iface)
    .with_service::<CalculatorService<_>>(CalculatorImpl)
    .build();
wasm_bindgen_futures::spawn_local(server);

let client = web_rpc::Builder::new(client_iface)
    .with_client::<CalculatorClient>()
    .build();

assert_eq!(client.add(41, 1).await, 42);

Features

  • Explicit routing: Post<T> crosses as a JavaScript value copied by structured clone, Transfer<T> crosses as one that is moved onto the postMessage transfer list. Everything else is postcard-encoded and must implement postcard_schema::Schema.
  • Option/Result wrappers (and nested): each variant routes independently, so Result<Transfer<ArrayBuffer>, RustError> and Result<Option<Post<JsT>>, _> just work, with no attribute.
  • Bidirectional RPC over a single channel, with both ends simultaneously acting as client and server.
  • Streaming RPCs via impl Stream<Item = T> returns, with abort-on-drop and close-and-drain.
  • Async or sync server methods, with per-request cancellation when the client drops the future of an RPC method that returns.
  • Notifications: methods with no return type are fire-and-forget.
  • Borrowed &str / &[u8]: zero-copy deserialization on the server side.
  • Conditional methods: #[cfg(...)] on a trait method is propagated to all generated code, so the method is stripped from the client, server, description and wire format when the cfg is off.
  • Generated JavaScript endpoints: js::endpoint! renders a typed .mjs and .d.ts for the other end of a connection, at compile time, from the same traits.

JavaScript endpoints

js::endpoint! is the JavaScript counterpart of Builder and reads the same way: service = is the trait the generated endpoint serves, client = the trait it calls. Place it in the binary crate that owns the transport, beside the Rust builder it mirrors:

// The Rust side of this binary.
Builder::new(iface)
    .with_service::<CalculatorService<_>>(calculator)
    .with_client::<DisplayClient>();
// The other end of the same connection, described as itself.
web_rpc::js::endpoint!(service = DisplayService, client = CalculatorClient);

The class is named after client =, or after service = when there is no client, and is handed its transport rather than creating one:

import { CalculatorClient } from './calculator_client.mjs';

const worker = new Worker(url, { type: 'module' });
const calculator = new CalculatorClient({ endpoint: worker, handlers: { /* the Display trait */ } });

await calculator.add(41, 1);                       // Request<number>, with .abort()
calculator.note('hello');                          // a notification, returns void
const { close, done } = calculator.items(10, item => console.log(item));

The expansion writes the module and its declarations into two custom sections named after the class in snake_case, which survive wasm-bindgen and wasm-opt. Extract them before wasm-bindgen runs, with llvm-objcopy or rust-objcopy from cargo-binutils:

llvm-objcopy --dump-section=__web_rpc_calculator_client_js=calculator_client.mjs \
             --dump-section=__web_rpc_calculator_client_d_ts=calculator_client.d.ts in.wasm out.wasm

Add --remove-section=... for each to strip them from what you ship. The .mjs has no imports and needs no bundling: it is a small trait-independent shell plus the traits' types and methods as data, which the shell interprets. Two endpoints may coexist in one binary; two with the same class name are a duplicate-symbol error.

Migrating from 0.0.7

Both ends must be on 0.0.8: the wire format changed from bincode to postcard. See WIRE.md for the format itself.

  • Replace bare JavaScript types in signatures with Post<T> or Transfer<T>, and delete every #[transfer(...)]. The compiler finds all the sites. A typed array is not transferable: send Transfer<ArrayBuffer> and rebuild the view on the other side.
  • Add #[derive(Schema)] to every payload type reachable from a #[web_rpc::service] trait, and add postcard-schema as a direct dependency, since the derive emits ::postcard_schema:: paths. A foreign type with no upstream impl needs a local mirror type. usize and isize have no Schema impl, because serde widens them to u64/i64 and their Rust-side width is a property of the target; use a fixed-width integer.
  • Dropping a Port or an Interface no longer terminates a Worker, and a MessagePort is no longer start()ed for you, on either side. Call port.start() before handing a port over, or the handshake spins forever rather than failing.
  • A request or stream now keeps the connection's listener alive on its own, so a client may be dropped while one is still pending.

See the crate documentation for the full feature reference. Need help with your latest project? Get in touch via contact@allwright.io. I'm available for new assignments.