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.

Features

Every other Elixir+Rust library (Rustler, Wasmex, Orb) runs code on your server. Exclosured runs code in the user's browser.

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

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

Add to your project

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

def deps do
  [{:exclosured, "~> 0.1.1"}]
end

Install 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_filter

Configure

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

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

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

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

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

Telemetry

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

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

Build, 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 --local

Or 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