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.
The macro generates CalculatorClient, CalculatorService, a Calculator trait you implement on the server side, and a CALCULATOR_DESCRIPTION const describing the trait:
;
Wire up over a MessageChannel (or a Worker / MessagePort):
let channel = 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 = join.await;
let server = new
.
.build;
spawn_local;
let client = new
.
.build;
assert_eq!;
Features
- Explicit routing:
Post<T>crosses as a JavaScript value copied by structured clone,Transfer<T>crosses as one that is moved onto thepostMessagetransfer list. Everything else is postcard-encoded and must implementpostcard_schema::Schema. Option/Resultwrappers (and nested): each variant routes independently, soResult<Transfer<ArrayBuffer>, RustError>andResult<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.mjsand.d.tsfor 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.
new
.
.;
// The other end of the same connection, described as itself.
endpoint!;
The class is named after client =, or after service = when there is no client, and is handed its transport rather than creating one:
import from './calculator_client.mjs';
const worker = ;
const calculator = ;
await calculator.; // Request<number>, with .abort()
calculator.; // a notification, returns void
const = calculator.;
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>orTransfer<T>, and delete every#[transfer(...)]. The compiler finds all the sites. A typed array is not transferable: sendTransfer<ArrayBuffer>and rebuild the view on the other side. - Add
#[derive(Schema)]to every payload type reachable from a#[web_rpc::service]trait, and addpostcard-schemaas a direct dependency, since the derive emits::postcard_schema::paths. A foreign type with no upstream impl needs a local mirror type.usizeandisizehave noSchemaimpl, because serde widens them tou64/i64and their Rust-side width is a property of the target; use a fixed-width integer. - Dropping a
Portor anInterfaceno longer terminates aWorker, and aMessagePortis no longerstart()ed for you, on either side. Callport.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.