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.
Every other Elixir+Rust library (Rustler, Wasmex, Orb) runs code on your server. Exclosured runs code in the user's browser.
- Zero server cost. 1000 users = 1000 browsers doing their own compute. Your server scales by doing less.
- Structural privacy. Data in WASM linear memory cannot reach your server. Not a policy, a code path.
- Local latency. WASM runs at sub-millisecond speed. No round-trip for drawing strokes, game input, or slider adjustments.
- Resource-constrained servers. Offload heavy tasks to the browser from a Raspberry Pi, Nerves device, or edge gateway.
| Capability | Description |
|---|---|
| Inline Rust | Write Rust inside Elixir with defwasm. No Cargo workspace needed. |
~RUST sigil |
Editor-friendly sigil for syntax highlighting and LSP support. |
| External crates | Add crate dependencies via deps: with feature support. |
| Rust LiveView hooks | Write DOM-interacting hooks in Rust, JS becomes a thin shim. |
| Declarative sync | LiveView assigns flow to WASM automatically via sync. |
| Streaming results | WASM emits incremental chunks, LiveView accumulates. |
| Server fallback | If WASM fails to load, run an Elixir function instead. |
| Typed events | Annotate Rust structs, get Elixir structs at compile time. |
| Typed RPC | Annotate Rust exports, get LiveView call helpers at compile time. |
| Telemetry | Every WASM operation emits :telemetry events. |
| 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 |
| Package | Purpose |
|---|---|
| exclosured (Hex) | Core Elixir library |
| exclosured (npm) | JS LiveView hook |
| exclosured_guest (crates.io) | Rust guest crate |
| exclosured_precompiled (Hex) | Precompiled WASM distribution |
| exclosured-precompiled-action | GitHub Action for CI precompilation |
| exclosured_example | Example library with precompilation |
Seventeen 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, inline WASM calculator |
| 14 | Brotli Compress | Brotli (WASM) vs Gzip (JS) compression benchmark |
| 15 | Matrix Multiply | 5-way benchmark: JS vs WASM vs WebGPU vs TF.js vs OpenCV |
| 16 | Elixir Notebook | Livebook-like static site: IEx + syntect highlighting + pulldown-cmark + Rust SQLite |
| 17 | Streaming + Worker Compare | Streaming emits plus main-thread vs worker-mode responsiveness |
Most demos run with cd examples/<name> && mix setup && mix phx.server. Some require npm setup; see each example's README. The Elixir Notebook (16) requires mise exec -- mix release; see its README.
- 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-cli# mix.exs
def project do
[compilers: [:exclosured] ++ Mix.compilers(), ...]
end
def deps do
[{:exclosured, "~> 0.1.4"}]
endcd assets && npm install exclosured// assets/js/app.js
import { ExclosuredHook } from "exclosured";
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { Exclosured: ExclosuredHook }
});mix exclosured.init --module my_filterChoose a starter template when the module has a specific role:
mix exclosured.init --module image_filter --template worker
mix exclosured.init --module renderer --template canvas
mix exclosured.init --module dom_hook --template liveview-hook
mix exclosured.init --module pipeline --template typed-events| Template | Use When | Config Hint |
|---|---|---|
default |
You need a small compute/1 example. |
my_filter: [] |
worker |
The module does compute-heavy work off the main thread. | image_filter: [worker: true] |
canvas |
The module owns a browser canvas and receives synced state. | renderer: [canvas: true] |
liveview-hook |
The module uses hook lifecycle exports such as init/1, apply_state/1, and destroyed/0. |
dom_hook: [canvas: true] |
typed-events |
Rust event structs should feed Exclosured.Events codegen. |
pipeline: [] |
basic aliases default, and hook aliases liveview-hook.
# config/config.exs
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
analyzer: [worker: true], # run compute calls in a Web Worker
renderer: [canvas: true], # auto-creates canvas in sandbox component
shared: [lib: true] # library crate, not compiled to .wasm
]Run the doctor task to verify the Rust toolchain, Exclosured config, WASM module sources, Phoenix static setup, and npm package wiring:
mix exclosured.doctor| Option | Default | Description |
|---|---|---|
features |
[] |
Cargo features to enable (--features a,b,c) |
no_default_features |
false |
Pass --no-default-features to cargo |
env |
[] |
Environment variables for the cargo build (keyword list) |
cargo_args |
[] |
Extra arguments forwarded directly to cargo build |
lib |
false |
Library crate (not compiled to standalone .wasm) |
canvas |
false |
Enable canvas integration |
worker |
false |
Mark the module as Web Worker friendly |
Example with all options (compiling SQLite C to WASM):
config :exclosured,
optimize: :size,
modules: [
# Pure Rust — just works
highlighter: [],
# Disable default features, enable specific ones
syntect: [no_default_features: true, features: ["html", "regex-fancy"]],
# C code needs a cross-compiler (env vars forwarded to `cc` crate)
sqlite: [
env: [
CC_wasm32_unknown_unknown: "/opt/homebrew/opt/llvm/bin/clang",
CFLAGS_wasm32_unknown_unknown: "--target=wasm32-wasi --sysroot=..."
]
],
# Arbitrary extra cargo flags
crypto: [cargo_args: ["--locked", "--offline"]]
]Simple functions fit on one line:
defmodule MyApp.Math do
use Exclosured.Inline
defwasm :add, args: [a: :i32, b: :i32], do: ~RUST"a + b"
endDeclare numeric return types when the default :i32 is not enough:
defwasm :ratio, args: [a: :f64, b: :f64], return: :f64 do
~RUST"""
a / b
"""
endMulti-line Rust with the ~RUST sigil:
defmodule MyApp.Crypto do
use Exclosured.Inline
defwasm :hash_password, args: [password: :binary] do
~RUST"""
let mut hash: u32 = 5381;
for &byte in password.iter() {
hash = hash.wrapping_mul(33).wrapping_add(byte as u32);
}
hash as i32
"""
end
endAdd crate dependencies with feature flags:
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();
// ...
"""
endInline 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 | ~RUST sigil |
Full rust-analyzer |
| Setup cost | Zero | Cargo workspace |
For larger modules with persistent state and browser APIs:
// 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)}
endExported functions may return either a plain value or a JavaScript Promise.
The LiveView result message is sent after the promise resolves, and rejected
promises arrive as {:wasm_error, module, func, reason}.
Use call_async/5 when you need to correlate concurrent calls:
def handle_event("analyze", %{"text" => text}, socket) do
{:ok, ref, socket} =
Exclosured.LiveView.call_async(socket, :my_module, "process", [text],
timeout: 5_000
)
{:noreply, assign(socket, active_ref: ref)}
end
def handle_info({:wasm_result, ref, :my_module, "process", count}, socket) do
{:noreply, assign(socket, active_ref: ref, word_count: count)}
end
def handle_info({:wasm_error, ref, :my_module, "process", :timeout}, socket) do
{:noreply, assign(socket, active_ref: ref, error: "WASM call timed out")}
endcancel_call/2 cancels a pending call_async/5 ref and asks the browser hook
to suppress any late result for that call. WASM modules can optionally export
cancel_call(ref) for cooperative cancellation.
Annotate Rust exports with /// exclosured:rpc to generate typed LiveView
call helpers:
/// exclosured:rpc
#[wasm_bindgen]
pub fn process(input: String, factor: f64) -> f64 {
input.len() as f64 * factor
}defmodule MyApp.Wasm do
use Exclosured.RPC,
source: "native/wasm/my_module/src/lib.rs",
module: :my_module
end
socket = MyApp.Wasm.process(socket, text, 2.0)
{:ok, ref, socket} = MyApp.Wasm.process_async(socket, text, 2.0, timeout: 5_000)When multiple sandbox instances are mounted on the same page, hook-managed guest
callbacks from exclosured_guest::emit/2 and exclosured_guest::broadcast/2
are routed through the calling hook instance. Broadcast channels still use the
shared browser-side bus, but receiving modules run their on_broadcast callback
inside their own host context.
Write DOM-interacting hooks entirely in Rust. JS becomes 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, keyboard shortcuts
// All via web-sys. No JS needed.
}
pub fn on_event(&self, event: &str, payload: &str) {
// Handle events from the server
}
}// The entire JS hook (6 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));LiveView assigns flow to WASM automatically. No push_event calls:
<Exclosured.LiveView.sandbox
module={:visualizer}
sync={Exclosured.LiveView.sync(assigns, ~w(speed color count)a)}
canvas
/>When @speed changes, the component re-renders and the hook pushes the new value to WASM's apply_state().
For higher-frequency state sync, encode the payload with Exclosured.Protocol
instead of JSON:
<Exclosured.LiveView.sandbox
module={:visualizer}
sync={Exclosured.LiveView.sync(assigns, ~w(speed color count)a)}
encoding={:binary}
/>Binary sync still calls the same Rust apply_state(data: &[u8]) export, but
data contains Exclosured protocol bytes. In Rust, decode it with the guest
helper:
use exclosured_guest::protocol::{self, Value};
#[wasm_bindgen]
pub fn apply_state(data: &[u8]) {
if let Ok(Value::Map(entries)) = protocol::decode(data) {
// Read entries from the compact binary state payload.
}
}For compute-heavy modules, run WASM off the browser's main thread by setting
worker: true in module config or passing worker to the sandbox component:
<Exclosured.LiveView.sandbox
module={:processor}
sync={%{threshold: @threshold}}
worker
/>The LiveView protocol stays the same: call/5, push_state/3, stream_call/5,
emit(), broadcast(), results, and errors use the same event shapes. Worker
mode is intended for compute modules and does not pass DOM canvas elements to
Rust init(); keep canvas and DOM hooks on the main thread for now.
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
)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
)exclosured::emit("event_name", r#"{"key": "value"}"#); // send to LiveView
exclosured::broadcast("channel", &payload); // send to other WASM modulesExclosured.LiveView.call(socket, :mod, "func", [args])
Exclosured.LiveView.call(socket, :mod, "func", [args], fallback: fn [args] -> ... end)
Exclosured.LiveView.push_state(socket, :mod, %{key: value})
Exclosured.LiveView.push_state(socket, :mod, %{key: value}, encoding: :binary)
Exclosured.LiveView.sync(assigns, [:key1, :key2, renamed: :original_key])
Exclosured.LiveView.stream_call(socket, :mod, "func", [args], on_chunk: ..., on_done: ...)Use Exclosured.Test in LiveView tests to drive WASM result paths without
booting a browser. The helpers deliver the same handle_info/2 messages your
LiveView receives after Exclosured hook events:
Exclosured.Test.ready(view, :processor)
Exclosured.Test.result(view, :processor, "score", 42)
Exclosured.Test.emit(view, :processor, "progress", %{"percent" => 50})
Exclosured.Test.error(view, :processor, "score", "boom")Correlated call_async/5 messages include the ref:
Exclosured.Test.result(view, ref, :processor, "score", 42)
Exclosured.Test.error(view, ref, :processor, "score", :timeout)Helpers return rendered HTML when passed a Phoenix.LiveViewTest.View; passing
a pid sends the message and returns :ok.
Exclosured.RPC reads annotated Rust exports at compile time and generates
Elixir helpers that delegate to Exclosured.LiveView.call/5 and
call_async/5.
/// exclosured:rpc
#[wasm_bindgen]
pub fn score(input: String, weight: f64) -> f64 {
input.len() as f64 * weight
}defmodule MyApp.Wasm do
use Exclosured.RPC,
source: "native/wasm/processor/src/lib.rs",
module: :processor
end
def handle_event("score", %{"input" => input}, socket) do
{:ok, ref, socket} = MyApp.Wasm.score_async(socket, input, 1.5, timeout: 5_000)
{:noreply, assign(socket, active_ref: ref)}
endGenerated helpers include typespecs and __rpc__/0 metadata for the parsed
exports. Keep the annotation on browser-callable #[wasm_bindgen] functions.
Generate a matching TypeScript declaration file for client-side imports:
mix exclosured.rpc.types \
--source native/wasm/processor/src/lib.rs \
--out assets/js/processor.d.ts \
--module-name ProcessorModuleThe generated declarations include the wasm-bindgen default initializer, named RPC exports, and a module interface that can be used with the npm loader:
import { ExclosuredLoader } from "exclosured/loader";
import type { ProcessorModule } from "./processor";
const processor = await ExclosuredLoader.load<ProcessorModule>(
"/wasm/processor/processor.js",
"/wasm/processor/processor_bg.wasm"
);/// 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
def handle_info({:wasm_emit, :pipeline, "stage_complete", payload}, socket) do
event = MyApp.Events.StageComplete.from_payload(payload)
# event.stage_name => "validate"
end| 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 |
Add "wasm" to your endpoint's Plug.Static :only list:
plug Plug.Static,
at: "/",
from: :my_app,
only: ~w(assets wasm fonts images favicon.ico robots.txt)mix compile # compiles Rust to .wasm
mix phx.digest # fingerprints static assets
MIX_ENV=prod mix release # builds the releaseThe .wasm files in priv/static/wasm/ are served like any other static asset. No special server-side runtime is needed.
If your app uses Content Security Policy, add:
script-src 'wasm-unsafe-eval';
worker-src blob:;
If you are publishing a library that includes WASM modules, you can distribute precompiled binaries so your users don't need the Rust toolchain. Use exclosured_precompiled:
# In your library
defmodule MyLib.Precompiled do
use ExclosuredPrecompiled,
otp_app: :my_lib,
base_url: "https://github.com/user/my_lib/releases/download/v0.1.0",
version: "0.1.0",
modules: [:my_processor]
endBuild, package, and upload in one workflow:
# Locally: compile from source, package into .tar.gz + .sha256
mix exclosured_precompiled.precompile
# Upload to GitHub Release
gh release create v0.1.0 _build/precompiled/*.tar.gz _build/precompiled/*.sha256
# Generate checksum file for Hex package
mix exclosured_precompiled.checksum --localOr automate with the GitHub Action:
- uses: cocoa-xu/exclosured-precompiled-action@v1
with:
project-version: ${{ github.ref_name }}See the exclosured_example repository for a complete working example with CI automation.
MIT