Skip to main content

docs/guides/16-shared-state.md

# Shared State

The pad is feature-complete. In this final chapter we take it one step
further: make it **collaborative**. Multiple people connect to the same
pad over SSH and see each other's changes in real time.

## Architecture

Plushie apps communicate with the renderer over a byte stream. By
default that stream is stdio to a local process, but it can be any
transport, including an SSH channel.

The key insight: each connected client gets its own `Plushie.Runtime`.
A shared GenServer holds the authoritative model and broadcasts changes
to all clients via `Runtime.dispatch/2`. This gives you the full SDK
pipeline per client (error isolation, tree diffing, subscriptions,
event coalescing) while keeping state centralized.

```
 ssh client 1 ──SSH──┐              ┌── Runtime 1 (tree diff, patch)
                      ├── iostream ──┤
 ssh client 2 ──SSH──┘              └── Runtime 2 (tree diff, patch)
                                    Shared GenServer
                                    (authoritative model,
                                     update/2, broadcast)
```

Each SSH channel acts as an iostream adapter. Events from any client
are decoded, forwarded to the shared server, which runs `update/2`
centrally and broadcasts the result. Each client's Runtime receives
the broadcast as a custom event, replaces its local model, re-renders,
diffs, and sends only the patches to the renderer.

## Broadcast event

Define a struct for the shared server to dispatch through each
client's Runtime:

```elixir
defmodule PlushiePad.Broadcast do
  @enforce_keys [:model]
  defstruct [:model]
end
```

Handle it in your app's `update/2` to replace the local model with
the authoritative state:

```elixir
def update(_model, %PlushiePad.Broadcast{model: shared_model}) do
  shared_model
end
```

## Shared state server

The shared GenServer holds the authoritative model, runs `update/2`
with error isolation, and broadcasts via `Runtime.dispatch/2`:

```elixir
defmodule PlushiePad.Shared do
  use GenServer

  require Logger

  def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, :ok, opts)
  def get_model(server), do: GenServer.call(server, :get_model)
  def connect(server, id, runtime), do: GenServer.call(server, {:connect, id, runtime})
  def disconnect(server, id), do: GenServer.cast(server, {:disconnect, id})
  def event(server, event), do: GenServer.cast(server, {:event, event})

  @impl true
  def init(:ok) do
    {:ok, %{model: PlushiePad.init([]), clients: %{}}}
  end

  @impl true
  def handle_call(:get_model, _from, state) do
    {:reply, state.model, state}
  end

  @impl true
  def handle_call({:connect, id, runtime}, _from, state) do
    Process.monitor(runtime)
    clients = Map.put(state.clients, id, runtime)
    {:reply, :ok, %{state | clients: clients}}
  end

  @impl true
  def handle_cast({:event, event}, state) do
    case safe_update(state.model, event) do
      {:ok, model} ->
        broadcast(state.clients, model)
        {:noreply, %{state | model: model}}

      :error ->
        {:noreply, state}
    end
  end

  @impl true
  def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
    clients =
      state.clients
      |> Enum.reject(fn {_id, p} -> p == pid end)
      |> Map.new()

    {:noreply, %{state | clients: clients}}
  end

  defp safe_update(model, event) do
    try do
      result = PlushiePad.update(model, event)

      model =
        case result do
          {m, _commands} -> m
          m -> m
        end

      {:ok, model}
    rescue
      e ->
        Logger.error("Shared: update/2 crashed: #{Exception.message(e)}")
        :error
    end
  end

  defp broadcast(clients, model) do
    event = %PlushiePad.Broadcast{model: model}

    Enum.each(clients, fn {_id, runtime} ->
      Plushie.Runtime.dispatch(runtime, event)
    end)
  end
end
```

When any client sends an event, the server runs `update/2` inside a
`try/rescue` so one client's bad input cannot crash the shared state.
Process monitoring cleans up disconnected clients automatically.

The important detail: `Runtime.dispatch/2` pushes the broadcast event
through the standard Runtime pipeline. The Runtime calls `update/2`
with your broadcast struct, renders the new model via `view/1`, diffs
against the previous tree, and sends only the changed patches to the
renderer. No full snapshots, no manual tree normalization.

## SSH channel as iostream adapter

Each SSH connection gets a channel that acts as an iostream adapter
for a dedicated `Plushie.Runtime`. The channel translates between
SSH framing and the iostream protocol that the Bridge speaks:

```elixir
defmodule PlushiePad.SshChannel do
  @behaviour :ssh_server_channel

  alias Plushie.Transport.Framing

  defstruct [:shared, :client_id, :conn, :channel, :bridge, :plushie_sup, buffer: <<>>]

  @impl true
  def init([shared]) do
    {:ok, %__MODULE__{shared: shared, client_id: "ssh-#{:erlang.unique_integer([:positive])}"}}
  end

  @impl true
  def handle_msg({:ssh_channel_up, channel, conn}, state) do
    state = %{state | conn: conn, channel: channel}

    # Seed the client with the current authoritative model
    model = PlushiePad.Shared.get_model(state.shared)

    plushie_name = :"plushie_pad_#{state.client_id}"

    {:ok, sup} =
      Plushie.start_link(PlushiePad,
        name: plushie_name,
        transport: {:iostream, self()},
        format: :msgpack,
        daemon: true,
        app_opts: [shared_model: model]
      )

    runtime = Plushie.runtime_for(plushie_name)
    PlushiePad.Shared.connect(state.shared, state.client_id, runtime)

    {:ok, %{state | plushie_sup: sup}}
  end

  # iostream protocol: Bridge registers itself
  def handle_msg({:iostream_bridge, bridge_pid}, state) do
    {:ok, %{state | bridge: bridge_pid}}
  end

  # iostream protocol: Bridge sends data to the renderer
  def handle_msg({:iostream_send, data}, state) do
    packet = Framing.encode_packet(data) |> IO.iodata_to_binary()
    :ssh_connection.send(state.conn, state.channel, packet)
    {:ok, state}
  end

  def handle_msg(_msg, state), do: {:ok, state}

  @impl true
  def handle_ssh_msg({:ssh_cm, _conn, {:data, _channel, 0, data}}, state) do
    combined = state.buffer <> data
    {frames, buffer} = Framing.decode_packets(combined)
    state = Enum.reduce(frames, state, &handle_frame/2)
    {:ok, %{state | buffer: buffer}}
  end

  def handle_ssh_msg({:ssh_cm, _conn, {:closed, _channel}}, state) do
    cleanup(state)
    {:stop, state.channel, state}
  end

  def handle_ssh_msg(_msg, state), do: {:ok, state}

  @impl true
  def terminate(_reason, state) do
    cleanup(state)
    :ok
  end

  defp handle_frame(frame, state) do
    case Plushie.Protocol.Decode.decode_message(frame, :msgpack) do
      {:hello, _} ->
        # Handshake: forward to Bridge
        if state.bridge, do: send(state.bridge, {:iostream_data, frame})
        state

      %_{} = event ->
        # User events go to shared server for centralized update
        PlushiePad.Shared.event(state.shared, event)
        state

      _ ->
        if state.bridge, do: send(state.bridge, {:iostream_data, frame})
        state
    end
  end

  defp cleanup(state) do
    PlushiePad.Shared.disconnect(state.shared, state.client_id)
    if state.bridge, do: send(state.bridge, {:iostream_closed, :ssh_closed})

    if state.plushie_sup do
      try do
        Plushie.stop(state.plushie_sup)
      catch
        :exit, _ -> :ok
      end
    end
  end
end
```

The channel decodes incoming frames to separate handshake messages
(forwarded to Bridge) from user events (forwarded to Shared). Outgoing
data from the Bridge gets SSH framing added before transmission.

On channel open, the channel fetches the current model, starts a
Plushie supervisor with iostream transport, and registers the runtime
with the shared server. From then on, the shared server broadcasts
model changes to the runtime via `dispatch/2`, and the runtime handles
tree diffing and patching automatically.

## SSH server with key authentication

The server uses Erlang's built-in `:ssh` daemon. On first start, it
generates an Ed25519 host key (using `ssh-keygen` if available,
falling back to Erlang's `:public_key` module). Client authentication
uses the user's existing SSH keys via `~/.ssh/authorized_keys`:

```elixir
defmodule PlushiePad.SshServer do
  def start(shared, port \\ 2222) do
    :ok = Application.ensure_started(:crypto)
    :ok = Application.ensure_started(:asn1)
    :ok = Application.ensure_started(:public_key)
    :ok = Application.ensure_started(:ssh)

    system_dir = ensure_host_key()
    user_dir = Path.expand("~/.ssh")

    {:ok, _} =
      :ssh.daemon({127, 0, 0, 1}, port,
        system_dir: String.to_charlist(system_dir),
        user_dir: String.to_charlist(user_dir),
        auth_methods: ~c"publickey",
        subsystems: [{~c"plushie", {PlushiePad.SshChannel, [shared]}}]
      )

    IO.puts("SSH server listening on localhost:#{port}")
  end

  defp ensure_host_key do
    dir = Path.join(["priv", "ssh"])
    File.mkdir_p!(dir)
    key_file = Path.join(dir, "ssh_host_ed25519_key")

    unless File.exists?(key_file) do
      case System.find_executable("ssh-keygen") do
        nil -> generate_key_erlang(key_file)
        _ -> System.cmd("ssh-keygen", ["-t", "ed25519", "-f", key_file, "-N", "", "-q"])
      end

      IO.puts("Generated SSH host key: #{key_file}")
    end

    dir
  end

  defp generate_key_erlang(path) do
    key = :public_key.generate_key({:namedCurve, :ed25519})
    pem = :public_key.pem_encode([:public_key.pem_entry_encode(:ECPrivateKey, key)])
    File.write!(path, pem)
  end
end
```

The host key persists in `priv/ssh/` so the server identity is stable
across restarts. Clients authenticate with their existing SSH keys.

## Starting the server

Add a mix task to start everything:

```elixir
defmodule Mix.Tasks.PlushiePad.Server do
  use Mix.Task

  def run(_args) do
    Mix.Task.run("app.start")
    {:ok, shared} = PlushiePad.Shared.start_link()
    PlushiePad.SshServer.start(shared)
    Process.sleep(:infinity)
  end
end
```

## Connecting

Start the server in one terminal:

```bash
mix plushie_pad.server
```

Connect from another terminal using the Plushie renderer over SSH:

```bash
plushie --exec "ssh -p 2222 localhost -s plushie"
```

Open a third terminal and connect again. Both windows show the same
pad. Edit an experiment in one window and click Save. The other
window updates instantly.

This is the same wire protocol, the same renderer binary, the same
`view/1` function. The only difference is the transport: SSH instead
of stdio. Your Elixir code runs on the server; the renderer runs
wherever there is a screen.

## Why Runtime.dispatch?

The previous version of this demo sent full UI tree snapshots to each
client on every change. With `Runtime.dispatch/2`, you get:

- **Tree diffing**: only changed patches are sent, not the full tree
- **Error isolation**: if one client's event crashes `update/2`, the
  shared server catches it without affecting other clients
- **Subscriptions**: each client Runtime manages its own subscription
  lifecycle (timers, key events, etc.)
- **Event coalescing**: rapid events (typing, scrolling) are coalesced
  by the Runtime before rendering

The shared server stays simple: hold model, run update, broadcast.
The SDK pipeline handles everything else.

## Per-client state

When some state is per-client (like a dark mode toggle), define it
in your model and preserve it across broadcasts. The collab demo in
the `plushie-demos` repository shows this pattern:

```elixir
def update(model, %Collab.Broadcast{model: shared_model, originator_id: originator_id}) do
  if originator_id == model.client_id do
    # Originator: local model is already up to date, just sync status
    %{model | status: shared_model.status}
  else
    # Other clients: replace shared fields, keep per-client state
    %{shared_model | dark_mode: model.dark_mode, client_id: model.client_id}
  end
end
```

The originator_id pattern prevents double-applying state changes on the
client that originated the event. Other clients replace their shared
fields with the broadcast state while preserving local preferences.

## Verify it

The shared GenServer is a plain GenServer that can be tested without
SSH:

```elixir
test "shared server broadcasts model changes" do
  {:ok, shared} = PlushiePad.Shared.start_link()

  # Start a real Runtime as the client
  {:ok, sup} =
    Plushie.start_link(PlushiePad,
      name: :test_client,
      transport: {:iostream, self()},
      format: :msgpack,
      daemon: true,
      app_opts: [shared_model: PlushiePad.Shared.get_model(shared)]
    )

  runtime = Plushie.runtime_for(:test_client)
  PlushiePad.Shared.connect(shared, "test", runtime)

  PlushiePad.Shared.event(shared, %WidgetEvent{type: :click, id: "save"})

  # The runtime received a Broadcast event via dispatch, re-rendered,
  # and sent a patch to us (the iostream adapter)
  assert_receive {:iostream_send, _data}, 1000

  Plushie.stop(sup)
end
```

---

WebSocket is another viable transport for shared state, particularly
for browser-based collaboration where SSH is not available. The wire
protocol is the same; only the transport layer changes. See the collab
demo in the `plushie-demos` repository for a WebSocket-based example
that includes origin checking, rate limiting, and per-client state.

You now have a collaborative editor with file management, styling,
animation, subscriptions, effects, canvas drawing, custom widgets,
tests, and shared state over SSH. The
[reference docs](../reference/built-in-widgets.md) cover each topic
in depth when you need it.