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.
Features
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.
What you can do
| 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. |
| Telemetry |
Every WASM operation emits :telemetry events. |
Compared 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 |
Resources
| 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 |
Demos
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, 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 |
Most demos run with cd examples/<name> && mix setup && mix phx.server. Some require npm setup; see each example's README.
Installation
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-cliAdd to your project
# mix.exs
def project do
[compilers: [:exclosured] ++ Mix.compilers(), ...]
end
def deps do
[{:exclosured, "~> 0.1.1"}]
endInstall the JS hook
cd assets && npm install exclosured// assets/js/app.js
import { ExclosuredHook } from "exclosured";
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { Exclosured: ExclosuredHook }
});Scaffold a WASM module
mix exclosured.init --module my_filterConfigure
# 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
renderer: [canvas: true], # auto-creates canvas in sandbox component
shared: [lib: true] # library crate, not compiled to .wasm
]Usage
Inline WASM with defwasm
Simple functions fit on one line:
defmodule MyApp.Math do
use Exclosured.Inline
defwasm :add, args: [a: :i32, b: :i32], do: ~RUST"a + b"
end
Multi-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 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 | ~RUST sigil | Full rust-analyzer |
| Setup cost | Zero | Cargo workspace |
Full 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)}
endLiveView Hooks in Rust
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));Declarative State Sync
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().
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
)Rust Guest API
exclosured::emit("event_name", r#"{"key": "value"}"#); // send to LiveView
exclosured::broadcast("channel", &payload); // send to other WASM modulesLiveView API Reference
Exclosured.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.sync(assigns, [:key1, :key2, renamed: :original_key])
Exclosured.LiveView.stream_call(socket, :mod, "func", [args], on_chunk: ..., on_done: ...)Typed Events
/// 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"
endTelemetry
| 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 |
Deployment
Endpoint setup
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)Production build
mix compile # compiles Rust to .wasm
mix phx.digest # fingerprints static assets
MIX_ENV=prod mix release # builds the release
The .wasm files in priv/static/wasm/ are served like any other static asset. No special server-side runtime is needed.
CSP headers
If your app uses Content Security Policy, add:
script-src 'wasm-unsafe-eval';Precompiled distribution
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.
License
MIT