WebSockets

HTTP request-response works well for most operations, but sometimes you need real-time, bidirectional communication. Nova has built-in WebSocket support through the nova_websocket behaviour. We will use it to build a live comments handler for our blog.

Creating a WebSocket handler

A WebSocket handler implements three callbacks: init/1, websocket_handle/2, and websocket_info/2.

Create src/controllers/blog_ws_handler.erl:

-module(blog_ws_handler).
-behaviour(nova_websocket).

-export([
         init/1,
         websocket_handle/2,
         websocket_info/2
        ]).

init(State) ->
    {ok, State}.

websocket_handle({text, Msg}, State) ->
    {reply, {text, <<"Echo: ", Msg/binary>>}, State};
websocket_handle(_Frame, State) ->
    {ok, State}.

websocket_info(_Info, State) ->
    {ok, State}.

The callbacks:

  • init/1 — called when the WebSocket connection is established. Return {ok, State} to accept.
  • websocket_handle/2 — called when a message arrives from the client. Return {reply, Frame, State} to send a response, {ok, State} to do nothing, or {stop, State} to close.
  • websocket_info/2 — called when the handler process receives an Erlang message (not a WebSocket frame). Useful for receiving pub/sub notifications from other processes.

Adding the route

WebSocket routes use the module name as an atom (not a fun reference) and set protocol => ws:

{"/ws", blog_ws_handler, #{protocol => ws}}

Add it to your public routes:

#{prefix => "",
  security => false,
  routes => [
             {"/login", fun blog_main_controller:login/1, #{methods => [get]}},
             {"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}},
             {"/ws", blog_ws_handler, #{protocol => ws}}
            ]
}

Testing the WebSocket

Start the node with rebar3 nova serve and test from a browser console:

let ws = new WebSocket("ws://localhost:8080/ws");
ws.onmessage = (e) => console.log(e.data);
ws.onopen = () => ws.send("Hello Nova!");
// Should log: "Echo: Hello Nova!"

A live comments handler

Let's build something more practical — a handler that broadcasts new comments to all connected clients using nova_pubsub.

Create src/controllers/blog_comments_ws_handler.erl:

-module(blog_comments_ws_handler).
-behaviour(nova_websocket).

-export([
         init/1,
         websocket_handle/2,
         websocket_info/2
        ]).

init(State) ->
    nova_pubsub:join(comments),
    {ok, State}.

websocket_handle({text, Msg}, State) ->
    nova_pubsub:broadcast(comments, "new_comment", Msg),
    {ok, State};
websocket_handle(_Frame, State) ->
    {ok, State}.

websocket_info({nova_pubsub, comments, _Sender, "new_comment", Msg}, State) ->
    {reply, {text, Msg}, State};
websocket_info(_Info, State) ->
    {ok, State}.

In init/1 we join the comments channel. When a client sends a message, we broadcast it to all channel members. When a pub/sub message arrives via websocket_info/2, we forward it to the connected client. We will explore pub/sub in depth in the Pub/Sub chapter.

Custom handlers

Nova uses a handler registry that maps return tuple atoms to handler functions. The built-in handlers:

Return atomWhat it does
jsonEncodes data as JSON
okRenders an ErlyDTL template
statusReturns a status code
redirectRedirects to another URL
sendfileSends a file
viewRenders a specific view template

You can register custom handlers:

nova_handlers:register_handler(xml, fun my_xml_handler:handle/3).

Then return from controllers:

my_action(_Req) ->
    {xml, <<"<user><name>Alice</name></user>">>}.

The handler function receives (StatusCode, ExtraHeaders, ControllerPayload) and must return a Cowboy request.

Fallback controllers

If a controller returns an unrecognized value, Nova can delegate to a fallback controller:

-module(my_controller).
-fallback_controller(my_fallback).

index(_Req) ->
    something_unexpected.

The fallback module needs resolve/2:

-module(my_fallback).
-export([resolve/2]).

resolve(Req, InvalidReturn) ->
    logger:warning("Invalid return from controller: ~p", [InvalidReturn]),
    {status, 500, #{}, #{error => <<"internal server error">>}}.

With WebSockets in place, let's build a real-time comment feed using Pub/Sub.