This tutorial builds a small counter root store, exposes it through a Phoenix socket, and mounts it from a TypeScript client.
1. Add Musubi
In the Phoenix app:
def deps do
[
{:musubi, "~> 0.2.0"}
]
endRun:
mix deps.get
If the application has a TypeScript frontend, add the Musubi compiler so the generated ambient types stay in sync with the server stores:
def project do
[
app: :my_app,
compilers: Mix.compilers() ++ [:musubi_ts]
]
endConfigure the generated type bundle:
config :musubi, :ts_codegen_output_path, "assets/src/generated/musubi.d.ts"2. Define A Root Store
Root stores opt in with use Musubi.Store, root: true. They may implement
mount/2, which receives client mount params before init/1 runs.
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}
end
@impl Musubi.Store
def handle_command(:increment, %{"amount" => amount}, socket) do
{:noreply, update(socket, :count, &(&1 + amount))}
end
endThe state do block is both a runtime validation contract and the source for
TypeScript generation. render/1 returns the Elixir-shaped state; Musubi
serializes it for the wire.
3. Declare Mountable Roots
A Musubi socket declares the root stores a client may mount. Application code implements Musubi callbacks; Phoenix socket and channel behaviours are handled by the adapter.
defmodule MyAppWeb.UserSocket do
use Musubi.Socket,
roots: [
MyAppWeb.Stores.CounterStore
]
@impl Musubi.Socket
def handle_connect(%{"token" => token}, socket) do
with {:ok, user} <- MyApp.Auth.verify_user_token(token) do
{:ok, Musubi.Socket.assign(socket, :current_user, user)}
else
_error -> :error
end
end
@impl Musubi.Socket
def handle_join(_params, socket), do: {:ok, socket}
endhandle_connect/2 runs when Phoenix establishes the socket. Use it for
connection-level authentication and assigns shared by every mounted root.
handle_join/2 runs once when the Musubi connection joins.
4. Wire The Phoenix Endpoint
Register the Musubi socket in the Phoenix endpoint. MyAppWeb.UserSocket
(built with use Musubi.Socket) is a Phoenix socket — mount it 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
endIf the application needs Phoenix session data in Musubi stores, configure
connect_info:
socket "/socket", MyAppWeb.UserSocket,
websocket: [connect_info: [session: @session_options]],
longpoll: falseStores can then read it with Musubi.Socket.session(socket).
5. Mount From TypeScript
@musubi/client ships inside the Musubi Hex package under
deps/musubi/packages/client. Reference it by local path from the
frontend project's package.json (adjust the relative path so it points
at deps/musubi/packages/client from the JS app root):
{
"dependencies": {
"@musubi/client": "file:../deps/musubi/packages/client",
"phoenix": "file:../deps/phoenix"
}
}Then install once after mix deps.get:
pnpm install # or npm install / yarn install
Open one connection, then mount one or more declared root stores by module name and id:
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 },
})
console.log(counter.count)
await counter.dispatchCommand("increment", { amount: 1 })
await unmount()connect<R>(socket) binds the registry once; later mountStore calls
infer the store type from the module literal alone. React consumers
typically use createMusubi<Musubi.Stores>() from @musubi/react to
get the same inference across hooks. The id must be unique within the
Musubi connection — a single connection can mount many root stores as
long as each root id is distinct.
6. Regenerate Types
When a store's state do or command declarations change, regenerate the
TypeScript bundle:
mix compile
In CI, check for drift:
mix compile.musubi_ts --check
Wire Encoding: Atoms Become Strings
Atom-typed fields and atom literals serialize to JSON strings. The TypeScript codegen emits matching string-literal unions; compare with strings on the client.
| Elixir field type | TypeScript | Wire |
|---|---|---|
field :winner, :p1 | :p2 | :draw | nil | "p1" | "p2" | "draw" | null | "p1" |
field :status, atom() | string | "on" |
The mental model: every atom alternative becomes its string form (the
atom's name verbatim, via Atom.to_string/1). Elixir atoms are
lowercase by convention, so :p1 arrives as "p1"; an atom like
:HTTPError would arrive as "HTTPError". nil serialises to JSON
null. The Elixir side keeps the atom shape inside socket.assigns;
the conversion happens on the way out through Musubi.Wire.to_wire/1.
Next Steps
- Read
Phoenix Setupfor connection/session details. - Read
Client and Reactfor React hooks and root cleanup. - Read
Testingfor theMusubi.Testingstore-test harness. - Read
Client Contractfor the wire and proxy model.