Search code examples
websocketserverclientnim-langprologue

How to set up a small websocket client-server example with nim/prologue?


I am using the prologue framework of the nim programming language for my webserver and want to play around with websockets.

There is a section about websockets in the prologue docs but that mostly tells me how to set up a handler for establishing a websocket:

import prologue
import prologue/websocket


proc hello*(ctx: Context) {.async.} =
  var ws = await newWebSocket(ctx)
  await ws.send("Welcome to simple echo server")
  while ws.readyState == Open:
    let packet = await ws.receiveStrPacket()
    await ws.send(packet)

  resp "<h1>Hello, Prologue!</h1>"

That doesn't quite tell me how it actually works, nor what the client needs to look like to connect to this. What do I need to do here?


Solution

  • The Client

    A viable client on the JS side is in fact not much more complicated than simply writing:
        const url = "ws://localhost:8080/ws"
        const ws = new WebSocket(url);
        ws.addEventListener("open", () => ws.send("Connection open!"));
        ws.addEventListener("message", event => console.log("Received: " event));
    

    This will write a message to the browsers console every time a message is received and initially send a message to the server when connection is establish.

    However, let's write a slightly more elaborate client for experimentation that will show you the exchange of messages between you and the server:

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Websocket Prototype</title>
    </head>
    <body>
      <h1> Hyper client !</h1>
      <input type="text">
      <button> Send Message </button>
      <h3> Conversation </h3>
      <ul></ul>
      <script>
        const list = document.querySelector("ul");
        function addMessage (sender, message){
          const element = document.createElement("li");
          element.innerHTML = `${sender}: ${message}`;
          list.appendChild(element);
        }
        
        const url = "ws://localhost:8080/ws"
        const ws = new WebSocket(url);
        ws.addEventListener("open", event => ws.send("Connection open!"));
        ws.addEventListener("message", event => addMessage("server", event.data));
        
        const input = document.querySelector("input");
        
        function sendMessage(){
          const clientMsg = input.value;
          ws.send(clientMsg);
          addMessage("user", clientMsg);
          input.value = null;
        }
        
        document.querySelector("button").addEventListener("click", sendMessage);
        document.querySelector('input').addEventListener('keypress', (e) => {
          if (e.key === 'Enter') {
            sendMessage(event);
          }
        });
      </script>
    </body>
    </html>
    

    The Server

    The Server needs to do 2 things:
    1. Handle creating + receiving websocket messages
    2. Serve the client

    1. Handle creating + receiving websocket messages

    This is how you can handle the messages (Prologue uses treeforms ws library under the hood):

    import std/options
    import prologue
    import prologue/websocket
    
    var connections = newSeq[WebSocket]()
    
    proc handleMessage(ctx: Context, message: string): Option[string] =
      echo "Received: ", message
      return some message
    
    proc initialWebsocketHandler*(ctx: Context) {.async, gcsafe.} =
      var ws = await newWebSocket(ctx)
      {.cast(gcsafe).}:
        connections.add(ws)
      await ws.send("Welcome to simple echo server")
      
      while ws.readyState == Open:
        let message = await ws.receiveStrPacket()
        let response = ctx.handleMessage(message)
        if response.isSome():
          await ws.send(response.get())
    
      await ws.send("Connection is closed")
      resp "<h1>Hello, Prologue!</h1>"
    

    Prologue keeps waiting inside the while loop as long as the websocket is open. The function handleMessage will get triggered every time a message is received.

    If you want to route a given message to specific procs that deal with different kinds of messages in different ways, you can implement it starting from handleMessage and based on the event itself decide to return or not return a response message.

    The {.gcsafe.} pragma on the handler informs the compiler that this proc is supposed to be garbage-collection-safe (no access to memory that may potentially be garbage collected while this proc is running). This will cause the compilation to error out because accessing global mutable variables like connections is never gc-safe as it theoretically could disappear. In this scenario that is not going to happen, as the global variable will live for the entire runtime of the program. So we must inform the compiler it's fine by using {.cast(gcsafe).}:.

    Note: This server does not implement a heartbeat-mechanic (the websocket package provides one), nor does it deal with closed connections! So currently your connections seq will only fill up.

    2. Serving the client

    As for serving the client, you can just read in the HTML file at compile time and serve that HTML string as response:

    proc client*(ctx: Context) {.async, gcsafe.} =
      const html = staticRead("./client.html")
      resp html
    

    The rest of the server

    Your actual server can then use these 2 handler-procs (aka controllers) as you would normally set up a prologue application Both can be done pretty quickly:
    #server.nim
    import prologue
    import ./controller # Where the 2 handler/controller procs are located
    
    proc main() =
      var app: Prologue = newApp()
      app.addRoute(
        route = "/ws",
        handler = initialWebsocketHandler,
        httpMethod = HttpGet
      )
      
      app.addRoute(
        route = "/client",
        handler = client,
        httpMethod = HttpGet
      )
      app.run()
    
    main()