Current section
Files
Jump to
Current section
Files
lib/musubi.ex
defmodule Musubi do
@moduledoc """
Server-authoritative, page-scoped runtime for Elixir/Phoenix with a
framework-agnostic JavaScript client.
## The name
`Musubi` (Japanese 結び) means *knot*, *bond*, or *connector* — the act
of tying threads together at a point. In this runtime, every store is
a musubi: a node that binds parent assigns to child renders, holds its
own state, and lets reactive changes propagate through the bonds.
## What it does
One BEAM process per connected page owns a tree of composable
**stores**. Each store renders a piece of resolved state. When state
changes, the runtime computes a structural diff against the previous
wire output and pushes an RFC 6902 JSON Patch to the client over a
`Phoenix.Channel`. The client materializes the patch into an
immutable snapshot tree without prescribing a UI framework.
Change tracking mirrors `Phoenix.LiveView`: per-key `__changed__`
flags drive per-store memoization, so unchanged subtrees are reused
verbatim and never re-rendered, re-serialized, or re-diffed.
## Core building blocks
* `Musubi.Store` — define a store with `state do`, command
handlers, optional async, and a `render/1` callback.
* `Musubi.State` / `Musubi.Input` — typed state and command-input
schemas.
* `Musubi.Socket` — per-store state carrier with LV-aligned
`assigns.__changed__` dirty tracking.
* `Musubi.Page.Server` — the per-page runtime process.
* `Musubi.Resolver` / `Musubi.Reconciler` — tree resolution and
mount/update/reuse decisions.
* `Musubi.Diff` — RFC 6902 add/remove/replace patch generator.
* `Musubi.Wire` — protocol turning resolved Elixir terms into wire
form.
* `Musubi.Async` — `assign_async/3,4`, `start_async/3,4`,
`cancel_async/2,3`, `stream_async/3,4`.
* `Musubi.Stream` — LV-aligned streams with stable wire markers.
* `Musubi.Testing` — test harness for stores.
See `docs/PRD.md` and `spec/decisions/` for the design rationale.
"""
@type store_id() :: Musubi.Socket.store_id()
@doc """
Targets a mounted child store with new assigns, aligned with
`Phoenix.LiveView.send_update/2`.
The `assigns` map is delivered to the addressed store's `update/2`
callback (or merged directly when the store does not export it). The
store's socket goes dirty and only that subtree re-renders; a clean
root short-circuits its own `render/1`. One coalesced patch
envelope ships for the cycle. A `store_id` that no longer resolves to a
mounted store is a no-op (LV-aligned) and emits
`[:musubi, :send_update, :no_target]` telemetry.
This two-arity form sends to `self()` — call it from inside the root
store's `handle_info/2`, where `self()` is the page process. It is the
intra-page last hop for cross-connection fan-out coordinated over
`Phoenix.PubSub`; Musubi owns the targeting, the
application owns the broadcast.
`store_id` may address any mounted store, including the root (`[]`).
Push keys the **child owns**, not keys the parent passes it — e.g. an
internal trigger like `%{reload_token: ref}` the child's `update/2`
reacts to by reloading. A key the parent also supplies is reconciled
back to the parent's value on the very next resolve (parent props win,
LiveView-aligned) and re-invokes `update/2`, so it has no net effect.
## Examples
iex> me = self()
iex> Musubi.send_update(["comments"], %{reload_token: :ref})
:ok
iex> receive do {:musubi_send_update, _, _} = msg -> msg end
{:musubi_send_update, ["comments"], %{reload_token: :ref}}
iex> me == self()
true
"""
@spec send_update(store_id(), map()) :: :ok
def send_update(store_id, assigns) when is_list(store_id) and is_map(assigns) do
send(self(), {:musubi_send_update, store_id, assigns})
:ok
end
@doc """
Targets a mounted child store on `page_pid` with new assigns, aligned
with `Phoenix.LiveView.send_update/3`.
Same semantics as `send_update/2` but addressed at an explicit page
process — for an in-node caller holding the page pid (e.g. a release
`rpc` task) rather than running inside the page's own `handle_info/2`.
## Examples
iex> Musubi.send_update(self(), ["comments"], %{reload_token: :ref})
:ok
iex> receive do {:musubi_send_update, _, _} = msg -> msg end
{:musubi_send_update, ["comments"], %{reload_token: :ref}}
"""
@spec send_update(pid(), store_id(), map()) :: :ok
def send_update(page_pid, store_id, assigns)
when is_pid(page_pid) and is_list(store_id) and is_map(assigns) do
send(page_pid, {:musubi_send_update, store_id, assigns})
:ok
end
end