Musubi

Hex.pmHexDocsLicenseCI

Musubi is a server-authoritative runtime for Elixir/Phoenix applications. A Phoenix socket owns one Musubi connection, and that connection can mount many declared root stores. Each root store runs in its own page-scoped process, renders typed state on the server, and streams RFC 6902 JSON Patch envelopes to the TypeScript client.

Musubi is useful when you want LiveView-style server authority, but your client is a TypeScript or React application that owns rendering.

Current Status

Musubi is pre-1.0. The public model is intentionally narrow:

Breaking changes are still possible before 1.0.

Installation

Add Musubi to your Phoenix application:

def deps do
  [
    {:musubi, "~> 0.2.0"}
  ]
end

For generated TypeScript types, add Musubi's compiler to the consumer app:

def project do
  [
    app: :my_app,
    compilers: Mix.compilers() ++ [:musubi_ts]
  ]
end

Configure the generated .d.ts output path:

config :musubi, :ts_codegen_output_path, "assets/src/generated/musubi.d.ts"

The JavaScript client packages ship inside the Musubi Hex package under deps/musubi/packages/. Reference them by local path from the frontend project's package.json (adjust the relative path so it points at deps/musubi/packages/<name> from the JS app root):

{
  "dependencies": {
    "@musubi/client": "file:../deps/musubi/packages/client",
    "@musubi/react": "file:../deps/musubi/packages/react",
    "phoenix": "file:../deps/phoenix"
  }
}

Then run the package manager once after mix deps.get:

pnpm install   # or npm install / yarn install

@musubi/client and @musubi/react ship TypeScript source directly; the consumer bundler (Vite, Phoenix esbuild) transpiles on demand — no build step required.

Minimal Example

Declare a root store:

defmodule MyAppWeb.Stores.CounterStore do
  use Musubi.Store, root: true

  state do
    field :count, integer()
  end

  command :increment do
    payload do
      field :amount, integer()
    end
  end

  @impl Musubi.Store
  def mount(params, socket) do
    {:ok, assign(socket, :count, Map.get(params, "count", 0))}
  end

  @impl Musubi.Store
  def render(socket), do: %{count: socket.assigns.count}

  @impl Musubi.Store
  def handle_command(:increment, %{"amount" => amount}, socket) do
    {:noreply, update(socket, :count, &(&1 + amount))}
  end
end

Expose it through a Musubi socket:

defmodule MyAppWeb.UserSocket do
  use Musubi.Socket,
    roots: [
      MyAppWeb.Stores.CounterStore
    ]

  @impl Musubi.Socket
  def handle_connect(_params, socket), do: {:ok, socket}

  @impl Musubi.Socket
  def handle_join(_params, socket), do: {:ok, socket}
end

Wire the socket into your Phoenix endpoint. Your UserSocket (built with use Musubi.Socket) is a Phoenix socket — mount it on the endpoint like any other transport:

defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  socket "/socket", MyAppWeb.UserSocket,
    websocket: true,
    longpoll: false

  # ... remaining plugs
end

The mount path ("/socket") is the same URL the TypeScript client passes to new Socket(...) below.

Mount the root from TypeScript:

import { Socket } from "phoenix"
import { connect } from "@musubi/client"

const socket = new Socket("/socket", { params: { token: window.userToken } })
const connection = await connect<Musubi.Stores>(socket)

const { store: counter, unmount } = await connection.mountStore({
  module: "MyAppWeb.Stores.CounterStore",
  id: "counter",
  params: { count: 1 },
})

await counter.dispatchCommand("increment", { amount: 1 })
await unmount()

The R generic is bound once on connect; the module string literal drives type inference for every later mountStore call. Command failures and timeouts throw a MusubiCommandError (from @musubi/client) with kind, command, storeId, reply, and an extracted code.

React consumers typically go through createMusubi<Musubi.Stores>() from @musubi/react, which binds R once and returns the full hook set — MusubiProvider (accepts connection or socket), useMusubiConnectionStatus, useMusubiRoot, useMusubiRootSuspense, useMusubiSnapshot, and useMusubiCommand (mutation-shaped: { dispatch, isPending, error, data, reset }). Use keyOf(proxy) for stable React list keys over child proxies.

Documentation

Build local ExDoc output with:

mix deps.get
mix docs

Examples

The repository includes standalone Phoenix examples under examples/:

Each example depends on Musubi with path: "../..".