# Custom Transports

An ExRatatui "transport" is any module that decides **where the bytes go** for a running `ExRatatui.App`. The library ships three built-in transports — `:local` for the host tty via `ExRatatui.run/1`, `ExRatatui.SSH.Daemon` + `ExRatatui.SSH` for serving the same App over OTP `:ssh`, and `ExRatatui.Distributed.Listener` for serving the App to remote BEAM nodes. If none of those fits — you want to serve an App over a raw TCP socket, a Livebook widget, a WebSocket, whatever — you can plug in your own. This guide walks through the contract and a small TCP example.

## The contract

`ExRatatui.Transport` is the shared behaviour. It declares one optional callback (`child_spec/1`) and — more importantly — documents the two-way protocol every transport speaks with the internal Server runtime:

```elixir
@type server_transport ::
        :local
        | {:session, ExRatatui.Session.t(), (iodata() -> :ok)}
        | {:distributed_server, pid(), pos_integer(), pos_integer()}

@type to_server ::
        {:ex_ratatui_event, ExRatatui.Event.t()}
        | {:ex_ratatui_resize, pos_integer(), pos_integer()}
```

A byte-stream transport (SSH, a TCP bridge, a Kino widget) does three things. It creates an `ExRatatui.Session` sized to the remote terminal, starts the runtime server via `ExRatatui.Transport.start_server/1` with `transport: {:session, session, writer_fn}` (the server calls `writer_fn` with the rendered ANSI bytes on every frame — job is to ship those bytes to the remote terminal), and when bytes arrive *from* the remote terminal it forwards them to the server using `ExRatatui.Transport.ByteStream.forward_input/3`. When the remote terminal resizes, it uses `forward_resize/4`.

`ByteStream` handles the parsing side (`Session.feed_input/2`), absorbs `%Event.Resize{}` events into `{:ex_ratatui_resize, _, _}` notifications, and sends everything else to the server as `{:ex_ratatui_event, event}` — exactly the shape the runtime expects. Reuse it; don't hand-roll the dispatch loop.

## A minimal TCP transport

Two responsibilities, one module: a long-lived **acceptor** that loops on `:gen_tcp.accept` and re-arms after every client, and a short-lived **connection** task per client that owns the `Session`, the runtime server, and the byte pump. Splitting them is what makes the listener survive across disconnects — fold the acceptor and connection into one GenServer and the whole thing dies the moment the first client leaves.

Beyond that, three things to get right: emit the alt-screen enter/leave sequences (the in-memory `Session` deliberately doesn't — it's the transport's job, mirroring `ExRatatui.SSH`), monitor the runtime server so the connection tears down when the app quits, and flush the leave-screen bytes *before* you close the socket so the client's terminal is restored.

```elixir
defmodule MyApp.TcpTransport do
  @moduledoc false
  use GenServer

  @behaviour ExRatatui.Transport

  alias ExRatatui.Session
  alias ExRatatui.Transport.ByteStream

  # Same canonical pair `ExRatatui.SSH` emits. Without these the TUI
  # would paint into the client's main scrollback (no alt buffer) and
  # leave the client stuck in the alt buffer with the cursor hidden
  # after disconnect.
  @enter_screen "\e[?1049h\e[?25l"
  @leave_screen "\e[?1049l\e[?25h\e[0m"

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts)

  @impl true
  def init(opts) do
    mod  = Keyword.fetch!(opts, :mod)
    port = Keyword.get(opts, :port, 4040)

    # `reuseaddr: true` lets us re-bind the port instantly after a
    # restart instead of waiting through the OS' lingering close.
    {:ok, listener} =
      :gen_tcp.listen(port, [:binary, active: false, reuseaddr: true])

    {:ok, %{mod: mod, listener: listener}, {:continue, :accept}}
  end

  # Accept one connection, hand it off to a per-client task, and
  # immediately re-arm the next accept. Each connection runs in its own
  # *unlinked* Task so a single client crashing or disconnecting can't
  # take the acceptor with it. For production you'd start each client
  # under a `DynamicSupervisor` so they're observable; for a minimal
  # example this is enough.
  @impl true
  def handle_continue(:accept, state) do
    {:ok, socket} = :gen_tcp.accept(state.listener)
    {:ok, conn}   = Task.start(fn -> run_connection(state.mod, socket) end)
    :ok = :gen_tcp.controlling_process(socket, conn)
    send(conn, :go)
    {:noreply, state, {:continue, :accept}}
  end

  ## Per-connection worker

  defp run_connection(mod, socket) do
    # Wait until the acceptor has finished the controlling-process
    # handover before we switch the socket to active mode.
    receive do
      :go -> :ok
    end

    :ok = :inet.setopts(socket, active: true)
    :ok = :gen_tcp.send(socket, @enter_screen)

    session = Session.new(80, 24)
    writer  = fn bytes -> :gen_tcp.send(socket, bytes) end

    {:ok, server} =
      ExRatatui.Transport.start_server(
        mod: mod,
        name: nil,
        transport: {:session, session, writer}
      )

    server_ref = Process.monitor(server)
    connection_loop(socket, session, server, server_ref)
  end

  defp connection_loop(socket, session, server, server_ref) do
    receive do
      {:tcp, ^socket, bytes} ->
        _ = ByteStream.forward_input(session, server, bytes)
        connection_loop(socket, session, server, server_ref)

      {:tcp_closed, ^socket} ->
        # Client disconnected. Stop the server explicitly — it owns
        # the Session and won't notice on its own until its next
        # writer_fn call fails.
        if Process.alive?(server), do: GenServer.stop(server)

      {:DOWN, ^server_ref, :process, ^server, _reason} ->
        # App quit (q pressed, terminate returned :stop, mount failed,
        # …). Flush the leave-screen bytes *now*, while the socket is
        # still writable, then close the socket so the client's
        # terminal is restored.
        _ = :gen_tcp.send(socket, @leave_screen)
        _ = :gen_tcp.close(socket)
    end
  end
end
```

Drop `{MyApp.TcpTransport, mod: MyApp.Counter, port: 4040}` into your supervision tree and any `ExRatatui.App` module now runs over TCP — concurrent clients welcome, and the listener stays up across disconnects.

### Client requirements

Plain TCP has no equivalent of SSH's PTY negotiation, so the *client* has to put its terminal in raw mode for per-keystroke delivery. The most robust option is `socat`, which handles the terminal mode itself:

```bash
socat -,raw,echo=0,escape=0x03 TCP:localhost:4040
```

`raw,echo=0` on `-` (stdin) puts your terminal in raw mode without local echo for the duration of the connection and restores it on exit. `escape=0x03` lets you Ctrl-C out if the server hangs.

`nc` works too with an `stty` wrapper, with one caveat:

```bash
stty raw -echo; nc localhost 4040; stty sane
```

Without `stty raw -echo`, your local terminal stays in cooked mode: keystrokes are buffered locally until you press Enter, your local terminal echoes characters before they're sent, and the TUI never sees individual key events. The trailing `stty sane` restores your shell once `nc` exits.

`nc` has a teardown quirk worth knowing about: it doesn't exit purely on remote socket close — it also waits for EOF on its stdin. So when the app quits, the TUI restores correctly but you'll need to press one extra key for `nc` to notice (the keypress fails to write to the dead socket, which is what makes `nc` finally exit) before `stty sane` runs and your shell returns. If that bothers you, use `socat`.

### Limitation: no resize support

The TCP example above hardcodes `Session.new(80, 24)` and never updates it. That's not a bug in the example — it's a structural limit of raw TCP. SSH has a dedicated channel for terminal dimensions (`pty_req` at connect for the initial size, `window_change` packets while running for SIGWINCH), and `ExRatatui.SSH` translates both into `{:ex_ratatui_resize, w, h}` messages to the runtime. Raw TCP has no equivalent — every byte on the socket is application data, and dumb clients like `nc` and `socat` ignore SIGWINCH on your local terminal because they have no protocol slot to forward it through.

If you need *initial* size discovery without changing the client, the SSH transport's CPR trick works over TCP too: send `\e[s\e[9999;9999H\e[6n\e[u` after the alt-screen enter, the client's terminal answers with `\e[<row>;<col>R`, the `Session`'s input parser decodes that into a `%Event.Resize{}`, and `ByteStream.forward_input/3` absorbs it into a runtime resize automatically. See `lib/ex_ratatui/ssh.ex:151` for the exact wiring SSH uses in subsystem mode.

For *ongoing* resize you need a smarter client — one that watches SIGWINCH and sends the new dimensions back over the socket as a framed message your transport decodes. That's a real custom-client effort (think `mosh`-style); if you're at that point, SSH is almost always the better answer.

## Checklist for a new byte-stream transport

A few non-obvious things to get right. Declare `@behaviour ExRatatui.Transport` on the module that owns the connection (the GenServer, channel handler, whatever your framework gives you). Separate the **acceptor** from the **per-connection worker** — a single process trying to do both can only serve one client at a time, and it dies as soon as that client leaves, taking the listener with it. Size the `Session` to the remote terminal at connect time — if you can't know the size up front, pick a default (80×24) and send a resize as soon as you learn the real dimensions. The `writer_fn` you hand to the runtime server must be fast and non-blocking; it's called from the server process on every render, and a blocking write back-pressures the entire runtime. Emit the alt-screen enter sequence (`\e[?1049h\e[?25l`) before the first frame and the leave sequence (`\e[?1049l\e[?25h\e[0m`) on teardown — the `Session` deliberately doesn't (see the comment in `lib/ex_ratatui/ssh.ex`); without them the TUI paints over the client's shell scrollback and the client is left stuck in the alt buffer after disconnect. Route inbound bytes through `ByteStream.forward_input/3` rather than calling `Session.feed_input/2` yourself — you'll miss the Resize-absorption logic. Route inbound resize signals through `ByteStream.forward_resize/4`. Monitor the runtime server pid (or trap exits) so you notice when the app quits — that's your cue to flush the leave-screen sequence *while the connection is still writable* and then close the underlying socket/channel.

## When NOT to use a byte-stream transport

If your transport doesn't carry raw ANSI — for example, BEAM distribution ships widget trees between nodes as Erlang terms — use the `{:distributed_server, pid, w, h}` variant instead. See `ExRatatui.Distributed.Listener` for the pattern. Byte-stream helpers don't apply there; you still send `{:ex_ratatui_event, _}` and `{:ex_ratatui_resize, _, _}` messages to the Server yourself, but the rendered payload crosses the network as structured widget data, not bytes.

## Telemetry

Every transport automatically participates in the runtime telemetry events — see [Telemetry](telemetry.md). `[:ex_ratatui, :transport, :connect]` and `[:ex_ratatui, :transport, :disconnect]` fire with `%{transport: :session}` for byte-stream transports (or `%{transport: :distributed_server}`), so a single handler can observe every transport uniformly without caring which concrete module is carrying the bytes.

## Related

- [Running TUIs over SSH](ssh_transport.md) — reference implementation of a byte-stream transport, backed by OTP `:ssh`.
- [Running TUIs over Erlang Distribution](distributed_transport.md) — the non-byte-stream transport.
- `ExRatatui.Transport` — behaviour, typespecs, and `start_server/1`
- `ExRatatui.Transport.ByteStream` — the two helpers used above
