Exclosured
Compile Rust to WebAssembly, run it in your users' browsers, and talk to it from Phoenix LiveView.
exclosure (n.): an ecological term for a fenced area that excludes external interference. Your WASM code runs in a browser sandbox, isolated and secure.
What Makes Exclosured Different
Every other Elixir+Rust library (Rustler, Wasmex, Orb) runs code on your server. Exclosured runs code in the user's browser.
This changes three things:
Cost. The server does zero work for offloaded tasks. 1000 users = 1000 browsers doing their own compute. Your server scales by doing less.
Privacy. Data that only exists in WASM linear memory cannot reach your server. Not "we promise not to look," but "the code path makes it structurally impossible." The Private Analytics demo runs AES-256-GCM encryption, DuckDB SQL queries, and PII masking entirely in the browser. The server relays opaque encrypted blobs.
Latency. WASM runs locally with sub-millisecond response. The server synchronizes state at 20Hz while users get instant feedback at 60fps. No round-trip for drawing strokes, game input, or slider adjustments.
Resource-constrained servers. If your Phoenix app runs on a Raspberry Pi, a Nerves device, or an edge gateway, the server has limited CPU and memory. Exclosured lets you offload computation-intensive tasks (image processing, data analysis, crypto) to the user's browser, which typically has far more resources. The embedded server only manages state and serves the UI.
Key Features
Write Rust inline in Elixir with defwasm. No Cargo workspace, no .rs files. Simple functions fit on one line:
defmodule MyApp.Math do
use Exclosured.Inline
defwasm :add, args: [a: :i32, b: :i32], do: "return a + b;"
end
Add pure-Rust crate dependencies via deps::
defmodule MyApp.Renderer do
use Exclosured.Inline
defwasm :render_card, args: [data: :binary], deps: [maud: "0.26"] do
~RUST"""
use maud::html;
let markup = html! { div class="card" { h3 { (title) } } };
let bytes = markup.into_string().into_bytes();
data[..bytes.len()].copy_from_slice(&bytes);
return bytes.len() as i32;
"""
end
endWrite LiveView hooks in Rust. DOM access via web-sys, events via a pushEvent callback. JS becomes a 10-line shim:
#[wasm_bindgen]
pub struct MyHook { el: HtmlElement, push_event: js_sys::Function }
#[wasm_bindgen]
impl MyHook {
pub fn mounted(&mut self) { /* full DOM access, event listeners, canvas rendering */ }
pub fn on_event(&self, event: &str, payload: &str) { /* handle server events */ }
}Declarative state sync. LiveView assigns flow to WASM automatically:
<Exclosured.LiveView.sandbox
module={:visualizer}
sync={Exclosured.LiveView.sync(assigns, ~w(speed color count)a)}
canvas
/>
No push_event calls. When @speed changes, the component re-renders and the hook pushes the new value to WASM's apply_state().
Streaming results. WASM emits incremental chunks, LiveView accumulates:
Exclosured.LiveView.stream_call(socket, :processor, "analyze", [data],
on_chunk: fn chunk, socket -> update(socket, :results, &[chunk | &1]) end,
on_done: fn socket -> assign(socket, processing: false) end
)Server fallback. If WASM fails to load, the same call/5 runs an Elixir function instead. Result shape is identical:
Exclosured.LiveView.call(socket, :my_mod, "process", [input],
fallback: fn [input] -> process_on_server(input) end
)Typed events. Annotate Rust structs, get Elixir structs at compile time:
/// exclosured:event
pub struct ProgressEvent { pub percent: u32, pub stage: String }defmodule MyApp.Events do
use Exclosured.Events, source: "native/wasm/my_mod/src/lib.rs"
end
# MyApp.Events.ProgressEvent.from_payload(payload) => %ProgressEvent{percent: 75, stage: "done"}Telemetry. Every WASM operation emits :telemetry events. Plug into LiveDashboard or any monitoring tool.
| Event | Measurements | Metadata |
|---|---|---|
[:exclosured, :compile, :start] | system_time | module |
[:exclosured, :compile, :stop] | duration | module, wasm_size |
[:exclosured, :compile, :error] | duration | module, error |
[:exclosured, :wasm, :call] | module, func | |
[:exclosured, :wasm, :result] | module, func | |
[:exclosured, :wasm, :emit] | module, event | |
[:exclosured, :wasm, :error] | module, func, error | |
[:exclosured, :wasm, :ready] | module |
:telemetry.attach_many("my-handler",
[[:exclosured, :compile, :stop], [:exclosured, :wasm, :call]],
fn event, measurements, metadata, _ ->
Logger.info("[exclosured] #{inspect(event)} #{inspect(metadata)}")
end, nil)Prerequisites
- Elixir >= 1.15 and Erlang/OTP >= 26
- Rust with the wasm32 target and wasm-bindgen:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cliQuick Start
# mix.exs
def project do
[compilers: [:exclosured] ++ Mix.compilers(), ...]
end
def deps do
[{:exclosured, "~> 0.1.0"}]
endmix exclosured.init --module my_filter # scaffold Cargo workspace + starter crate
mix compile # builds Rust to .wasm automatically# config/config.exs
config :exclosured, modules: [my_filter: []]Install the JS hook (one copy shared across all packages that use Exclosured):
cd assets && npm install exclosured// assets/js/app.js
import { ExclosuredHook } from "exclosured";
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { Exclosured: ExclosuredHook }
});<%# In your LiveView template %>
<div id="wasm" phx-hook="Exclosured" data-wasm-module="my_filter"></div>Examples
Fifteen example applications in examples/, each with its own README.
| # | Demo | What it shows |
|---|---|---|
| 1 | Inline WASM | defwasm macro, zero setup |
| 2 | Text Processing | Compute offload, progress events |
| 3 | Interactive Canvas | 60fps wasm-bindgen rendering, PubSub sync |
| 4 | State Sync |
Declarative sync attribute, wave visualizer |
| 5 | Image Editor | Collaborative editing, WASM as source of truth |
| 6 | Racing Game | Server-authoritative multiplayer, anti-cheat |
| 7 | Offload Compute | Server vs WASM side-by-side timing |
| 8 | Confidential Compute | PII stays in browser, server sees only results |
| 9 | Latency Compare | Server round-trip vs local WASM |
| 10 | Private Analytics | E2E encrypted analytics, DuckDB-WASM, Rust hooks |
| 11 | LiveVue + WASM | Vue.js integration, real-time stats dashboard |
| 12 | LiveSvelte + WASM | Svelte integration, WASM markdown editor + KaTeX |
| 13 | Kino Data Explorer | Livebook smart cell, WASM stats with JS fallback |
| 14 | Brotli Compress | Brotli (WASM) vs Gzip (JS) compression benchmark |
| 15 | Matrix Multiply | nalgebra (WASM) vs JS nested loops, GFLOPS comparison |
Most demos run with cd examples/<name> && mix setup && mix phx.server. Some examples require additional setup (npm, Vite, etc.); see each example's README for details.
Inline vs Full Workspace
Inline defwasm | Full Cargo workspace | |
|---|---|---|
| Lines of Rust | < 50 | Any size |
| External crates |
Yes (via deps:) | Yes |
| Browser APIs (web-sys) | No | Yes |
| LiveView hooks in Rust | No | Yes |
| Persistent state | No | Yes |
| Rust testing | No | cargo test |
| IDE support | String in Elixir | Full rust-analyzer |
| Setup cost | Zero | Cargo workspace |
Configuration
config :exclosured,
source_dir: "native/wasm", # where Rust source lives
output_dir: "priv/static/wasm", # where .wasm files go
optimize: :none, # :none | :size | :speed (requires wasm-opt)
modules: [
my_processor: [], # default options
renderer: [canvas: true], # auto-creates canvas in sandbox component
shared: [lib: true] # library crate, not compiled to .wasm
]API Reference
# URLs
Exclosured.wasm_url(:my_mod) #=> "/wasm/my_mod/my_mod_bg.wasm"
Exclosured.wasm_js_url(:my_mod) #=> "/wasm/my_mod/my_mod.js"
# Call WASM from LiveView
Exclosured.LiveView.call(socket, :mod, "func", [args])
Exclosured.LiveView.call(socket, :mod, "func", [args], fallback: fn [args] -> ... end)
# Push state
Exclosured.LiveView.push_state(socket, :mod, %{key: value})
# Declarative sync (auto-pushes on assign change)
Exclosured.LiveView.sync(assigns, [:key1, :key2, renamed: :original_key])
# Streaming results
Exclosured.LiveView.stream_call(socket, :mod, "func", [args],
on_chunk: fn chunk, socket -> ... end,
on_done: fn socket -> ... end
)
# HEEx component
~H"<Exclosured.LiveView.sandbox module={:mod} sync={...} canvas />"// Rust guest crate
exclosured::emit("event_name", r#"{"key": "value"}"#); // send to LiveView
exclosured::broadcast("channel", &payload); // send to other WASM modulesCode Examples
Inline WASM with defwasm
Define a Rust function directly in Elixir. No Cargo workspace, no .rs files:
defmodule MyApp.Crypto do
use Exclosured.Inline
defwasm :hash_password, args: [password: :binary] do
"""
let mut hash: u32 = 5381;
for &byte in password.iter() {
hash = hash.wrapping_mul(33).wrapping_add(byte as u32);
}
"""
end
end
# After mix compile:
MyApp.Crypto.wasm_url() #=> "/wasm/my_app_crypto/my_app_crypto_bg.wasm"
MyApp.Crypto.wasm_exports() #=> [:hash_password]
External Crates via deps:
Add crate dependencies that compile to wasm32. Pure-Rust crates generally work; crates with C/system dependencies do not. Enable Cargo features with a keyword list:
defwasm :render_card, args: [data: :binary], deps: [maud: "0.26"] do
~RUST"""
use maud::html;
let markup = html! {
div class="card" {
h3 { (title) }
ul {
@for item in &items {
li { (item) }
}
}
}
};
let bytes = markup.into_string().into_bytes();
data[..bytes.len()].copy_from_slice(&bytes);
return bytes.len() as i32;
"""
end
To enable crate features (e.g. serde's derive), pass a keyword list as the third element:
defwasm :parse, args: [data: :binary],
deps: [{"serde", "1", features: ["derive"]}, {"serde_json", "1"}] do
~RUST"""
#[derive(serde::Deserialize)]
struct Input { name: String, value: f64 }
let input: Input = serde_json::from_str(
core::str::from_utf8(data).unwrap_or("{}")
).unwrap();
// ...
"""
endFull Cargo Workspace
For larger modules with persistent state, browser APIs, and multiple files:
// native/wasm/my_module/src/lib.rs
use wasm_bindgen::prelude::*;
use exclosured_guest as exclosured;
#[wasm_bindgen]
pub fn process(input: &str) -> i32 {
let result = input.split_whitespace().count();
exclosured::emit("progress", r#"{"percent": 100}"#);
result as i32
}# In your LiveView
def handle_event("analyze", %{"text" => text}, socket) do
socket = Exclosured.LiveView.call(socket, :my_module, "process", [text])
{:noreply, socket}
end
def handle_info({:wasm_result, :my_module, "process", count}, socket) do
{:noreply, assign(socket, word_count: count)}
endLiveView Hook in Rust
Write DOM-interacting hooks entirely in Rust, with JS as a thin shim:
#[wasm_bindgen]
pub struct SqlEditorHook {
container: HtmlElement,
push_event: js_sys::Function,
}
#[wasm_bindgen]
impl SqlEditorHook {
#[wasm_bindgen(constructor)]
pub fn new(container: HtmlElement, push_event: js_sys::Function) -> Self { ... }
pub fn mounted(&mut self) {
// Set up textarea, syntax highlighting overlay, keyboard shortcuts
// All via web-sys. No JS needed.
}
pub fn on_event(&self, event: &str, payload: &str) {
// Handle events from the server (e.g., sync SQL from another editor)
}
}// The entire JS hook (10 lines):
const mod = await import("/wasm/my_hook/my_hook.js");
await mod.default("/wasm/my_hook/my_hook_bg.wasm");
const pushFn = (event, payload) => this.pushEvent(event, JSON.parse(payload));
this._hook = new mod.SqlEditorHook(this.el, pushFn);
this._hook.mounted();
this.handleEvent("sync_sql", (d) => this._hook.on_event("set_sql", d.sql));Typed Events from Rust Structs
Annotate Rust structs, get Elixir structs at compile time:
/// exclosured:event
pub struct StageComplete {
pub stage_name: String,
pub items_processed: u32,
pub duration_ms: u32,
}defmodule MyApp.Events do
use Exclosured.Events, source: "native/wasm/pipeline/src/lib.rs"
end
# Pattern match on typed structs instead of raw maps:
def handle_info({:wasm_emit, :pipeline, "stage_complete", payload}, socket) do
event = MyApp.Events.StageComplete.from_payload(payload)
# event.stage_name => "validate"
# event.items_processed => 500
# event.duration_ms => 42
endCompared to Other Libraries
| Rustler | Wasmex | Orb | Exclosured | |
|---|---|---|---|---|
| Where code runs | Server | Server | Server | Browser |
| Compilation target | NIF | .wasm (server) | .wasm (server) | .wasm (browser) |
| Server CPU usage | Increases | Increases | Increases | Zero for offloaded tasks |
| Data privacy | Server sees all | Server sees all | Server sees all | Server can be excluded |
| LiveView integration | None | None | None | Bidirectional |
Use Rustler for server-side performance. Use Wasmex for server-side sandboxing. Use Exclosured when the computation should happen in the browser.
License
MIT