Running plushie
Plushie's renderer draws windows and handles input. Your Ruby code (the host) manages state and builds the UI tree. They talk over a wire protocol -- locally through a pipe, remotely over SSH, or through any transport you provide. See the collab demo for a working example of native, SSH, and WebSocket transports.
This guide covers all the ways to connect them.
Local desktop
The simplest setup: the host spawns the renderer as a child process.
bundle exec ruby lib/my_app.rb
Or from code:
Plushie.run(MyApp)
The renderer is resolved automatically. For most projects,
bundle exec rake plushie:download fetches a precompiled renderer and
you're done. If you have native Rust extensions,
bundle exec rake plushie:build compiles a custom renderer.
Configure which artifacts to install and where they go:
Plushie.configure do |config|
config.artifacts = [:bin, :wasm] # download both (default: [:bin])
config.bin_file = "bin/plushie" # custom binary path
config.wasm_dir = "public" # WASM files to public/
end
Then rake plushie:download and rake plushie:build use those
defaults automatically. Environment variables override config for
one-off use:
PLUSHIE_BIN_FILE=bin/plushie rake plushie:download
PLUSHIE_WASM_DIR=public rake plushie:download
Resolution order: env var > Plushie.configure > hardcoded default.
You can also set PLUSHIE_BINARY_PATH explicitly for runtime
binary resolution:
Plushie.configure do |config|
config.binary_path = "/opt/plushie/bin/plushie"
config.source_path = "~/projects/plushie" # used by rake plushie:build
end
Dev mode
Plushie::DevServer watches your source files and reloads on change.
Edit code, save, see the result instantly. The model state is preserved
across reloads.
bundle exec ruby lib/my_app.rb # live reload enabled
bundle exec ruby lib/my_app.rb --no-watch # disable file watching
Exec mode
The renderer can spawn the host instead of the other way around. This is useful when plushie is the entry point (a release binary or launcher) and it's the foundation for remote rendering over SSH.
plushie --exec "bundle exec ruby lib/my_app.rb --connect"
The renderer controls the lifecycle. When the user closes the window, the renderer closes stdin, and the Ruby process exits cleanly.
Remote rendering
Your host runs on a server. You want to see its UI on your laptop. The renderer runs locally (where your display is), the host runs remotely (where the data is), and SSH connects them:
[your laptop] [server]
renderer <--- SSH ---> host
draws windows init/update/view
handles input business logic
Your init/update/view code doesn't change at all.
Prerequisites
- Your laptop: the
plushierenderer installed and on your PATH. - The server: your Ruby project deployed with its dependencies. The server does NOT need the renderer or a display server.
- SSH access: you can
ssh user@serverfrom your laptop.
Quick start
plushie --exec "ssh user@server 'cd /app && bundle exec ruby lib/my_app.rb --connect'"
The renderer on your laptop spawns an SSH session, which starts the host on the server. The wire protocol flows through the SSH tunnel.
In-process SSH
If your server already runs a Ruby process (a Rails service, a data pipeline), you can connect directly to the running VM using a custom transport adapter. See custom transports below.
Binary distribution
The renderer always runs on the display machine (your laptop, not the server):
| Your project uses | Renderer needed | How to get it |
|---|---|---|
| Built-in widgets only | Precompiled | rake plushie:download or GitHub release |
| Pure Ruby extensions | Precompiled | Same -- composites don't need a custom build |
| Native Rust extensions | Custom build | rake plushie:build targeting your laptop's architecture |
Resiliency
Things go wrong. Renderers crash, code has bugs, networks drop. Plushie handles these without losing your model state.
Renderer crashes
If the renderer crashes (segfault, GPU error, out of memory), the host detects it and restarts automatically with exponential backoff. Your model state is preserved -- the new renderer receives fresh settings, a full snapshot of the current UI, and re-synced subscriptions and windows.
The host retries up to 5 times (100ms, 200ms, 400ms, 800ms, 1.6s). If all retries fail, it logs troubleshooting steps and the plushie runtime stops. The rest of your application is unaffected. A successful connection resets the retry counter, so intermittent crashes get a fresh budget each time.
Exceptions in your code
If update or view raises, the runtime catches it, logs the
error with a full backtrace, and keeps the previous model state.
The window stays open and continues responding to events. You don't
need begin/rescue in your callbacks.
After 100 consecutive errors, log output is suppressed to prevent flooding, with periodic reminders every 1000 errors.
Network drops
When an SSH connection drops, both sides detect the broken pipe:
- The renderer sees the host's stdout close.
- The host sees stdin close. Without daemon mode, the plushie runtime exits. With daemon mode, plushie keeps running with the model preserved.
When a new renderer connects, the host sends a snapshot of the current state. No restart, no state loss.
Plushie.run(MyApp, transport: :stdio, daemon: true)
Window close
When the user closes the last window, your update receives the
event. You can save state, persist data, or show a confirmation
dialog. In non-daemon mode, the runtime exits. In daemon mode,
it keeps running and waits for a new renderer to connect.
Event rate limiting
Over a network, continuous events like mouse moves, scroll, and slider drags can overwhelm the connection. Rate limiting tells the renderer to buffer these and deliver at a controlled frequency. Discrete events like clicks and key presses are never rate-limited.
Rate limiting is useful locally too -- a dashboard doesn't need 1000 mouse move updates per second even on a fast machine.
Global default
def settings
{default_event_rate: 60} # 60 events/sec -- good for most cases
end
For a monitoring dashboard:
def settings
{default_event_rate: 15}
end
Per-subscription
def subscribe(model)
[
Subscription.on_mouse_move(:mouse, max_rate: 30),
Subscription.on_animation_frame(:frame, max_rate: 60),
Subscription.on_mouse_move(:capture, max_rate: 0) # capture only
]
end
Per-widget
("volume", [0, 100], model.volume, event_rate: 15)
("seek", [0, model.duration], model.position, event_rate: 60)
Latency and animations
| Transport | Localhost | LAN | WAN |
|---|---|---|---|
| Port (local) | < 1ms | -- | -- |
| SSH | -- | 1-5ms | 20-150ms |
On a LAN, animations are smooth and interactions feel instant. Over a WAN (50ms+), user interactions have a visible round-trip delay. Design for this by keeping UI responsive to local input (hover effects, focus states) and accepting that model updates lag by the round-trip time.
Token authentication
When using --exec or remote rendering, you can require the host to
authenticate with a token. The renderer generates a random token and
passes it to the host process. The host must include the token in its
Settings message. Connections with an invalid token are rejected.
Configure token auth via Plushie.configure:
Plushie.configure do |config|
# Token is read from PLUSHIE_TOKEN env var when using --exec
end
Or pass token: directly to Plushie.run:
Plushie.run(MyApp, transport: :stdio, token: ENV["PLUSHIE_TOKEN"])
IoStream transport
The iostream transport lets you connect the Plushie runtime to any bidirectional message-passing channel. Instead of spawning a child process, pass a process or object that speaks the iostream protocol:
Plushie.run(MyApp, transport: [:iostream, adapter_pid])
This is useful for embedding a Plushie app inside an existing process (e.g. connecting over a TCP socket, WebSocket, or custom IPC).
Custom transports
For advanced use cases, the iostream transport lets you bridge any I/O mechanism to plushie. Write an adapter that speaks a simple four-message protocol, and plushie handles the rest.
The protocol
| Direction | Message | Purpose |
|---|---|---|
| Bridge -> Adapter | [:iostream_bridge, bridge] |
Init handshake |
| Adapter -> Bridge | [:iostream_data, binary] |
One complete protocol message |
| Bridge -> Adapter | [:iostream_send, iodata] |
Protocol message to send |
| Adapter -> Bridge | [:iostream_closed, reason] |
Transport closed |
Example: TCP adapter
class TCPAdapter
def initialize(socket)
@socket = socket
@bridge = nil
@buffer = "".b
end
def (msg)
case msg
in [:iostream_bridge, bridge]
@bridge = bridge
in [:iostream_send, data]
@socket.write(Plushie::Transport::Framing.encode_packet(data))
in [:tcp_data, data]
, @buffer = Plushie::Transport::Framing.decode_packets(@buffer + data)
.each { |m| @bridge.push([:iostream_data, m]) }
in [:tcp_closed]
@bridge&.push([:iostream_closed, :tcp_closed])
end
end
end
Framing
Raw byte streams (SSH channels, raw sockets) need message boundaries.
Plushie::Transport::Framing handles this:
# MessagePack: 4-byte length prefix
encoded = Plushie::Transport::Framing.encode_packet(data)
, remaining = Plushie::Transport::Framing.decode_packets(buffer + chunk)
# JSON: newline-delimited
encoded = Plushie::Transport::Framing.encode_line(data)
lines, remaining = Plushie::Transport::Framing.decode_lines(buffer + chunk)
How props reach the renderer
When you return a tree from view, it passes through stages before
reaching the wire:
Widget builders (DSL block methods,
Plushie::Widget::*modules) returnNodeobjects with raw Ruby values.Tree normalization (
Plushie::Tree.normalize) walks the tree and encodes each prop value via thePlushie::Encodemodule. Scoped IDs are resolved here.Protocol encoding stringifies symbol keys to strings, then serializes to MessagePack or JSON.
Next steps
- Getting started -- setup, first app
- Commands and subscriptions -- event rate limiting details
- Testing -- three-backend test framework
- Extensions -- custom widgets