0

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?

Philipp Doerner
  • 1,090
  • 7
  • 24

1 Answers1

2

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()
Philipp Doerner
  • 1,090
  • 7
  • 24
  • Could you explain why the `gcsafe` pragma and `cast(gcsafe)` are needed? – Chris Arndt Jun 29 '23 at 20:11
  • 1
    I will edit this into the reply, but basically request handlers in prologue need to be "garbage collection safe" aka you must not ever run into a scenario where you're trying to access a piece of memory that may have been garbage collected in the meantime. That is a risk you run into every time you start accessing global variables, in this case that is the `connections` seq. By storing your websockets in that global connections seq you expose yourself to that risk theoretically. In this case the variable won't be GC'd so that's not a problem, which you need to tell the compiler via pragmas. – Philipp Doerner Jun 29 '23 at 20:24