rpc_dart_wasm 0.2.1
rpc_dart_wasm: ^0.2.1 copied to clipboard
Runtime-agnostic WASM bridge transport for rpc_dart — turns any byte-level wasm bridge into an IRpcTransport.
rpc_dart_wasm #
WASM runtime bridge transport for rpc_dart.
This package is intentionally runtime-agnostic. It does not load .wasm
modules by itself. Instead, a runtime backend
implements the byte-only RpcWasmBridge, and RpcWasmTransport.fromBridge
turns it into a normal IRpcTransport.
final transport = RpcWasmTransport.fromBridge(
bridge: myWasmBridge,
isClient: true,
);
final endpoint = RpcPeerEndpoint(transport: transport);
endpoint.start();
WASM Bootstrap #
Code compiled to WASM can bootstrap itself with RpcWasm.run(...):
import 'package:rpc_dart_wasm/rpc_wasm.dart';
void main() {
RpcWasm.run(
isClient: false,
configure: (endpoint) {
// Register RPC services here, then let the bootstrap start listening.
},
);
}
RpcWasm.run installs the runtime-side rpcWasmReceiveBytes callback,
adapts the host byte pipe into RpcWasmTransport, creates a
RpcPeerEndpoint, and starts it after configure returns.
Flutter Backend #
RpcFlutterWasmBridge is the built-in Flutter plugin backend:
final support = await RpcFlutterWasmBridge.checkSupport();
if (!support.canRunDartWasm) {
throw StateError('Dart WASM is not supported on this device');
}
final bridge = await RpcFlutterWasmBridge.load(
wasmBytes: wasmBytes,
mjsCode: mjsCode,
);
final transport = RpcWasmTransport.fromBridge(
bridge: bridge,
isClient: true,
);
Native backends:
- Android:
androidx.javascriptengine.JavaScriptSandbox, driven from a coroutine loop; bytes cross as Binder calls. - iOS: an offscreen
WKWebViewwith a customrpc-wasm:scheme handler. Bytes cross asfetchcalls the handler answers — guest-to-host is a POST, and host-to-guest is a long-polled GET.
iOS is WKWebView, not JavaScriptCore. JSC has no WebAssembly, so it cannot run a dart2wasm guest at all.
The Flutter host keeps lifecycle calls on the rpc_dart_wasm method channel,
but runtime byte traffic goes over raw binary messenger channels:
rpc_dart_wasm/<runtimeId>/outgoingfor Dart to nativerpc_dart_wasm/<runtimeId>/incomingfor native to Dart
The WASM JavaScript side should expose a byte receiver:
globalThis.rpcWasmReceiveBytes = function(bytes) {
// bytes is a Uint8Array containing one RpcChannelFrame.
};
To send bytes back to the host, call:
_rpcWasmSendBytes(bytes); // bytes is Uint8Array
Bridge Protocol #
RpcWasmBridge exchanges only Uint8List frames:
abstract interface class RpcWasmBridge {
Stream<Uint8List> get incoming;
Future<void> send(Uint8List data);
bool get isClosed;
Future<void> close();
}
No RPC-level JSON and no WASM-specific envelope is added by this package.
RpcWasmTransport adapts the bridge to IRpcChannel and reuses
RpcChannelTransport.fromChannel, so the wire format is the existing
RpcChannelFrame.
Runtime Backends #
Expected backends:
- Flutter plugin backend: an offscreen WKWebView on iOS, JavaScriptSandbox on Android.
- Browser backend:
dart:js_interop/ JS functions. - Native backend: wasmtime/wasmer embedding.
- Test backend: paired in-memory bridge.
The transport itself does not guarantee zero-copy. Most WASM host boundaries copy bytes; backends may optimize byte transfer independently.
What a call costs #
Every call crosses a process boundary and then waits for a driver tick, so the
cost is dominated by round trips rather than by bytes. Measured, not estimated —
integration_test/frame_cost_test.dart in example/, round-trip through a real
dart2wasm guest, after a discarded warm-up call:
| shape | n | Android p50 | iOS p50 | Android p99 | iOS p99 |
|---|---|---|---|---|---|
| empty unary | 200 | 10.3 ms | 3.0 ms | 78.7 ms | 12.7 ms |
| 1 KiB response | 200 | 8.8 ms | 31.1 ms | 91.7 ms | 617 ms |
| 64 KiB response | 100 | 12.0 ms | 33.5 ms | 51.7 ms | 183 ms |
| 1 MiB response | 20 | 68.3 ms | 81.2 ms | 132 ms | 265 ms |
Android 11 (API 30) emulator and iOS 18.6 simulator — a floor rather than a prediction, since real hardware is faster and the tail especially so. Read the SHAPE, which the hardware does not change: the price is dominated by the boundary rather than the payload, and the p99 is several times the p50 because a call can miss a driver tick.
The two platforms are not interchangeable. iOS answers an empty call in 3 ms against Android's 10 ms, and Android carries a 1 KiB payload in 8.8 ms against iOS's 31 ms — so the cheaper platform depends on whether your calls are chatty or bulky.
Budget accordingly: this transport is for isolating untrusted or hot-swappable logic, not for chatty per-item calls. Batch where you can.
Guest timers are not throttled #
The iOS guest runs in a WKWebView that is never added to a view hierarchy, so
its page is permanently hidden — and WebKit throttles timers in hidden pages.
Measured lag over the requested delay, ten samples each, clocked inside the
guest:
| delay | Android median | iOS median |
|---|---|---|
Timer(1 ms) |
3 ms | 0 ms |
Timer(10 ms) |
4 ms | 1 ms |
Timer(100 ms) |
5 ms | 1 ms |
Timer(1 s) |
7 ms | 99 ms |
So deadlines, keepalives and backoff inside a guest are usable on both. iOS is tighter at short delays and drifts about 10% on a one-second timer; Android is a flat few milliseconds throughout.
Stopwatch needs a shim on Android, and has one #
dart2wasm's glue calls performance.now(). A WKWebView supplies it;
JavaScriptSandbox is a bare V8 isolate that does not, so a guest using
Stopwatch used to die on Android with an opaque Internal server error while
the same guest worked on iOS. The Android boot script now installs a Date.now()
shim when performance is absent — millisecond resolution, so a guest
timing sub-millisecond intervals reads 0 rather than a wrong number.