Asynchronous Websockets -- a quick tutorial

Primary tabs

Tutorial, Frontend, Caché

Intro

 

Please note, this article is considered deprecated, check out the new revision over here: https://community.intersystems.com/post/tutorial-websockets

The goal of this post is to discuss working with Websockets in a Caché environment. We are going to have a quick discussion of what websockets are and then talk through an example chat application implemented on top of Websockets.

Requirements:

  • Caché 2016.1+
  • Ability to load/compile csp pages and classes

The scope of this document doesn't include an in-depth discussion of Websockets. To satisfy your thirst for details, please have a look at RFC6455[1]. Wikipedia also provides a nice high-level overview[2].

The quick'n'dirty version: Websockets provide a full-duplex channel over a TCP connection. This is mainly focused on, but not limited to, facilitating a persistent two-way communication channel between a web client and a webserver. This has a number of implications, both on the possiblities in application design as well as resource considerations on the server side.

The application

One of the standard examples, kind of the 'hello world' of Websockets is a chat application. You'll find numerous examples of similar implementations for many languages.

First we'll define a small set of goals for our implementetation. Some of these might sound basic, but keep in mind, any project lives and dies with a proper scope definition:

  • Users to send messages
  • Messages sent by one user should be broadcasted to all connected users
  • Provide different chat rooms
  • A user should get a list of currently connected users
  • A user should be able to set their own nickname

A production like chat application will have many more requirements, but the goal here is to demonstrate basic Websocket programming and not to replace IRC ;)

You'll find the code we are about to discuss in the repository (https://github.com/intersystems/websockets-tutorial). It contains:

  • ChatTest.csp -- The CSP page actually showing the chat
  • Chat.Server.cls -- Server side class managing the Websocket connection

To be able to manage the chatroom and the communication between the client and the server side, we are defining a couple of message formats we are going to use. Please note, that this is purely up to you. Websockets do not prescribe any format of the data being sent over it.

A chat message:

  {
         "Type":"Chat",
         "Message":"<msg>"
  }

A status update, informing the client of its websocketID

  {
         "Type":"Status",
         "WSID":"<websocketid>"
  }

The list of currently connected users (usually updated when a new user connects/disconnects):

  {
    "Type":"userlist",
    "Users": [ {"Name":"<username>"},
                            ....
                 ]
  }

The Client side

We are going to render the client side with a simple single html5 page. This is basic html with a little bit of css to make it look nice. For details, look at the implementation of ChatTest.csp itself. For simplicity we're pulling in jquery, which will allow us to make dynamic updates to the page a little easier.

ScreenShot

Down the road we are interested only in a couple of dom elements, identified by:

  • #chat -- the ul holding the chatmessages
  • #chatdiv -- the div holding #chat (used for automatic scrolling to the last message)
  • #userlist -- the ul holding the list of currently active users
  • #inputline -- the input field where the user is going to type messages

There are a couple of lines of javascript code in init() which are dealing with setting up events and handling input which we will not discuss. The first interesting bit is opening the WebSocket and binding function handlers to the relevant events:

In function init(): [..]

 ws = new WebSocket(((window.location.protocol == "https:") ? "wss:" : "ws:") + "//" + window.location.host + " #($system.CSP.GetDefaultApp($namespace))#/Chat.Server.cls"+"?room="+ROOM);

This opens a websocket connection to our server side class, either using ws or secured wss protocol, depending on the way we connected to our page.

  ws.onopen = function(event) {
     $("#headline").html("CHAT - connected");
  };

Once the WebSocket has been opened, we update the headline to let us know we are connected. Note that we're using the CSP expression "#($system.CSP.GetDefaultApp($namespace))#" to get the path of the current namespace. This will only work if you're using Caché to actually serve those pages. If you're only connecting to Caché as a backend and are planning on serving the page through a webserver directly, you'll have to hardcode the path to the websocket class (i.e. /csp/users/Chat.Server.cls)

  ws.onmessage = function(event) {
                  var d=JSON.parse(event.data);
                //var msg=d.Message;
                if (d.Type=="Chat") {
                        $("#chat").append(wrapmessage(d));
                        $("#chatdiv").animate({ scrollTop: $('#chatdiv').prop("scrollHeight")}, 1000);
                } else if(d.Type=="userlist") {
                        $("#userlist").html(wrapuser(data.Users));
                } else if(d.Type=="Status") {
                        document.getElementById("headline").innerHTML = "CHAT - connected - "+d.WSID;
                }
  };

This function handles an incoming message. We parse the JSON formatted data and then simply act based on the different types of messages as we've defined them earlier: * Chat: using the helper function wrapmessage, we add the new message to the #chat ul * userlist: using the helper function wrapuser, we generate and update the #userlist * Status: is a general status update, and we'll put the websocket ID into the headline

  ws.onerror = function(event) { alert("Received error"); };

This handles an error, we're simply displaying a message.

  ws.onclose = function(event) {
          ws = null;
          $("#headline").html("CHAT - disconnected");
  }

Closing the websocket leads to updating the headline again.

The one function left is

  function send() {
          var line=$("#inputline").val();
          if (line.substr(0,5)=="/nick"){
                  nickname=line.split(" ")[1];
                  if (nickname==""){
                          nickname="default";
                  }
          } else {
                  var msg=btoa(line);
                  var data={};
                  data.Message=msg;
                  data.Author=nickname;
             if (ws && msg!="") {
                       ws.send(JSON.stringify(data));
             }
          }
     $("#inputline").val("");
  }

Here we handle setting the nickname via "/nick newnickname" and sending a new chatmessage to the server. For that we b64 encode the textmessage and wrap it into an object, which we'll send json encoded to the server. We're doing the b64 encoding to avoid having to deal with special characters.

Server side.

The server side code is a simple class extending %CSP.WebSocket. We'll discuss the 5 functions in our implementation now.

Method OnPreServer() As %Status
{
  set ..SharedConnection=1
  set room=$GET(%request.Data("room",1),"default")
  set:room="" room="default"
  if (..WebSocketID'=""){
    set ^CacheTemp.Chat.WebSockets(..WebSocketID)=""
    set ^CacheTemp.Chat.Room(..WebSocketID)=room
  } else {
    set ^CacheTemp.Chat.Error($INCREMENT(^CacheTemp.Chat.Error),"no websocketid defined")=$HOROLOG 
  }

  Quit $$$OK
}

is the hook that is getting called for a new WebSocket connection. Here we set ..SharedConnection=1 to indicate that we want to be able to write to this socket from multiple processes. We are also recording the new socket ID and association with a chatroom into globals. For this example we're using ^CacheTemp.Chat.* in the hopes to not conflict with anything. Obviously these can be replace by other mechanisms.

Method Server() As %Status
{

        job ..StatusUpdate(..WebSocketID)
        for {                
        set data=..Read(.size,.sc,1) 
         if ($$$ISERR(sc)){
            if ($$$GETERRORCODE(sc)=$$$CSPWebSocketTimeout) {
                                  //$$$DEBUG("no data")
              }
              If ($$$GETERRORCODE(sc)=$$$CSPWebSocketClosed){
                      kill ^CacheTemp.Chat.WebSockets(..WebSocketID)
                      kill ^CacheTemp.Chat.Room(..WebSocketID)
                      do ..EndServer()        
                      Quit  // Client closed WebSocket
              }
         } else {
                 set mid=$I(^CacheTemp.Chat.Message)
                 set sc= ##class(%ZEN.Auxiliary.jsonProvider).%ConvertJSONToObject(data,"%Object",.msg)
                 set msg.Room=$G(^CacheTemp.Chat.Room(..WebSocketID))
                 set msg.WSID=..WebSocketID //meta data for the message
                 set ^CacheTemp.Chat.Message(mid)=msg.$toJSON()
                 job ..ProcessMessage(mid)
         }
        }

        Quit $$$OK
}

The Server() as %Status method is being called for a WebSocket afterwards. This holds our main loop for incoming messages from the client. An incoming message gets stored into a global after which we job off a updater (job ..ProcessMessage(mid)). This is all we're doing in our main loop.

/// clients for this room. 
ClassMethod ProcessMessage(mid As %String)
{
  set msg = ##class(%Object).$fromJSON($GET(^CacheTemp.Chat.Message(mid)))
  set msg.Type="Chat"

  set msg.Sent=$ZDATETIME($HOROLOG,3)
  set c=$ORDER(^CacheTemp.Chat.WebSockets(""))
  while (c'="") {
    set ws=..%New()
    set sc=ws.OpenServer(c)
    if $$$ISERR(sc){
      set ^CacheTemp.Chat.Error($INCREMENT(^CacheTemp.Chat.Error),"open failed for",c)=sc 
    }
    set sc=ws.Write(msg.$toJSON())
    set c=$ORDER(^CacheTemp.Chat.WebSockets(c))

  }
}

ProcessMessage is getting a message id passed in. It will parse the received json data into an %Object. We now $Order through our ^CacheTemp.Chat.WebSockets global to send the message to all connected chat clients for this room.

The BroadCast method demonstrates how to send a chatmessage to all connected clients. It's not being used on normal operations.

Wrapup

Exercise left for the user:

Implement the tracking of usernames on the server side and send a userlist message at the appropriate times.

Caveats, or why this isn't production code.

For a production ready system the inputs would need to be sanitized. We also hardly added any error trapping, access control, etc .

[1]https://tools.ietf.org/html/rfc6455 

[2]https://en.wikipedia.org/wiki/WebSocket

Feedback

Please feel free to provide feedback in the comments below. I'll also try and answer any questions!

  • + 12
  • 4
  • 7886
  • 28

Comments

Ok then, how to manage to get it worked with REST in Caché. For example. My application uses REST dispatch class for csp application. This class returns static files from disk in development, or from XData in production. And then I need to use WebSocket as well, but all requests catch by REST class. And in REST route map, i want to have path '/websocket', and send it to my WebSocket class.

You have to create two CSP applications, one for REST and another for regular calls, e.g.:

  1. /csp/samples/ for websocket and others
  2. /csp/samples/rest/v1/ for REST

HTH,

Stefan

Stefan,

Is this always strickly the case?  Any web application that wants to add new UI capabilities which rely on REST needs to create a new csp application in order to do so?  

Thanks!

You have to create a dedicated CSP application for REST calls using the %CSP.REST architecture as all calls that match the CSP application path will be forwarded to the REST dispatcher class.

Yeah, that was bad surprise for me recently. I could connect from client via websocket to any CSP class name (which is good thing). But I have to create separate web-application if I want to connect to %CSP.REST handler (which was quite unexpected). If I would not create separate application with their own dispatcher class then I'd receive strange 403 error (not authorized). 

This inconsistent treating REST and websocket makes no much sense for me, but unfortunately this is the way things are in %CSP.REST

I don't really think that that's surprising at all.  A REST handler class manages everything coming in for a certain endpoint like /some/url/

Note the trailing '/'. Anything added to that specifies a REST path, and will be handled according to your definitions in your urlmap. 

A Websocket endpoint is a single point of connection and not a starting point for a whole set of target urls. So it makes total sense to separate those. 

I know you can actually write a REST dispatcher which allows to render csp/zen pages through a REST dispatching class, but that is a very hack-ish solution. Unlike REST handling, websockets need to be treated special in the CSP gateway, so I'd be surprised if you could do a similar hack. Of course you can always mod_rewrite on the apache side to do whatever you want....

 

"/" itself is also a valid REST path:

<Route Url="/" Method="GET" Call="Index"/>

I'd be surprised if you could do a similar hack.

I wish you had not said that :)

Two issues:

First in 2016.2 (Cache for UNIX (Apple Mac OS X for x86-64) 2016.2 (Build 618U) Wed Mar 16 2016 19:23:28 EDT) two processes are created and both call the Server method. Looks like CSP is invoking %CSP.WebSocket:Page multiple times during the initial connection. The following seems to fix the issue:

Method Server() As %Status
{
    Quit:$D(^CacheTemp.Chat.WebSockets(..WebSocketID,"$J"))
    Set ^CacheTemp.Chat.WebSockets(..WebSocketID,"$J")=$J
    // ...
 

 

Second in WebKit browsers (Safari, Chrome) if the page is refreshed the WebSocket will open but no ..StatusUpdate is received and at least one process starts spinning in an infinite loop. Sometimes it may take more than one refresh, other times multiple processes start spinning.  This may be due to a long-standing bug in chromium where WebSocket close is not called on refresh/close like Firefox does (IE not tested). Adding an addition listener to init() solved the issue and seems to work cross browser:

window.addEventListener("beforeunload", function (event) {
  if (ws) {
    ws.close();
    ws = null
  }
});

Hi Mike,

thanks! The first one is already known but hasn't been addressed yet. However, the second issue you mentioned, I'll have a closer look at! 

 

Cheers,

Fabian

 

Two quick questions regarding the Server Side section:

1.  In the Server() method in the error handling for when the CSP web socket is closed, there is code that is doing "kill ^CacheTemp.ChatWebSockets(..WebSocketID)" - shouldn't this actually be:  "kill ^CacheTemp.Chat.WebSockets(..WebSocketID)"? <-- notice the period I added between "Chat" and "WebSockets" in global name.

2.  In the Server() method in the error handling for when the CSP web socket is closed, shouldn't the code that is doing the kill of "kill ^CacheTemp.Chat.WebSockets(..WebSocketID)" also be doing a kill on "^CacheTemp.Chat.Room(..WebSocketID)"?

Thanks.    And this was a good article!

Thanks! I fixed both:) (and updated the git repo). 

Please also keep in mind that the code is using the old json syntax, so you'll need to update those too. 

Cheers,

Fab

I have some questions: 

This tutorial is about Async, but, technically, you are holding a connection in your Server Method with the fact that you are in an infinite loop.

To be truly async, should the body of the server method be moved to a task which runs each second and checks if each WebSocket has written something and then do the work required?

At the moment when ProcessMessage occurs, each Websocket is opened twice, one for the initial call (now running as an infinite loop), and once when OpenServer is called with its websocketid. 

Would this not consume 2 connections, not necessarily 2 licenses, but 2 connections?

Just for clarity:

Right now, we are sync from client to server, but async server to client.

PLEASE NOTE: I understand that for a chat application, this is probably the best way to go.

When WebSocket initialize connection from the client side, the server should create a process, which will do any work for this client/s. But this connection should stay alive, that's needed by the standard. And Async, in this case, means, that any side of this connection, client or server can send a message at any time, when it needed. While another side should be ready to get this message and send or not some response. But this response, not the same as it could be in plain HTTP, it should be as any other messages if it was initiated by own.

I see what you are saying, but according to the docs, in pure Async mode, there is basically no code in the Server method, it just does a quit $$$OK.

Request  -> WebSocket Class -> Store the Websocket ID -> Exit (but do not call EndServer). This cache process then goes away. CSP is keeping the socket alive, as per the standard, ie as per the documentation, it doesn't need a cache process running to keep the connection alive.

You can then use a Task to periodically do a read on the Websockets, if you are interested.

This does not hold a license, basically the CSP gateway holds the data in it's pool, and at any time, a process can open the WebSocket via OpenServer and Read/Write the socket. Inside read is a call to ReadAsync if SharedConnection = 1.

BTW I have tried this and it works a treat. This would be good for say a Dashboard that really doesn't need to send data from the client to the Server, but the server can send whenever it is ready.

This mode of operation is not "realtime" which, say, a chat client would need.

Please feel free to let me know if my thinking is out of whack, but I don't think it is :)

Hello, 

I'm new to Intersystem technology and I'm trying to make this Websocket example works and I have some difficulties ...

So to be clear, I opened Studio and get on the USER namespace. Then I used the "Tools->Import local" and selected the 2 files Chat.Server.cls and ChatTest.csp and then compiled ...

Then I opened my browser (Chrome) and went to : http://(mywebserver)/csp/user/ChatTest.csp

Here, I can see this:

 

I just delete this part of the code from ChatTest.csp, hoping it's not essential.

But at this point, nothing happens, I can't send message, or at least they don't appear in  the chat history, I'm not even sure I'm really connected

I'm missing what exactly is my problem here so if you have any clue, I'm all ears.

Thank you very much

Jean-Baptiste

Jour problem is that morder**....js is not found in your CSP library structures.

Better start with the official example in namespace SAMPLES: Class Web.SocketTest Extends %CSP.WebSocket

That one is really useful to start

Aside from what Robert pointed out, please keep in mind that this example was for a (now) ancient version and a couple of things have changed. We're currently working on a revised version of this article that includes the necessary updates to get it to run on a current setup.

Documentation states Set SharedConnection=1 and not Set ..SharedConnection=1. The implementation here is correct as Set SharedConnection=1 does nothing, so the documentation needs to be updated. 

Since its earliest days, QEWD.js has supported WebSocket-based applications - with the WebSocket side of things all taken care of by Node.js (specifically using the socket.io module) rather than Cache/IRIS.

Now that it's quick and easy to try out QEWD with IRIS (by using my extensions for the IRIS Community Edition for AWS), you can see QEWD's Web-Sockets based applications for yourself - the QEWD-Monitor application is one that comes "out of the box" for you.  However, QEWD makes it very easy to create interactive browser/mobile Web-Socket based applications that integrate with IRIS.  See:

https://github.com/robtweed/qewd/blob/master/up/docs/InteractiveApps.md

For information on getting QEWD running wih the IRIS Community Edition for AWS, see:

https://github.com/robtweed/qewd/tree/master/docker-server-iris-ce-aws

Hi @Rob Tweed  !

Submit your QEWD framework on Open Exchange! You'll get the option to advertise it on Global Masters and DC Social media, on Youtube and even here soon.

Submitted now, awaiting approval

This was fast! Rob, all is good, please update the article link and version and you are all set!

I provided an external article link.  What do you suggest I use instead.

I entered the version number of QEWD as 2.44.23 which is what it is set to on NPM - why has this been queried?

About the version - I see 2,44,33 with commas:

Please put dots in the next Approval request?

About the Community Article URL - it waits for the link to a related article on DC (yes, we want to cross-promote DC with this).

Evgeny

I seem to be stuck in "awaiting approval" status, so am unable to make the change you're wanting