Search documentation of Plushie

# Plushie.Protocol (Plushie v0.7.2)

Copy Markdown View Source

Wire protocol between the Elixir runtime and the Rust renderer.

Supports two wire formats:

- `:json` - newline-delimited JSON. Opt-in for debugging and observability. Each encode function returns a JSON string with a trailing newline.
- `:msgpack` - MessagePack via `Msgpax` (default). Returns iodata with no length prefix (Erlang's `{:packet, 4}` Port driver handles framing).

`decode_message/2` returns a safe tagged result for tests and diagnostics. `decode_message!/2` enforces the protocol contract and raises on malformed or incompatible payloads. The bridge/runtime path uses the strict variant.

Implementation is split across internal submodules:

- Protocol.Encode - all `encode_*` functions and serialization
- Protocol.Decode - `decode/2`, `decode_message/2`, `decode_message!/2`, and dispatch
- Protocol.Keys - named/physical key maps and `parse_key/1`
- Protocol.Parsers - strict enum parsers and widget-family checks

## Summary

### Types

decode_error_reason()
decode_result()

Safe decode result returned by `decode_message/2`.

decoded_message()

Structured message returned by `decode_message!/2`.

format()

Wire format for protocol messages.

parse_reason()

Structured decode error returned by `decode_message/2`.

### Functions

decode(data, format \\ :msgpack)

Decodes a wire-format binary into a string-keyed map without dispatch.

decode_event(event)

Decodes a renderer event map into a typed Plushie event struct.

decode_message(data, format \\ :msgpack)

Decodes a protocol message into an event struct or internal tuple.

decode_message!(data, format \\ :msgpack)

Decodes a protocol message into an event struct or internal tuple.

encode(map, format \\ :msgpack)

Encodes an arbitrary map as wire-format iodata.

encode_advance_frame(timestamp, format \\ :msgpack)

Encodes an advance_frame message for headless/test mode.

encode_command(id, family, value, format \\ :msgpack)

Encodes a widget-targeted command as a protocol message.

encode_commands(commands, format \\ :msgpack)

Encodes a batch of widget-targeted commands as a protocol message.

encode_effect(id, kind, payload, format \\ :msgpack)

Encodes an effect request as a protocol message.

encode_image_op(op, payload, format \\ :msgpack)

Encodes an image operation as a protocol message.

encode_interact(id, action, selector, payload, format \\ :msgpack)

Encodes an interact request as a protocol message.

encode_load_font(family, data, format \\ :msgpack)

Encodes a font-load message as a protocol message.

encode_patch(ops, format \\ :msgpack)

Encodes a list of patch operations as a protocol message.

encode_register_effect_stub(kind, response, format \\ :msgpack)

Encodes an effect stub registration message.

encode_settings(settings, format \\ :msgpack)

Encodes application-level settings as a protocol message.

encode_snapshot(tree, format \\ :msgpack)

Encodes a UI tree snapshot as a protocol message.

encode_subscribe(kind, tag, format \\ :msgpack, max_rate \\ nil, window_id \\ nil)

Encodes a subscribe message as a protocol message.

encode_system_op(op, payload, format \\ :msgpack)

Encodes a system-wide operation as a protocol message.

encode_system_query(op, payload, format \\ :msgpack)

Encodes a system-wide query as a protocol message.

encode_unregister_effect_stub(kind, format \\ :msgpack)

Encodes an effect stub removal message.

encode_unsubscribe(kind, format \\ :msgpack, tag \\ nil)

Encodes an unsubscribe message as a protocol message.

encode_widget_op(op, payload, format \\ :msgpack)

Encodes a widget operation as a protocol message.

encode_window_op(op, window_id, payload, format \\ :msgpack)

Encodes a window lifecycle operation as a protocol message.

parse_key(key)

Converts a key name string to an atom for named keys, or returns the string unchanged for single-character keys.

protocol_version()

Returns the current protocol version number.

## Types

# decode_error_reason()

    @type decode_error_reason() ::
      {:decode_failed, term()}
      | {:unknown_message, map()}
      | {:unknown_event_family, term(), map()}
      | {:invalid_event_field, String.t(), atom(), term(), parse_reason(), map()}

# decode_result()

    @type decode_result() :: decoded_message() | {:error, decode_error_reason()}

Safe decode result returned by `decode_message/2`.

# decoded_message()

    @type decoded_message() ::
      Plushie.Event.delivered_t()
      | {:hello,
         %{
           protocol: pos_integer(),
           version: String.t(),
           name: String.t(),
           mode: String.t(),
           backend: String.t(),
           transport: String.t(),
           native_widgets: [String.t()],
           widgets: [String.t()]
         }}
      | {:settings, map()}
      | {:snapshot, map()}
      | {:patch, list()}
      | {:effect, String.t(), String.t(), map()}
      | {:widget_op, String.t(), map()}
      | {:subscribe, String.t(), String.t()}
      | {:unsubscribe, String.t()}
      | {:image_op, String.t(), map()}
      | {:command, String.t(), String.t(), term()}
      | {:commands, [{String.t(), String.t(), term()}]}
      | {:window_op, String.t(), String.t(), map()}
      | {:system_op, String.t(), map()}
      | {:system_query, String.t(), map()}
      | {:interact, String.t(), String.t(), map(), map()}
      | {:interact_step, String.t(), [map()]}
      | {:interact_response, String.t(), [map()]}
      | {:screenshot_response, map()}
      | {:advance_frame, non_neg_integer()}
      | {:register_effect_stub, String.t(), term()}
      | {:unregister_effect_stub, String.t()}
      | {:effect_stub_ack, String.t()}
      | {:session_error, String.t(), String.t() | nil, term()}
      | {:session_closed, String.t(), term()}
      | {:prop_validation, String.t(), map() | nil}

Structured message returned by `decode_message!/2`.

Event payloads decode to `Plushie.Event.*` structs. Non-event protocol messages decode to internal tuples used by the bridge, runtime, and test harness.

# format()

    @type format() :: :json | :msgpack

Wire format for protocol messages.

# parse_reason()

    @type parse_reason() :: :unknown | :invalid

Structured decode error returned by `decode_message/2`.

These errors are intended for tests and diagnostics. The bridge/runtime path uses `decode_message!/2` and crashes on protocol violations.

## Functions

# decode(data, format \\ :msgpack)

    @spec decode(data :: binary(), format :: format()) :: {:ok, map()} | {:error, term()}

Decodes a wire-format binary into a string-keyed map without dispatch.

Unlike `decode_message/2` which dispatches into Elixir event structs and internal tuples, this returns the raw deserialized map. Used by script and test helpers that handle renderer responses (query_response, interact_response, etc.) directly.

# decode_event(event)

    @spec decode_event(event :: map()) :: Plushie.Event.delivered_t()

Decodes a renderer event map into a typed Plushie event struct.

This is the shared event-map decoder used for interact responses and other already-deserialized renderer events. Raises on unknown or malformed events. Every event from the renderer must include `window_id`.

# decode_message(data, format \\ :msgpack)

    @spec decode_message(data :: binary(), format :: format()) :: decode_result()

Decodes a protocol message into an event struct or internal tuple.

Returns `{:error, reason}` on parse failure or an unrecognised message shape. Use `decode_message!/2` for the strict runtime path.

## Examples

    iex> Plushie.Protocol.decode_message(~s({"type":"event","family":"click","id":"btn_save","window_id":"main"}), :json)
    %Plushie.Event.WidgetEvent{type: :click, id: "btn_save", scope: ["main"], window_id: "main", value: nil}

    iex> match?({:error, {:decode_failed, _}}, Plushie.Protocol.decode_message("not json"))
    true

# decode_message!(data, format \\ :msgpack)

    @spec decode_message!(data :: binary(), format :: format()) :: decoded_message()

Decodes a protocol message into an event struct or internal tuple.

Raises `Plushie.Protocol.Error` when the payload is malformed or violates the SDK's protocol contract.

# encode(map, format \\ :msgpack)

    @spec encode(message :: map(), format :: format()) :: iodata()

Encodes an arbitrary map as wire-format iodata.

For `:json`, returns a JSON string with a trailing newline. For `:msgpack`, returns msgpack iodata (no length prefix; the Erlang `{:packet, 4}` Port driver handles framing).

# encode_advance_frame(timestamp, format \\ :msgpack)

    @spec encode_advance_frame(timestamp :: non_neg_integer(), format :: format()) ::
      iodata()

Encodes an advance_frame message for headless/test mode.

# encode_command(id, family, value, format \\ :msgpack)

    @spec encode_command(
      id :: String.t(),
      family :: String.t(),
      value :: term(),
      format :: format()
    ) :: iodata()

Encodes a widget-targeted command as a protocol message.

Commands use the unified format: `{type: "command", id, family, value}`.

# encode_commands(commands, format \\ :msgpack)

    @spec encode_commands(
      commands :: [{String.t(), String.t(), term()}],
      format :: format()
    ) :: iodata()

Encodes a batch of widget-targeted commands as a protocol message.

Each command in the list is a `{id, family, value}` tuple. All commands in the batch are processed in a single cycle on the Rust side.

# encode_effect(id, kind, payload, format \\ :msgpack)

    @spec encode_effect(
      id :: String.t(),
      kind :: String.t(),
      payload :: term(),
      format :: format()
    ) :: iodata()

Encodes an effect request as a protocol message.

## Example

    Plushie.Protocol.encode_effect("req_1", "file_open", %{title: "Pick a file"}, :json)
    #=> ~s({"id":"req_1","kind":"file_open","payload":{"title":"Pick a file"},"session":"","type":"effect"}) <> "\n"

# encode_image_op(op, payload, format \\ :msgpack)

    @spec encode_image_op(op :: String.t(), payload :: map(), format :: format()) ::
      iodata()

Encodes an image operation as a protocol message.

Image ops are `create_image`, `update_image`, or `delete_image`. The payload map contains the op-specific fields (handle, data/pixels, width, height).

Binary fields (`data`, `pixels`) are encoded based on the wire format:

- `:msgpack` - wrapped in `Msgpax.Bin` for native msgpack binary type (zero overhead)
- `:json` - base64-encoded strings (JSON has no binary type)

## Example

    Plushie.Protocol.encode_image_op("create_image", %{handle: "logo", data: <<1, 2, 3>>}, :json)
    #=> ~s({"op":"create_image","payload":{"data":"AQID","handle":"logo"},"session":"","type":"image_op"}) <> "\n"

# encode_interact(id, action, selector, payload, format \\ :msgpack)

    @spec encode_interact(
      id :: String.t(),
      action :: String.t(),
      selector :: map(),
      payload :: map(),
      format :: format()
    ) :: iodata()

Encodes an interact request as a protocol message.

The renderer will process the interaction and respond with `interact_step` / `interact_response` messages.

# encode_load_font(family, data, format \\ :msgpack)

    @spec encode_load_font(family :: String.t(), data :: binary(), format :: format()) ::
      iodata()

Encodes a font-load message as a protocol message.

Typed binary message: the renderer receives `type: "load_font"` with a `payload` carrying the font `family` and raw font `data`. JSON encodes `data` as a base64 string; MessagePack encodes it as a native binary.

# encode_patch(ops, format \\ :msgpack)

    @spec encode_patch(ops :: list(), format :: format()) :: iodata()

Encodes a list of patch operations as a protocol message.

The ops list is encoded as-is into the payload.

## Example

    Plushie.Protocol.encode_patch([], :json)
    #=> ~s({"ops":[],"session":"","type":"patch"}) <> "\n"

# encode_register_effect_stub(kind, response, format \\ :msgpack)

    @spec encode_register_effect_stub(
      kind :: String.t(),
      response :: term(),
      format :: format()
    ) ::
      iodata()

Encodes an effect stub registration message.

# encode_settings(settings, format \\ :msgpack)

    @spec encode_settings(settings :: map(), format :: format()) :: iodata()

Encodes application-level settings as a protocol message.

## Example

    Plushie.Protocol.encode_settings(%{antialiasing: true, default_text_size: 16}, :json)
    #=> ~s({"session":"","settings":{"antialiasing":true,"default_text_size":16,"protocol_version":1},"type":"settings"}) <> "\n"

# encode_snapshot(tree, format \\ :msgpack)

    @spec encode_snapshot(tree :: term(), format :: format()) :: iodata()

Encodes a UI tree snapshot as a protocol message.

## Example

    Plushie.Protocol.encode_snapshot(%{tag: "text", value: "hello"}, :json)
    #=> ~s({"session":"","tree":{"tag":"text","value":"hello"},"type":"snapshot"}) <> "\n"

# encode_subscribe(kind, tag, format \\ :msgpack, max_rate \\ nil, window_id \\ nil)

    @spec encode_subscribe(
      kind :: String.t(),
      tag :: String.t(),
      format :: format(),
      max_rate :: non_neg_integer() | nil,
      window_id :: String.t() | nil
    ) :: iodata()

Encodes a subscribe message as a protocol message.

An optional `max_rate` (events per second) can be included to enable renderer-side event coalescing for this subscription. An optional `window_id` scopes the subscription to a specific window.

## Example

    Plushie.Protocol.encode_subscribe("on_key_press", "keys", :json)
    #=> ~s({"kind":"on_key_press","session":"","tag":"keys","type":"subscribe"}) <> "\n"

# encode_system_op(op, payload, format \\ :msgpack)

    @spec encode_system_op(op :: String.t(), payload :: map(), format :: format()) ::
      iodata()

Encodes a system-wide operation as a protocol message.

Uses the unified `_op` envelope: op-specific data lives under `payload`.

# encode_system_query(op, payload, format \\ :msgpack)

    @spec encode_system_query(op :: String.t(), payload :: map(), format :: format()) ::
      iodata()

Encodes a system-wide query as a protocol message.

Uses the unified `_op` envelope: query-specific data lives under `payload`.

# encode_unregister_effect_stub(kind, format \\ :msgpack)

    @spec encode_unregister_effect_stub(kind :: String.t(), format :: format()) ::
      iodata()

Encodes an effect stub removal message.

# encode_unsubscribe(kind, format \\ :msgpack, tag \\ nil)

    @spec encode_unsubscribe(
      kind :: String.t(),
      format :: format(),
      tag :: String.t() | nil
    ) :: iodata()

Encodes an unsubscribe message as a protocol message.

An optional `tag` identifies the specific subscription to remove (when multiple subscriptions share the same kind).

## Example

    Plushie.Protocol.encode_unsubscribe("on_key_press", :json)
    #=> ~s({"kind":"on_key_press","session":"","type":"unsubscribe"}) <> "\n"

# encode_widget_op(op, payload, format \\ :msgpack)

    @spec encode_widget_op(op :: String.t(), payload :: map(), format :: format()) ::
      iodata()

Encodes a widget operation as a protocol message.

## Example

    Plushie.Protocol.encode_widget_op("focus", %{target: "username"}, :json)
    #=> ~s({"op":"focus","payload":{"target":"username"},"session":"","type":"widget_op"}) <> "\n"

# encode_window_op(op, window_id, payload, format \\ :msgpack)

    @spec encode_window_op(
      op :: String.t(),
      window_id :: String.t(),
      payload :: map(),
      format :: format()
    ) :: iodata()

Encodes a window lifecycle operation as a protocol message.

Uses the unified `_op` envelope: op-specific data lives under `payload`; the `window_id` addressing field stays flat beside `op`.

## Example

    Plushie.Protocol.encode_window_op("open", "main", %{title: "My App"}, :json)
    #=> ~s({"op":"open","payload":{"title":"My App"},"session":"","type":"window_op","window_id":"main"}) <> "\n"

# parse_key(key)

    @spec parse_key(key :: String.t()) :: atom() | String.t()

Converts a key name string to an atom for named keys, or returns the string unchanged for single-character keys.

## Examples

    iex> Plushie.Protocol.parse_key("Escape")
    :escape

    iex> Plushie.Protocol.parse_key("a")
    "a"

# protocol_version()

    @spec protocol_version() :: non_neg_integer()

Returns the current protocol version number.