Websockets
Using Bandit OTP and Plug
- Source Codegil-air-may/test-containers
- StackElixir / OTP / Bandit / Plug
Setting up a proper WebSockets server using Elixir isn't widely covered in tutorials. Most of the available information focuses on using the Phoenix framework with channels. While Phoenix is powerful, it may not be the right tool for a microservice approach. This small project will guide you through creating a barebones WebSockets setup using important concepts like Plug and OTP. As a bonus, instead of the typical Cowboy Erlang server, we'll use a more modern, pure Elixir server called Bandit.
Inside the backend folder, you'll find websocket_process.ex, which includes the Spike.WebsocketProcess module. This file is key to understanding the setup. Bandit creates processes to represent actual connections. The application then interacts with these processes using the callbacks shown below.
defmodule Spike.WebsocketProcess doalias Spike.ConnectionManageralias Spike.Messagedef init(conn) doid = get_id_from_conn(conn)IO.puts('New connection PID: #{inspect(self())}, ID: #{id}')ConnectionManager.add_connection(id, self()){:ok, conn}enddef handle_in({client_message, [opcode: :text]}, state) doMessage.handle_client_message(client_message){:ok, state}enddef handle_info({:text, server_message}, state) do{:push, {:text, server_message}, state}enddef handle_info({:close, _code, _reason}, state) doid = get_id_from_conn(state.conn)ConnectionManager.remove_connection(id){:ok, state}enddefp get_id_from_conn(conn) doString.to_integer(conn.params["id"])endend
Mapping Connections:
The
Server Messages:
To specify a target client, we simply send it to the process linked to that client's connection.
...pid_map = ConnectionManager.get_conn_map()pid_id_1 = pid_map[1]Kernel.send(pid_id_1, {:text, payload})...
This will trigger the handle_info/2 callback in Spike.WebsocketProcess, which relays the message to the client connection by returning a tuple with :push.
Client Messages:
Whenever a client sends a message, the application runs the handle_in/2 callback. You can insert any custom logic to handle updates from the client. In this example, the server expects a JSON message with the keys "target" and "payload". The function loops through the targets and the payload is sent to each connection associated with the target ID.
defmodule Spike.Message doalias Spike.ConnectionManagerdef handle_client_message(client_message) doclient_message = Jason.decode!(client_message)Enum.each(client_message["targets"], fn target ->pid = ConnectionManager.get_connection(target)payload = Jason.encode!(client_message["payload"])Kernel.send(pid, {:text, payload})end)endend
Final thoughts
This is a lightweight setup, and I've kept the example as simple as possible. However, it can serve as a solid foundation for more advanced WebSocket applications that require authentication and additional business logic. Happy coding!