Flick

build Hex.pm Hex.pm

Binary Erlang External Term Format (ETF) WebSocket transport for Phoenix, decoded on the client with flick.js.

The project is an evolution of the JavaScript ETF codec implemementation 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:

mix flick.install --channels

or added to an existing installation without re-running the boilerplate generator:

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 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 supportFlick.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 clientflick.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 encodingflick.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 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:

{:websock_adapter, "~> 0.5"}

Then run:

mix deps.get

2. Run mix flick.install

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:

WhatHow
Vendor flick.min.js.gzwritten to assets/vendor/ and priv/static/assets/js/
Add <script> tag to root layoutinserted before app.js
WebSock module skeletoncreated at lib/<app>_web/my_socket.ex
Upgrade controllercreated at lib/<app>_web/my_socket_controller.ex
Router routeget "/ws", ... inserted into router.ex
Starter JS hookappended 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:

@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 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:

_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 for atom handling and the nil/null mapping.

4. Restart and test

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:

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:

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:

payload = :erlang.term_to_binary(%{
  type:    :snapshot,
  candles: history,
  forming: forming   # nil or a candle map — no workaround needed
})
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).
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:

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:

<script src={~p"/assets/js/flick.min.js"}></script>

B. Define the WebSock module

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

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

get "/ws", MySocketController, :connect

E. Add a JS hook

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 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

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
# 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:

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.

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:

cd test/js
python3 -m http.server 8000

Then open http://localhost:8000/flick-test.html.

License

MIT License