Packages

A typed Protobuf layer over Phoenix.Channel: declare event ⇄ message pairs at compile time and exchange typed structs over a binary wire format.

Current section

Files

Jump to
proto_channel README.md
Raw

README.md

# proto_channel

Typed Protobuf layer over `Phoenix.Channel`.

Two independently usable pieces that compose:

- **`ProtoChannel`** — a macro that lets a channel declare `event ⇄ Protobuf
  message` pairs at compile time, so handlers exchange typed structs instead of
  raw `{:binary, bytes}` payloads.
- **`ProtoChannel.Serializer`** — a `Phoenix.Socket.Serializer` that frames
  every socket frame as a protobuf `Envelope` (kinds: `PUSH`, `REPLY`,
  `BROADCAST`).

## Goals

- A typed-struct boundary inside channels: pattern-match request and reply
  structs at the edge, get compile-time field-name safety, and let dialyzer
  type-check `handle_proto/3` end-to-end.
- An all-binary wire format — no JSON, no per-event ad-hoc encoding.
- Stay small: each piece is opt-in and can be paired with hand-written
  counterparts.

## Installation

```elixir
def deps do
  [
    {:proto_channel, "~> 0.2.0"}
  ]
end
```

Docs: [https://hexdocs.pm/proto_channel](https://hexdocs.pm/proto_channel).

## Channel usage

```elixir
defmodule MyAppWeb.MyChannel do
  use ProtoChannel

  alias MyApp.{Request, Response, Notice}

  proto_message "ping", request: Request, reply: Response
  proto_push "notice", Notice
  proto_broadcast "notice", Notice

  @impl Phoenix.Channel
  def join("room:" <> _, _payload, socket), do: {:ok, socket}

  @impl ProtoChannel
  def handle_proto("ping", %Request{} = req, socket) do
    push(socket, "notice", %Notice{text: req.text})
    broadcast(socket, "notice", %Notice{text: req.text})
    {:reply, {:ok, %Response{text: req.text}}, socket}
  end
end
```

`proto_message` generates the `handle_in/3` clause that decodes the inbound
bytes into a `%Request{}`, dispatches to `handle_proto/3`, and encodes the
reply back to bytes. `proto_push` and `proto_broadcast` generate typed
wrappers around `Phoenix.Channel.push/3`, `broadcast/3`, `broadcast!/3`,
`broadcast_from/3`, and `broadcast_from!/3` — each declared event accepts only
its declared struct. To bypass the wrappers, call `Phoenix.Channel.push/3`
etc. directly.

### `handle_proto/3` callback

```elixir
@callback handle_proto(
            event :: String.t(),
            request :: struct(),
            socket :: Phoenix.Socket.t()
          ) :: result()
```

Supported return shapes:

- `{:reply, {:ok | :error, %Reply{}}, socket}`
- `{:noreply, socket}` / `{:noreply, socket, timeout | :hibernate}`
- `{:stop, reason, socket}` / `{:stop, reason, {:ok | :error, %Reply{}}, socket}`

### Typed join payloads

```elixir
proto_join MyApp.JoinRequest

@impl ProtoChannel
def handle_proto_join("room:" <> _, %MyApp.JoinRequest{} = req, socket) do
  {:ok, assign(socket, :resume_from, req.resume_from)}
end
```

`proto_join` generates `join/3`: it decodes the `phx_join` payload into
`%JoinRequest{}` and dispatches to `handle_proto_join/3`, which returns what
`join/3` would. A client that sends no join payload decodes to the struct's
defaults, so older clients need no special case. Don't define `join/3`
yourself alongside it.

In tests, join with the struct directly:
`subscribe_and_join(socket, MyChannel, "room:1", %MyApp.JoinRequest{...})`.

### Compile-time validation

`proto_join` may be declared once, requires `handle_proto_join/3`, and cannot
be combined with a hand-written `join/3`.
Duplicate event names within the same macro family raise `ArgumentError`.
Every referenced module must `use Protobuf` (verified via `__message_props__/0`),
so typos and accidentally pointing at a plain struct fail at compile time
rather than at runtime over the wire.

## Serializer usage

```elixir
socket "/socket", MyAppWeb.UserSocket,
  websocket: [serializer: [{ProtoChannel.Serializer, "~> 2.0.0"}]],
  longpoll: false
```

Every frame is wrapped in a protobuf `Envelope` (defined in
`priv/proto/wire.proto`). Payloads must be `{:binary, bytes}`; empty maps
are tolerated for Phoenix's join acks.