Plushie.Protocol (Plushie v0.6.0)

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:

Summary

Types

Safe decode result returned by decode_message/2.

Structured message returned by decode_message!/2.

Wire format for protocol messages.

Structured decode error returned by decode_message/2.

Functions

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

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

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

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

Encodes an arbitrary map as wire-format iodata.

Encodes an advance_frame message for headless/test mode.

Encodes an effect request as a protocol message.

Encodes an image operation as a protocol message.

Encodes an interact request as a protocol message.

Encodes a list of patch operations as a protocol message.

Encodes an effect stub registration message.

Encodes application-level settings as a protocol message.

Encodes a UI tree snapshot as a protocol message.

Encodes a system-wide operation as a protocol message.

Encodes a system-wide query as a protocol message.

Encodes an effect stub removal message.

Encodes an unsubscribe message as a protocol message.

Encodes a single widget command as a protocol message.

Encodes a batch of widget commands as a protocol message.

Encodes a widget operation as a protocol message.

Encodes a window lifecycle operation as a protocol message.

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

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(),
       backend: String.t(),
       widgets: [String.t()],
       transport: 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()}
  | {:widget_command, String.t(), String.t(), map()}
  | {:widget_commands, [map()]}
  | {: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(), term()}
  | {:session_closed, String.t(), term()}

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, data: 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_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({"data":"AQID","handle":"logo","op":"create_image","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_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, settings, format \\ :msgpack)

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

Encodes a system-wide operation as a protocol message.

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

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

Encodes a system-wide query as a protocol message.

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_command(node_id, op, payload, format \\ :msgpack)

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

Encodes a single widget command as a protocol message.

Widget commands bypass the normal tree update / diff / patch cycle and are delivered directly to the target native widget on the Rust side.

encode_widget_commands(commands, format \\ :msgpack)

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

Encodes a batch of widget commands as a protocol message.

Each command in the list is a {node_id, op, payload} tuple. All commands in the batch are processed in a single cycle on the Rust side.

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, settings, format \\ :msgpack)

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

Encodes a window lifecycle operation as a protocol message.

Example

Plushie.Protocol.encode_window_op("open", "main", %{title: "My App"}, :json)
#=> ~s({"op":"open","session":"","settings":{"title":"My App"},"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.