Skip to content

Repository files navigation

Exclosured

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

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.

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

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.

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

Choose 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.

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

Check your setup

Run the doctor task to verify the Rust toolchain, Exclosured config, WASM module sources, Phoenix static setup, and npm package wiring:

mix exclosured.doctor

Module options

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

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

Declare numeric return types when the default :i32 is not enough:

defwasm :ratio, args: [a: :f64, b: :f64], return: :f64 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

Exported 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")}
end

cancel_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.

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().

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.
    }
}

Web Worker Mode

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.

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.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: ...)

Testing WASM Flows

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.

Typed RPC

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

Generated 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 ProcessorModule

The 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"
);

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_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';
worker-src blob:;

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

About

Compile Rust to WebAssembly, run it in users' browsers, and communicate with it from Phoenix LiveView.

Topics

Resources

Stars

51 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages