Search documentation of Plushie

# Shared State

Copy Markdown View Source

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:

    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:

    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`:

    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:

    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`:

    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:

    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:

    mix plushie_pad.server

Connect from another terminal using the Plushie renderer over SSH:

    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:

    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:

    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 cover each topic in depth when you need it.

← Previous Page Testing
Next Page → WASM Deployment