{"id":15725820,"url":"https://github.com/ndrean/phoenix-websockets","last_synced_at":"2025-05-13T04:57:06.524Z","repository":{"id":250619213,"uuid":"834966098","full_name":"ndrean/phoenix-websockets","owner":"ndrean","description":"Sending binary data through Websockets and Channels with Phoenix LiveView","archived":false,"fork":false,"pushed_at":"2024-07-29T13:26:22.000Z","size":139,"stargazers_count":6,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-05-13T04:57:00.808Z","etag":null,"topics":["channels","elixir","lightweight-charts","phoenix-liveview","websocket","websocket-client"],"latest_commit_sha":null,"homepage":"","language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ndrean.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2024-07-28T21:03:14.000Z","updated_at":"2025-03-10T23:41:55.000Z","dependencies_parsed_at":"2024-07-28T22:41:19.756Z","dependency_job_id":"c8298f30-1888-437d-8cb4-caf2e59a6954","html_url":"https://github.com/ndrean/phoenix-websockets","commit_stats":null,"previous_names":["ndrean/phoenix-websockets"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ndrean%2Fphoenix-websockets","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ndrean%2Fphoenix-websockets/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ndrean%2Fphoenix-websockets/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ndrean%2Fphoenix-websockets/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ndrean","download_url":"https://codeload.github.com/ndrean/phoenix-websockets/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":253877508,"owners_count":21977643,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["channels","elixir","lightweight-charts","phoenix-liveview","websocket","websocket-client"],"created_at":"2024-10-03T22:24:26.276Z","updated_at":"2025-05-13T04:57:06.505Z","avatar_url":"https://github.com/ndrean.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"# WebSockets with Elixir Phoenix LiveView\n\n## Overview of WebSocket Options\n\nWe want to pass data via a WebSocket connection between the `Elixir/Phoenix` server and the browser.\n\nWe consider the following options:\n\n1. LiveSocket\n   1. Uses LiveView under the hood for real-time updates\n   2. Client-side implementation uses \"hooks\" with pushEvent and handleEvent from Phoenix.js.\n2. Elixir Channel\n   1. Built on top of Phoenix.Socket, providing higher-level abstraction.\n   2. Features reconnection and fallback to long-polling.\n   3. Client-side: Instantiate new Socket(\"/socket\"), use channel.push and channel.on.\n   4. Allows fine-grained authorization per channel.\n3. Custom WebSocket\n   1. Utilizes the WebSocket API directly.\n   2. Implement the Phoenix.Socket.Transport behaviour server-side in a module declared in \"endpoint.ex\".\n4. Elixir WebSocket client\n   1. Use libraries like Fresh to connect to WebSocket endpoints directly from Elixir applications.\n   2. Suitable for client-side connections within Elixir, distinct from Phoenix's server-side WebSocket handling.\n\n## Choosing the Right Approach\n\nWe are mostly interested by sending images, possibly large, fron the server to the browser, or from the browser to the server.\n\n## LiveSocket implementation with base64 encoding\n\n### Sending Images from Server to Browser\n\nWe 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:\n\n```elixir\nimage_base64 =\n  File.read!(file)\n  |\u003e Base.encode64(image_binary)\n```\n\nthen update an assign:\n\n`assign(socket, :image_base64, image_base64)`,\n\nand then it will render when the assigns are udpated:\n\n```elixir\ndef render(assigns) do\n  ~H\"\"\"\n  [...]\n  \u003cimg src={\"data:image/jpeg;base64,#{@image_base64}\"} /\u003e\n  \"\"\"\nend\n```\n\nThis is done along the `LiveSocket`. It sends the data as base 64 encoded text to the browser.\n\n### Sending Images from Browser to Server\n\nIf 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:\n\n```js\nconst sendBase64ViaLiveSocket = (blob) =\u003e {\n  const reader = new FileReader();\n  reader.readAsDataURL(blob);\n  reader.onload = () =\u003e {\n    this.pushEvent(\"send_as_base64\", {\n      data_as_b64: reader.result,\n    });\n  };\n};\n```\n\nHowever, 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.\n\n### Channel implementation\n\nChannels are processes built on top of the WebSocket.\n\n#### Setting Up Channels\n\nWe instantiate our \"userSocket\"\n\n\u003cdetails\u003e\n\u003csummary\u003eUserSocket.js\n\u003c/summary\u003e\n\n```js\nimport { Socket } from \"phoenix\";\n\nconst userSocket = new Socket(\"/userSocket\", {\n  params: { userToken: window.userToken },\n});\nuserSocket.connect();\n\nexport default userSocket;\n```\n\n\u003c/details\u003e\n\u003cbr/\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003eA generic implementation of a Channel client-side\n\u003c/summary\u003e\n\n```js\nexport default function useChannel(socket, topic) {\n  return new Promise((resolve, reject) =\u003e {\n    if (!socket) {\n      reject(new Error(\"Socket not found\"));\n      return;\n    }\n\n    const channel = socket.channel(topic, { token: window.userToken });\n    channel\n      .join()\n      .receive(\"ok\", () =\u003e {\n        console.log(`Joined successfully Channel : ${topic}`);\n        resolve(channel);\n      })\n      .receive(\"error\", (resp) =\u003e {\n        console.log(`Unable to join ${topic}`, resp.reason);\n        reject(new Error(resp.reason));\n      });\n  });\n}\n```\n\n\u003c/details\u003e\n\u003cbr/\u003e\n\nWe instantiate the \"userSocket\" and a Channel with a given \"topic\"\n\n```js\nimport userSocket from \"./userSocket\";\nimport useChannel from \"./useChannel\";\nconst channel = useChannel(userSocket, \"topic\");\n```\n\nThis \"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.\n\n#### Streaming Large Files from Server to Browser\n\nWe 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:\n\n```elixir\ndef handle_in(\"request-image\", _, socket) do\n  File.stream!(\"channel.jpg\", 1024 * 10)\n  |\u003e Stream.with_index()\n  |\u003e Enum.each(fn {chunk, index} -\u003e\n    IO.puts(\"CH: sending chunk #{index}\")\n    push(socket, \"new chunk\", {:binary, \u003c\u003cindex::32, chunk::binary\u003e\u003e})\n  end)\n\n  push(socket, \"image complete\", %{})\n\n  {:noreply, socket}\nend\n```\n\nThe client-side code is:\n\n```js\nimageChannel.on(\"new chunk\", (payload) =\u003e {\n  if (payload instanceof ArrayBuffer) {\n    let view = new DataView(payload);\n    let index = view.getInt32(0);\n    let chunk = payload.slice(4);\n    console.log(\"Channel: received chunk \", index);\n    imageChunks[index] = chunk;\n    totalChunks++;\n  }\n});\n\nimageChannel.on(\"image complete\", () =\u003e {\n  imageChunks = imageChunks.filter((chunk) =\u003e chunk !== undefined);\n  let blob = new Blob(imageChunks, { type: \"image/jpeg\" });\n  imageURL = URL.createObjectURL(blob);\n  document.getElementById(\"from-server-via-channel\").src = imageURL;\n});\n```\n\n\u003cdetails\u003e\n\u003csummary\u003e\nIf we don't send chunks but directly the whole file, then we would simply have:\n\u003c/summary\u003e\n\n```elixir\ndef handle_in(\"request-image\", _, socket) do\n  data = File.read!(\"channel.jpg\")\n  {:reply, {:ok, {:binary, data}}, state}\nend\n```\n\nand the client code could be:\n\n```js\nfunction displayReceivedMsg(payload, topic, picId) {\n  const { response, status } = payload;\n  if (response instanceof ArrayBuffer) {\n    console.log(\"Received a pic via Channel\", status);\n    let blob = new Blob([response], { type: \"image/jpeg\" });\n    let imageUrl = URL.createObjectURL(blob);\n    document.getElementById(picId).src = imageUrl;\n  } else {\n    console.log(\"Channel received a message :\", topic);\n  }\n}\n```\n\n\u003c/details\u003e\n\n#### Streaming Large Files from Browser to Server\n\nYou have the Phoenix LiveView Uplaods.\n\n\u003cdetails\u003e\n\u003csummary\u003e An example if you have an endpoint that serves large files and you want to download and push through a Channel\u003c/summary\u003e\n\n```js\nconst sendLargeFileViaChannel = async (channel) =\u003e {\n  let url =\n    \"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/WhatCarCanYouGetForAGrand.mp4\";\n  const response = await fetch(url);\n  const reader = response.body.getReader();\n  const contentLength = +response.headers.get(\"Content-Length\");\n  console.log(contentLength);\n\n  let receivedLength = 0;\n\n  while (true) {\n    const { done, value } = await reader.read();\n\n    if (done) {\n      console.log(\"Transfer completed\");\n      break;\n    }\n\n    receivedLength += value.length;\n\n    channel.push(\"chunk\", value.buffer);\n\n    console.log(`Received ${receivedLength} of ${contentLength} bytes`);\n  }\n};\n```\n\n\u003c/details\u003e\n\u003cbr/\u003e\n\nand the server code is:\n\n```elixir\ndef handle_in(\"chunk\", {:binary, data}, socket) when is_binary(data) do\n  File.write(\"large.mp4\", data, [:append])\n  {:noreply, socket}\nend\n```\n\n\u003c/details\u003e\n\u003cbr/\u003e\n\n### Raw WebSocket Implementation\n\nWe use the WebSocket API to the `Elixir` server. We send data to the server.\n\n### Client-Side Setup\n\n\u003cdetails\u003e\n\u003csummary\u003eClient implementation of raw WebSocket\n\u003c/summary\u003e\n\n```js\nlet protocole = window.location.protocol.includes(\"https\") ? \"wss\" : \"ws\";\nlet url = \"https://picsum.photos/300/300.jpg\",\nlet ws = new WebSocket(\n  `${protocole}://${window.location.host}/rawsocket?token=${window.userToken}`\n);\n\nws.onopen = async () =\u003e {\n  const response = await fetch(url);\n  const blob = await response.blob();\n  const arrayBuffer = await blob.arrayBuffer();\n  ws.send(arrayBuffer);\n};\n```\n\n\u003c/details\u003e\n\u003cbr/\u003e\n\n### Server-Side Setup\n\nServer-side, we have:\n\n```elixir\n# endpoint.ex\n\n socket \"/rawsocket\", WsHandler,\n    websocket: [\n      path: \"\",\n      check_origin: Application.compile_env(:ws, :websocket_origins)\n    ]\n```\n\nThe 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).\n\n```elixir\ndefmodule WsHandler do\n  @behaviour Phoenix.Socket.Transport\n\n  # \u003chttps://hexdocs.pm/phoenix/Phoenix.Socket.Transport.html#module-example\u003e\n\n  def child_spec(_opts); do: :ignore\n\n  def connect(%{params: %{\"user_token\" =\u003e user_token, \"_csrf_token\" =\u003e csrf_token}} = info) do\n    case Phoenix.Token.verify(WsWeb.Endpoint, \"user token\", user_token, max_age: 86_400) do\n      {:ok, user_id} -\u003e\n        [{\"user_id\", encrypted_csrf}] = :ets.lookup(:my_token, \"user_id\")\n\n        case Phoenix.Token.verify(WsWeb.Endpoint, \"csrf token\", encrypted_csrf) do\n          {:ok, ^csrf_token} -\u003e {:ok, Map.put(info, user_id, user_id)}\n          {:error, _} -\u003e :error\n        end\n\n      {:error, _reason} -\u003e\n        :error\n    end\n  end\n\n  def connect(_info), do: :error\n\n  def init(state); do: {:ok, state}\n\n  def handle_in({img, [opcode: :binary]}, state) do\n    # for example, lets save the data inot a file\n    File.write(\"data.jpg\", img)\n    {:ok, state}\n  end\nend\n```\n\n### Elixir WebSocket Client\n\n#### Connecting to External WebSocket Stream\n\nWe want connect to a realtime WebSocket stream ([coincap.io](https://docs.coincap.io/#37dcec0b-1f7b-4d98-b152-0217a6798058)) and use a realtime charting library to display the data.\n\nIn our LiveView `mount/3`, we supervise the module that instantiates the connection:\n\n```elixir\nsymbol = \"bitcoin\"\nif connected?(socket) do\n  DynamicSupervisor.start_child(DynSup, {\n    Ws.ClientWebsocketHandler,\n    uri: \"wss://ws.coincap.io/prices?assets=\" \u003c\u003e symbol, state: %{symbol: symbol}\n  })\n  MyApp.Endpoint.subscribe(\"price\")\n```\n\nThe connection module uses the WebSocket client [Fresh](https://hexdocs.pm/fresh/readme.html).\nOnce we receive data, we \"pubsub\" it. The LiveView subscribed to this topic.\n\n```elixir\ndefmodule MyApp.ClientWebsocketHandler do\n  use Fresh\n\n  def handle_connect(101, _headers, socket) do\n    {:reply, [], socket}\n  end\n\n  def handle_in({:text, payload}, state) do\n    %{symbol: symbol} = state\n\n    value =\n      Jason.decode!(payload)\n      |\u003e Map.get(symbol)\n\n    :ok = MyAppWeb.Endpoint.broadcast(\"price\", \"new\", %{value: value})\n    {:ok, state}\n  end\nend\n```\n\nTo send data to the client module, we push it via the LiveSocket (and the LiveView subscrbed to the PubSub topic):\n\n```elixir\ndef handle_info(%{topic: \"price\", event: \"new\", payload: %{value: value}}, socket) do\n  {:noreply, push_event(socket, \"new\", %{value: value})}\nend\n```\n\n#### Real-time Data Visualization\n\nTo render a chart, we used \"lightweight-charts\" from [TradingView](https://github.com/tradingview/lightweight-charts).\n\nWe copied the [library code](https://unpkg.com/lightweight-charts/dist/lightweight-charts.standalone.production.js) into the **\"vendor\" folder**.\n\nThe library exposes the object `LightWeightCharts` directly on the `window`.\n\nWe use it in a \"hook\" and use `handleEvent` to receive the data and inject it into the chart.\n\n\u003cdetails\u003e\n\u003csummary\u003eClient code\u003c/summary\u003e\n\n```js\nimport \"../vendor/lightweightCharts\";\n\nexport const chartHook = {\n  mounted() {\n    const chart = window.LightweightCharts.createChart(this.el, {\n      width: window.innerWidth * 0.6,\n      height: window.innerHeight * 0.4,\n      rightPriceScale: {\n        visible: true,\n      },\n      leftPriceScale: {\n        visible: true,\n      },\n    });\n    const btc = chart.addLineSeries({ priceScaleId: \"right\" });\n\n    chart.timeScale().fitContent();\n\n    this.handleEvent(\"new\", ({ value }) =\u003e {\n      const newPriceEvt = {\n        time: new Date().getTime() / 1000,\n        value: Number(value),\n      };\n      btc.update(newPriceEvt);\n    });\n\n    window.addEventListener(\"resize\", () =\u003e {\n      chart.resize(window.innerWidth * 0.6, window.innerHeight * 0.4);\n    });\n  },\n};\n```\n\n\u003c/details\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fndrean%2Fphoenix-websockets","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fndrean%2Fphoenix-websockets","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fndrean%2Fphoenix-websockets/lists"}