Wasm/LiveView Bridge
A two-way bridge between wasm-bindgen Rust and a mounted Phoenix LiveView.
Outbound, it wraps the Phoenix.LiveView.JS command set so Rust/wasm code can fire LV events, navigate, dispatch DOM events, run transitions, and manage focus - without trampolining through hidden phx-* trigger elements.
Inbound, it lets Rust subscribe to server-pushed events from Phoenix.LiveView.push_event/3.
Written for game code that renders in wasm but wants the server to own authentication, state, and persistence.
Status
Early. I originally built this inside a wasm game project, then needed the same bridge in a second one, so I extracted it into a shared crate. Both projects use it today, and the docs were cleaned up ahead of a first release. The outbound side wraps the common JS commands. The inbound side covers server-pushed events via window phx:<event> listeners. Not yet implemented: client-to-server pushEvent with a reply callback, which needs a LiveView hook on the JS side.
Install
[]
= "0.3"
The crate only pulls in wasm-bindgen / js-sys / web-sys on the wasm32 target. On non-wasm targets every call stubs to Ok(()) so the command encoders can be unit-tested without a browser.
Sending commands to LiveView
Every outbound function is a thin wrapper around one Phoenix.LiveView.JS command, ultimately dispatched via window.liveSocket.execJS(rootEl, ...).
use wasm_liveview as lv;
// Push an event to the root LiveView (ad-hoc JSON).
push_event?;
// Or with a typed payload - no json! allocation, field names checked at compile time.
push_event?;
// Push to a component by CID or selector.
push_event_to?;
// Client-side routing.
navigate?; // pushes history
patch?; // replaces history, same LV
// Dispatch a CustomEvent on the LV root (or a selector).
dispatch?;
dispatch_with?;
// Run a CSS transition.
transition?;
// Focus management (uses LV's focus stack).
focus?;
push_focus?;
pop_focus?;
// Execute a JS command chain stored in a data-* attribute.
exec_attr?;
All outbound calls are fire-and-forget. execJS returns no reply, so if you need the server's response, use the hook-backed channel (not yet implemented).
Receiving server-pushed events
Phoenix.LiveView.push_event/3 dispatches phx:<event> CustomEvents on window whose detail is the payload. subscribe turns that into a typed listener:
use wasm_liveview as lv;
let sub = ?;
// `sub` removes the listener when dropped. To listen for the lifetime of the page:
sub.forget;
Deserialization failures are logged via console.error and the handler is skipped. Malformed payloads will never panic your wasm module.
Reading and watching server state
LiveView templates often render authoritative state as data-* attributes on a hidden "bridge" element. Bridge reads those attributes with typed parsing and watches them for changes via a MutationObserver - no polling, no custom JS hook.
use Bridge;
let bridge = new;
// One-shot reads. None if the attribute is missing, empty, or unparseable.
let status: = bridge.attr;
let remaining: = bridge.read;
let guesses:
= bridge.read_json;
// Watch for updates. Handler fires on each mutation that decodes cleanly,
// plus once on initial page ready and once per reconnect with the current value -
// so you don't need a separate "read once at startup" or "re-sync after disconnect" step.
let sub = bridge.?;
sub.forget;
phx-update="ignore" is recommended so LV mutates the bridge element's attributes in place rather than replacing it; if the element is replaced, the MutationObserver silently stops firing (the phx:page-loading-stop re-delivery still works in that case, since it re-queries by selector).
How it works
- Outbound. Each command is encoded as
[[op, args]]JSON and passed towindow.liveSocket.execJS(rootEl, commandJson). This is exactly the format LiveView's ownphx-click={JS.push(...)}attributes use, so the server sees your events indistinguishably from clicks. - Inbound. Phoenix already broadcasts
push_event/3payloads asphx:<event>windowCustomEvents;subscribejust adds a typedaddEventListenerand JSON-decodesevent.detailinto yourT. - Caching. wasm32 is single-threaded and a page hosts a single
liveSocket, sowindow,document,liveSocket, and itsexecJSfunction are cached in athread_local!for the page's lifetime. The cache is cleared on everyphx:page-loading-stopso a reconnect picks up a freshliveSocket/execJSrather than holding the pre-disconnect references. - Bridge reads.
Bridge::new(selector)stores only the selector; the element is re-queried on eachread/read_json/watchcall, so aBridgesurvives LV navigations.watchwraps aMutationObserverwith an attribute filter, so only the watched attribute wakes the callback. Watchers also receive a delivery on everyphx:page-loading-stopso initial-load and reconnect cases don't need separate handling.
Documentation
Every public item has rustdoc. To build and read the docs locally:
To mirror what docs.rs renders (feature-gate badges, etc.), build with the docsrs cfg on nightly:
RUSTDOCFLAGS="--cfg docsrs"
Once published, docs.rs will build the same configuration automatically - the [package.metadata.docs.rs] block in Cargo.toml pins the target to wasm32-unknown-unknown and enables --cfg docsrs.