Exclosured

Copy Markdown

Hex.pm npm crates.io CI

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

CapabilityDescription
Inline RustWrite Rust inside Elixir with defwasm. No Cargo workspace needed.
~RUST sigilEditor-friendly sigil for syntax highlighting and LSP support.
External cratesAdd crate dependencies via deps: with feature support.
Rust LiveView hooksWrite DOM-interacting hooks in Rust, JS becomes a thin shim.
Declarative syncLiveView assigns flow to WASM automatically via sync.
Streaming resultsWASM emits incremental chunks, LiveView accumulates.
Server fallbackIf WASM fails to load, run an Elixir function instead.
Typed eventsAnnotate Rust structs, get Elixir structs at compile time.
TelemetryEvery WASM operation emits :telemetry events.

Compared to other libraries

RustlerWasmexOrbExclosured
Where code runsServerServerServerBrowser
Compilation targetNIF.wasm (server).wasm (server).wasm (browser)
Server CPU usageIncreasesIncreasesIncreasesZero for offloaded tasks
Data privacyServer sees allServer sees allServer sees allServer can be excluded
LiveView integrationNoneNoneNoneBidirectional

Resources

PackagePurpose
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-actionGitHub Action for CI precompilation
exclosured_exampleExample library with precompilation

Demos

Fifteen example applications in examples/, each with its own README.

#DemoWhat it shows
1Inline WASMdefwasm macro, zero setup
2Text ProcessingCompute offload, progress events
3Interactive Canvas60fps wasm-bindgen rendering, PubSub sync
4State SyncDeclarative sync attribute, wave visualizer
5Image EditorCollaborative editing, WASM as source of truth
6Racing GameServer-authoritative multiplayer, anti-cheat
7Offload ComputeServer vs WASM side-by-side timing
8Confidential ComputePII stays in browser, server sees only results
9Latency CompareServer round-trip vs local WASM
10Private AnalyticsE2E encrypted analytics, DuckDB-WASM, Rust hooks
11LiveVue + WASMVue.js integration, real-time stats dashboard
12LiveSvelte + WASMSvelte integration, WASM markdown editor + KaTeX
13Kino Data ExplorerLivebook smart cell, inline WASM calculator
14Brotli CompressBrotli (WASM) vs Gzip (JS) compression benchmark
15Matrix Multiply5-way benchmark: JS vs WASM vs WebGPU vs TF.js vs OpenCV
16Elixir NotebookLivebook-like static site: IEx + syntect highlighting + pulldown-cmark + Rust SQLite

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 setup; see its 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-cli

Add to your project

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

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

Module options

OptionDefaultDescription
features[]Cargo features to enable (--features a,b,c)
no_default_featuresfalsePass --no-default-features to cargo
env[]Environment variables for the cargo build (keyword list)
cargo_args[]Extra arguments forwarded directly to cargo build
libfalseLibrary crate (not compiled to standalone .wasm)
canvasfalseEnable canvas integration

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

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 defwasmFull Cargo workspace
Lines of Rust< 50Any size
External cratesYes (via deps:)Yes
Browser APIs (web-sys)NoYes
LiveView hooks in RustNoYes
Persistent stateNoYes
Rust testingNocargo test
IDE support~RUST sigilFull rust-analyzer
Setup costZeroCargo 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

EventMeasurementsMetadata
[: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