We want to pass data via a WebSocket connection between the Elixir/Phoenix
server and the browser.
We consider the following options:
- LiveSocket
- Uses LiveView under the hood for real-time updates
- Client-side implementation uses "hooks" with pushEvent and handleEvent from Phoenix.js.
- Elixir Channel
- Built on top of Phoenix.Socket, providing higher-level abstraction.
- Features reconnection and fallback to long-polling.
- Client-side: Instantiate new Socket("/socket"), use channel.push and channel.on.
- Allows fine-grained authorization per channel.
- Custom WebSocket
- Utilizes the WebSocket API directly.
- Implement the Phoenix.Socket.Transport behaviour server-side in a module declared in "endpoint.ex".
- Elixir WebSocket client
- Use libraries like Fresh to connect to WebSocket endpoints directly from Elixir applications.
- Suitable for client-side connections within Elixir, distinct from Phoenix's server-side WebSocket handling.
We are mostly interested by sending images, possibly large, fron the server to the browser, or from the browser to the server.
We can send an image as base64 encoded string via the LiveSocket. We load an image from the file-system in the server and display it. In a LiveView
, we can do:
image_base64 =
File.read!(file)
|> Base.encode64(image_binary)
then update an assign:
assign(socket, :image_base64, image_base64)
,
and then it will render when the assigns are udpated:
def render(assigns) do
~H"""
[...]
<img src={"data:image/jpeg;base64,#{@image_base64}"} />
"""
end
This is done along the LiveSocket
. It sends the data as base 64 encoded text to the browser.
If we want to send from the browser, say from a hook, we transform again the data into a base64 encoded string and send it via the LiveSocket:
const sendBase64ViaLiveSocket = (blob) => {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onload = () => {
this.pushEvent("send_as_base64", {
data_as_b64: reader.result,
});
};
};
However, we don't want to use base64 encoded strings as this increases the size of the data by 30%. This is inconvienient if the file is big or have many images.
Channels are processes built on top of the WebSocket.
We instantiate our "userSocket"
UserSocket.js
import { Socket } from "phoenix";
const userSocket = new Socket("/userSocket", {
params: { userToken: window.userToken },
});
userSocket.connect();
export default userSocket;
A generic implementation of a Channel client-side
export default function useChannel(socket, topic) {
return new Promise((resolve, reject) => {
if (!socket) {
reject(new Error("Socket not found"));
return;
}
const channel = socket.channel(topic, { token: window.userToken });
channel
.join()
.receive("ok", () => {
console.log(`Joined successfully Channel : ${topic}`);
resolve(channel);
})
.receive("error", (resp) => {
console.log(`Unable to join ${topic}`, resp.reason);
reject(new Error(resp.reason));
});
});
}
We instantiate the "userSocket" and a Channel with a given "topic"
import userSocket from "./userSocket";
import useChannel from "./useChannel";
const channel = useChannel(userSocket, "topic");
This "userSocket" is declared in the Elixir "endpoint.ex" module, and the server-side Elixir module to handle the Channel is declared in the "user_socket.ex" module.
We use the possibility of the handle_in
callback to respond with binary data. The browser sends a demand for the server to upload a file from the ifie system and the response is:
def handle_in("request-image", _, socket) do
File.stream!("channel.jpg", 1024 * 10)
|> Stream.with_index()
|> Enum.each(fn {chunk, index} ->
IO.puts("CH: sending chunk #{index}")
push(socket, "new chunk", {:binary, <<index::32, chunk::binary>>})
end)
push(socket, "image complete", %{})
{:noreply, socket}
end
The client-side code is:
imageChannel.on("new chunk", (payload) => {
if (payload instanceof ArrayBuffer) {
let view = new DataView(payload);
let index = view.getInt32(0);
let chunk = payload.slice(4);
console.log("Channel: received chunk ", index);
imageChunks[index] = chunk;
totalChunks++;
}
});
imageChannel.on("image complete", () => {
imageChunks = imageChunks.filter((chunk) => chunk !== undefined);
let blob = new Blob(imageChunks, { type: "image/jpeg" });
imageURL = URL.createObjectURL(blob);
document.getElementById("from-server-via-channel").src = imageURL;
});
If we don't send chunks but directly the whole file, then we would simply have:
def handle_in("request-image", _, socket) do
data = File.read!("channel.jpg")
{:reply, {:ok, {:binary, data}}, state}
end
and the client code could be:
function displayReceivedMsg(payload, topic, picId) {
const { response, status } = payload;
if (response instanceof ArrayBuffer) {
console.log("Received a pic via Channel", status);
let blob = new Blob([response], { type: "image/jpeg" });
let imageUrl = URL.createObjectURL(blob);
document.getElementById(picId).src = imageUrl;
} else {
console.log("Channel received a message :", topic);
}
}
You have the Phoenix LiveView Uplaods.
An example if you have an endpoint that serves large files and you want to download and push through a Channel
const sendLargeFileViaChannel = async (channel) => {
let url =
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/WhatCarCanYouGetForAGrand.mp4";
const response = await fetch(url);
const reader = response.body.getReader();
const contentLength = +response.headers.get("Content-Length");
console.log(contentLength);
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log("Transfer completed");
break;
}
receivedLength += value.length;
channel.push("chunk", value.buffer);
console.log(`Received ${receivedLength} of ${contentLength} bytes`);
}
};
and the server code is:
def handle_in("chunk", {:binary, data}, socket) when is_binary(data) do
File.write("large.mp4", data, [:append])
{:noreply, socket}
end
We use the WebSocket API to the Elixir
server. We send data to the server.
Client implementation of raw WebSocket
let protocole = window.location.protocol.includes("https") ? "wss" : "ws";
let url = "https://picsum.photos/300/300.jpg",
let ws = new WebSocket(
`${protocole}://${window.location.host}/rawsocket?token=${window.userToken}`
);
ws.onopen = async () => {
const response = await fetch(url);
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
ws.send(arrayBuffer);
};
Server-side, we have:
# endpoint.ex
socket "/rawsocket", WsHandler,
websocket: [
path: "",
check_origin: Application.compile_env(:ws, :websocket_origins)
]
The server module "WsHandler" receives binary data. We check the "user_token" and the csrf_token as set via a meta tag against their encrypted version (the csrf token is collected with Phoenix.LiveView.get_connect_params
in the LiveView mount/3 once the socket is connected, and is saved encrypted into an ETS table).
defmodule WsHandler do
@behaviour Phoenix.Socket.Transport
# <https://hexdocs.pm/phoenix/Phoenix.Socket.Transport.html#module-example>
def child_spec(_opts); do: :ignore
def connect(%{params: %{"user_token" => user_token, "_csrf_token" => csrf_token}} = info) do
case Phoenix.Token.verify(WsWeb.Endpoint, "user token", user_token, max_age: 86_400) do
{:ok, user_id} ->
[{"user_id", encrypted_csrf}] = :ets.lookup(:my_token, "user_id")
case Phoenix.Token.verify(WsWeb.Endpoint, "csrf token", encrypted_csrf) do
{:ok, ^csrf_token} -> {:ok, Map.put(info, user_id, user_id)}
{:error, _} -> :error
end
{:error, _reason} ->
:error
end
end
def connect(_info), do: :error
def init(state); do: {:ok, state}
def handle_in({img, [opcode: :binary]}, state) do
# for example, lets save the data inot a file
File.write("data.jpg", img)
{:ok, state}
end
end
We want connect to a realtime WebSocket stream (coincap.io) and use a realtime charting library to display the data.
In our LiveView mount/3
, we supervise the module that instantiates the connection:
symbol = "bitcoin"
if connected?(socket) do
DynamicSupervisor.start_child(DynSup, {
Ws.ClientWebsocketHandler,
uri: "wss://ws.coincap.io/prices?assets=" <> symbol, state: %{symbol: symbol}
})
MyApp.Endpoint.subscribe("price")
The connection module uses the WebSocket client Fresh. Once we receive data, we "pubsub" it. The LiveView subscribed to this topic.
defmodule MyApp.ClientWebsocketHandler do
use Fresh
def handle_connect(101, _headers, socket) do
{:reply, [], socket}
end
def handle_in({:text, payload}, state) do
%{symbol: symbol} = state
value =
Jason.decode!(payload)
|> Map.get(symbol)
:ok = MyAppWeb.Endpoint.broadcast("price", "new", %{value: value})
{:ok, state}
end
end
To send data to the client module, we push it via the LiveSocket (and the LiveView subscrbed to the PubSub topic):
def handle_info(%{topic: "price", event: "new", payload: %{value: value}}, socket) do
{:noreply, push_event(socket, "new", %{value: value})}
end
To render a chart, we used "lightweight-charts" from TradingView.
We copied the library code into the "vendor" folder.
The library exposes the object LightWeightCharts
directly on the window
.
We use it in a "hook" and use handleEvent
to receive the data and inject it into the chart.
Client code
import "../vendor/lightweightCharts";
export const chartHook = {
mounted() {
const chart = window.LightweightCharts.createChart(this.el, {
width: window.innerWidth * 0.6,
height: window.innerHeight * 0.4,
rightPriceScale: {
visible: true,
},
leftPriceScale: {
visible: true,
},
});
const btc = chart.addLineSeries({ priceScaleId: "right" });
chart.timeScale().fitContent();
this.handleEvent("new", ({ value }) => {
const newPriceEvt = {
time: new Date().getTime() / 1000,
value: Number(value),
};
btc.update(newPriceEvt);
});
window.addEventListener("resize", () => {
chart.resize(window.innerWidth * 0.6, window.innerHeight * 0.4);
});
},
};