
[](https://github.com/saleyn/flick/actions/workflows/ci.yml)
[](https://hex.pm/packages/flick)
[](https://hex.pm/packages/flick)
Binary Erlang External Term Format (ETF) WebSocket transport for Phoenix,
decoded on the client with [flick.js](priv/flick.js).
The project is an evolution of the JavaScript ETF codec implemementation
[erlb.js](https://github.com/saleyn/erlb.js) migrated to be used by Phoenix
applications.
## Raw WebSocket vs Phoenix Channels — which do you need?
Phoenix ships two WebSocket abstractions that serve different purposes:
**Raw WebSocket (`WebSock` behaviour)** is a plain, low-overhead connection.
Your server module receives frames and pushes `{:binary, payload}` replies
directly. There is no topic system, no join/leave lifecycle, and no built-in
broadcast. This is the right choice when you control both ends of the
connection and want minimal latency — for example, streaming live market data
or sensor feeds to a single client page.
**Phoenix Channels** layer a pub/sub protocol on top of a WebSocket. A single
connection is multiplexed across named topics; clients join and leave topics,
and the server can broadcast to all subscribers via `Phoenix.PubSub`. The
tradeoff is that every message is wrapped in a JSON envelope
(`join_ref`, `ref`, `topic`, `event`, `payload`). This is the right choice
when you already use Channels for other features and want ETF to replace only
the serialization layer.
**Flick supports both.** The default `mix flick.install` sets up a raw
`WebSock` connection. Channels support can be included from the start:
```bash
mix flick.install --channels
```
or added to an existing installation without re-running the boilerplate
generator:
```bash
mix flick.install --channels --no-boilerplate
```
Either way, only `flick-channel.min.js.gz` is vendored — the server and
client Channels configuration is always done manually. See
[Phoenix Channels over ETF](#phoenix-channels-over-etf) for the full
configuration.
## Features
- **Raw WebSocket + ETF** — push `{:binary, etf_bytes}` frames directly via
the `WebSock` behaviour; no JSON serialization, no Channels overhead.
- **Phoenix Channels support** — `Flick.Socket.Serializer` and
`flick-channel.js` swap out Phoenix's default JSON channel
serializer with an ETF one, keeping full Channels semantics (topics, join,
push, broadcast).
- **Zero-dependency client** — `flick.js` is a single self-contained script
exposing `window.Flick`; no npm package or bundler step required.
- **`mix flick.install`** — one command vendors the pre-minified, pre-gzipped
`flick.min.js.gz`, patches the root layout, and generates all server and
client boilerplate. No esbuild or npm step required.
- **`nil` / `true` / `false` round-trip** — Elixir `nil`, `true`, and `false`
atoms decode to their JS equivalents without any custom mapping.
- **Bidirectional encoding** — `flick.js` can both encode JS objects to ETF
and decode ETF binary frames back to JS values.
## Why ETF instead of JSON
- **Compact binary encoding** — no string serialization/parsing overhead.
- **Native Elixir types** — `:erlang.term_to_binary/1` on any map/list/number
with zero custom serialization code.
- **Single shared format** — the same payload structure flows from
`Phoenix.PubSub` messages straight to the wire.
## Architecture
This uses a **raw WebSocket** via the `WebSock` behaviour and
`WebSockAdapter`, not Phoenix Channels. Channels add their own JSON-based
framing protocol (join/leave/reply envelopes) that would conflict with raw
ETF binary frames. The pieces:
1. A Phoenix controller action that upgrades the connection with
`WebSockAdapter.upgrade/4`.
2. A `WebSock` behaviour module that pushes `{:binary, etf_bytes}` frames.
3. A router `get` route pointing at the controller action.
4. `flick.js` loaded as a global `<script>` tag, exposing `window.Flick`.
5. Client JS that sets `ws.binaryType = "arraybuffer"` and calls
`window.Flick.decode(event.data)` on each message.
---
## Step-by-Step: Adding an ETF WebSocket to a New Project
> **Prefer a worked example?** The [example/TUTORIAL.md](example/TUTORIAL.md)
> walks through a concrete Phoenix stock-ticker app, migrating it from HTTP JSON
> polling to a flick ETF WebSocket step by step, with before/after diffs for
> every changed file.
### 1. Add `websock_adapter` to your dependencies
`mix flick.install` checks that `:websock_adapter` is listed in your `mix.exs`
and exits with an error if it is missing. Typically Phoenix >= 1.7 pulls it in
transitively (via `bandit`), but it must appear explicitly so the check passes:
```elixir
{:websock_adapter, "~> 0.5"}
```
Then run:
```bash
mix deps.get
```
### 2. Run `mix flick.install`
```bash
mix flick.install # defaults
mix flick.install --module TickerSocket --path /ws/ticker
mix flick.install --channels # also install flick-channel.js
mix flick.install --yes # skip confirmation prompt
```
The installer prints a plan of every change and asks for confirmation before
writing anything. It handles all remaining setup steps automatically:
| What | How |
|-----------------------------------------|----------------------------------------------|
| Vendor `flick.min.js.gz` | written to `assets/vendor/` and `priv/static/assets/js/` |
| Add `<script>` tag to root layout | inserted before `app.js` |
| `WebSock` module skeleton | created at `lib/<app>_web/my_socket.ex` |
| Upgrade controller | created at `lib/<app>_web/my_socket_controller.ex` |
| Router route | `get "/ws", ...` inserted into `router.ex` |
| Starter JS hook | appended to `assets/js/app.js` |
All steps are idempotent — re-running is safe.
`Plug.Static` serves `.gz` files automatically when `gzip: true` is set —
that is the Phoenix default, so no extra configuration is needed.
Available options (`mix help flick.install` for the full list):
- `--module NAME` — module name suffix, default `MySocket`
- `--path PATH` — WebSocket URL path, default `/ws`
- `--layout PATH` — non-default root layout file
- `--skip-layout` — skip the `<script>` tag patch
- `--channels` — also vendor `flick-channel.min.js.gz`
- `--no-boilerplate` — vendor JS and patch layout only (skip the server/JS boilerplate)
- `--yes` — skip the confirmation prompt
### 3. Fill in your business logic
After `mix flick.install` runs, two files need your application-specific code:
**`lib/<app>_web/my_socket.ex`** — generated skeleton, edit `handle_info` to push
real data:
```elixir
@impl WebSock
def handle_info(:send_snapshot, state) do
payload = :erlang.term_to_binary(%{type: :snapshot, data: []})
{:push, {:binary, payload}, state}
end
```
See [Guidelines: server-side encoding](#1-server-side---encode-with-erlangtermtobinary1)
for what types are safe to encode.
**`assets/js/app.js`** — the appended starter hook opens the socket and logs
messages. Replace the `console.log` with your actual dispatch logic:
```javascript
_flickWs.onmessage = (event) => {
const msg = window.Flick.decode(event.data)
const type = msg.type && msg.type.value ? msg.type.value : String(msg.type)
switch (type) {
case "snapshot": /* handle msg.data */ break
default: console.warn("Unknown message type:", type)
}
}
```
See [Guidelines: client-side decoding](#2-atoms-decode-to-erlatom-objects-not-strings)
for atom handling and the `nil`/`null` mapping.
### 4. Restart and test
```bash
mix assets.deploy # or just restart the dev server
```
Open the page and check the browser console — you should see the WebSocket
connecting and decoded messages appearing as plain JS objects, with atom fields
as `ErlAtom{value: "..."}`.
---
## Guidelines
These apply whether you used `mix flick.install` or wired things up manually.
### 1. Server side - encode with `:erlang.term_to_binary/1`
Any map, list, atom, number, or binary can be encoded directly:
```elixir
payload = :erlang.term_to_binary(%{
type: :tick,
time: candle.time,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume: candle.volume
})
{:push, {:binary, payload}, state}
```
### 2. Client side - atoms decode to `ErlAtom` objects, not strings
`flick.js` decodes Erlang atoms (e.g. `:snapshot`, `:tick`) as `ErlAtom{value:
"snapshot"}` objects, **not** plain JS strings. Always compare via `.value`:
```javascript
const msg = window.Flick.decode(event.data)
const type = msg.type && msg.type.value ? msg.type.value : String(msg.type)
if (type === "tick") { /* ... */ }
```
### 3. Client side - Elixir `nil` decodes to JS `null`
`flick.js`'s `decode_atom` maps the Erlang/Elixir atom `nil` directly to JS
`null` (alongside its existing `true`/`false`/`undefined`/`null` atom
mappings), and `encode_object` maps JS `null` back to the atom `nil` when
encoding. This means `nil` values round-trip without any workaround:
```elixir
payload = :erlang.term_to_binary(%{
type: :snapshot,
candles: history,
forming: forming # nil or a candle map — no workaround needed
})
```
```javascript
const f = msg.forming
if (f && typeof f.time === 'number') {
// real forming candle; f is null when there is none
}
```
### 4. Client setup: binary frames + global decoder
`mix flick.install` adds the `<script>` tag and appends the starter hook
automatically. If you are wiring things up manually:
- Add `<script src={~p"/assets/js/flick.min.js"}></script>` before `app.js`
in the root layout so `window.Flick` is available globally.
- Set `ws.binaryType = "arraybuffer"` before the socket connects.
- Decode each frame with `window.Flick.decode(event.data)`.
```javascript
const ws = new WebSocket(url)
ws.binaryType = "arraybuffer"
ws.onmessage = (event) => {
const msg = window.Flick.decode(event.data)
// ... dispatch on msg.type.value
}
```
---
## Manual Wiring (alternative to `mix flick.install`)
If you prefer not to use the installer, here are the equivalent manual steps.
### A. Vendor the JS files
Copy from the `:flick` dependency's `priv/` directory:
```bash
cp deps/flick/priv/flick.min.js.gz assets/vendor/
cp deps/flick/priv/flick.min.js.gz priv/static/assets/js/
```
Add to the root layout, before `app.js`:
```heex
<script src={~p"/assets/js/flick.min.js"}></script>
```
### B. Define the `WebSock` module
```elixir
defmodule MyAppWeb.MySocket do
@behaviour WebSock
@impl WebSock
def init(args), do: {:ok, %{args: args}}
@impl WebSock
def handle_in(_frame, state), do: {:ok, state}
@impl WebSock
def handle_info(:send_snapshot, state) do
payload = :erlang.term_to_binary(%{type: :snapshot, data: []})
{:push, {:binary, payload}, state}
end
def handle_info(_msg, state), do: {:ok, state}
@impl WebSock
def terminate(_reason, _state), do: :ok
end
```
### C. Add a controller
```elixir
defmodule MyAppWeb.MySocketController do
use MyAppWeb, :controller
def connect(conn, params) do
WebSockAdapter.upgrade(conn, MyAppWeb.MySocket, params, [])
end
end
```
### D. Add the router route
```elixir
get "/ws", MySocketController, :connect
```
### E. Add a JS hook
```javascript
const proto = location.protocol === "https:" ? "wss" : "ws"
const ws = new WebSocket(`${proto}://${location.host}/ws`)
ws.binaryType = "arraybuffer"
ws.onmessage = (event) => {
const msg = window.Flick.decode(event.data)
const type = msg.type && msg.type.value ? msg.type.value : String(msg.type)
switch (type) {
case "snapshot": /* handle msg.data */ break
default: console.warn("Unknown message type:", type)
}
}
```
---
## Phoenix Channels over ETF
If you'd rather use Phoenix Channels (join/leave, topics, PubSub broadcasts)
instead of a raw `WebSock` socket, `Flick.Socket.Serializer` and
[flick-channel.js](priv/flick-channel.js) replace
Phoenix's default JSON channel serializer with one that encodes the whole
Channels envelope (`join_ref`, `ref`, `topic`, `event`, `payload`) as a
single ETF binary frame.
`mix flick.install --channels` vendors `flick-channel.min.js.gz` in addition
to `flick.min.js.gz`. The server and client must then be configured manually
as shown below — the installer does not generate Channels boilerplate.
### Server: configure the socket's serializer
```elixir
defmodule MyAppWeb.UserSocket do
use Phoenix.Socket
channel "room:*", MyAppWeb.RoomChannel
@impl true
def connect(_params, socket, _connect_info), do: {:ok, socket}
@impl true
def id(_socket), do: nil
end
```
```elixir
# in the endpoint
socket "/socket", MyAppWeb.UserSocket,
websocket: [serializer: [{Flick.Socket.Serializer, "~> 2.0.0"}]]
```
### Client: pass `encode`/`decode` to `Socket`
`flick-channel.js` is vendored by `mix flick.install --channels`
(see `priv/flick-channel.js`) and exposes
`window.FlickChannelSerializer`, built on top of `window.Flick`:
```javascript
import {Socket} from "phoenix"
const socket = new Socket("/socket", {
encode: FlickChannelSerializer.encode,
decode: FlickChannelSerializer.decode
})
socket.connect()
const channel = socket.channel("room:lobby", {})
channel.join()
.receive("ok", () => {
channel.push("echo", {message: "hi", values: [1, 2, 3]})
.receive("ok", (reply) => console.log(reply))
})
```
No special `vsn` is needed — `Phoenix.Socket`'s default client `vsn`
(`"2.0.0"`) already satisfies the `"~> 2.0.0"` serializer requirement above.
### Caveats
- `join_ref`, `ref`, `topic`, and `event` are always normalized to/from
plain strings on both ends.
- The `payload`, however, is encoded/decoded as-is by `flick.js`/`:erlang`,
so the same caveats as the raw WebSocket integration apply: JS strings
inside a payload decode to Erlang **charlists** (and conversely, Erlang
binaries decode to `ErlBinary` on the client — see "Guidelines" above).
`flick-channel.js` wraps outgoing payloads with `Flick.map(...)` so they
always encode as Erlang maps, even with a single key.
## Related
- [flick.js](priv/flick.js) — the client-side ETF encoder/decoder source,
vendored from [erlb.js](https://github.com/saleyn/erlb.js).
Pre-built as [flick.min.js.gz](priv/flick.min.js.gz) (run `make minify` to
regenerate after editing the source).
- [flick-channel.js](priv/flick-channel.js) — Phoenix
Channels ETF serializer for the JS client, pairing with
`Flick.Socket.Serializer`.
Pre-built as [flick-channel.min.js.gz](priv/flick-channel.min.js.gz).
## JS Unit Tests
`test/js/flick-test.js` contains QUnit test cases for `flick.js` covering
encoding, decoding, and stringification. Serve the `test/js` directory and
open `flick-test.html` in a browser:
```bash
cd test/js
python3 -m http.server 8000
```
Then open `http://localhost:8000/flick-test.html`.
## License
[MIT License](LICENSE)