Exclosured

Hex.pmnpmcrates.ioCI

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
end

Write 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_timemodule
[:exclosured, :compile, :stop]durationmodule, wasm_size
[:exclosured, :compile, :error]durationmodule, 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

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli

Quick Start

# mix.exs
def project do
  [compilers: [:exclosured] ++ Mix.compilers(), ...]
end

def deps do
  [{:exclosured, "~> 0.1.0"}]
end
mix 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 modules

Code 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();
  // ...
  """
end

Full 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)}
end

LiveView 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
end

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

Use Rustler for server-side performance. Use Wasmex for server-side sandboxing. Use Exclosured when the computation should happen in the browser.

License

MIT