Flick

buildHex.pmHex.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.

Installation

Add flick to your dependencies in mix.exs:

def deps do
[
{:flick, "~> 0.1"}
]
end

Then run:

mix deps.get

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

Why ETF instead of JSON

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

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:

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

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