Musubi is Phoenix-first. The runtime uses Phoenix sockets and channels as the transport, while application modules implement Musubi callbacks.
Endpoint
Register one Musubi socket in the endpoint:
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
socket "/socket", MyAppWeb.UserSocket,
websocket: true,
longpoll: false
endIf the application needs Phoenix session data in stores, pass the session
through connect_info:
socket "/socket", MyAppWeb.UserSocket,
websocket: [connect_info: [session: @session_options]],
longpoll: falseThe Musubi socket captures connect_info[:session] during connect. Every root
mounted on the same Musubi connection can read it with:
Musubi.Socket.session(socket)Socket Module
Use Musubi.Socket instead of Phoenix.Socket in application code:
defmodule MyAppWeb.UserSocket do
use Musubi.Socket,
roots: [
MyApp.Stores.DashboardStore,
MyApp.Stores.PollRoomStore
]
@impl Musubi.Socket
def handle_connect(params, socket) do
case MyApp.Auth.verify(params["token"]) do
{:ok, user} -> {:ok, Musubi.Socket.assign(socket, :current_user, user)}
:error -> :error
end
end
@impl Musubi.Socket
def handle_join(_params, socket), do: {:ok, socket}
enduse Musubi.Socket generates the Phoenix socket adapter internally and
registers the "musubi:*" channel route. Do not add a separate Musubi channel
entry by hand.
Callback Responsibilities
handle_connect/2 runs once when Phoenix establishes the socket. It receives
transport params from the client. Use it for:
- user authentication
- long-lived assigns shared by every Musubi connection and mounted root
- rejecting the socket with
:erroror{:error, :unauthorized}
handle_join/2 runs once when the Musubi connection channel joins. It receives
the connection join params, not root mount params. Use it for:
- workspace or tenant scope checks
- connection-level authorization
- setting assigns shared by every root mounted on that Musubi connection
Root store mount/2 runs once for each mounted root. It receives the root's
own mount params:
@impl Musubi.Store
def mount(%{"poll_id" => poll_id}, socket) do
socket =
socket
|> assign(:poll_id, poll_id)
|> assign(:current_user, socket.assigns.current_user)
{:ok, socket}
endParams, Session, And Connect Info
Use these scopes consistently:
| Scope | Source | Lifetime | Use for |
|---|---|---|---|
| connect params | new Socket("/socket", params: ...) | Phoenix socket | auth tokens and transport credentials |
| connect info | Phoenix endpoint connect_info | Phoenix socket | session, peer data, headers configured by the endpoint |
| session | connect_info[:session] | Musubi socket | browser session data shared by mounted roots |
| join params | connect(socket, { topic: ... }) channel join payload | Musubi connection | connection-wide scopes |
| mount params | connection.mountStore({ module, id, params }) | one root store | business identity such as poll_id or cart_id |
Child stores do not receive mount/2. They run init/1 and can still read
root params, session, connect info, and inherited assigns from the socket.
Root Declaration Rules
Only stores declared in roots: [...] may be mounted by the client:
use Musubi.Socket,
roots: [
MyApp.Stores.CartPageStore,
MyApp.Stores.InboxStore
]The client mounts by module string and root id. mountStore returns a
{ store, unmount } pair:
const { store: cart, unmount } = await connection.mountStore({
module: "MyApp.Stores.CartPageStore",
id: "cart:current-user",
params: { cart_id: "current-user" },
})Calling mountStore again with the same root id on one Musubi connection
attaches to the existing mounted root instead of creating a second
server-side root. Each caller receives its own unmount handle, but the
underlying root stays mounted until the last caller unmounts:
await unmount()Child stores are server-owned and cannot be mounted or unmounted directly by the client.
Live Reloading on Store Changes
For dev-time auto-refresh of the generated TypeScript bundle, include your Musubi store directory in Phoenix's live-reload patterns and point the codegen output into your JS dev server's watched tree:
# config/dev.exs
config :my_app, MyAppWeb.Endpoint,
live_reload: [
patterns: [
~r"lib/.*/stores/.*\\.ex$",
# ... existing patterns
]
]
config :musubi, :ts_codegen_output_path, "ui/src/generated/musubi.d.ts"The chain is: edit a store, save, Phoenix.CodeReloader recompiles on the
next HTTP request, :musubi_ts rewrites the bundle, the JS dev server (Vite
HMR / equivalent) picks up the file change.
If you change a store in IEx without a request firing, recompile in the
IEx prompt runs the same compiler chain and updates the bundle.