WS + API: A NodeJS Server
Designing a Node Server with Web Sockets and RESTful API
- Part 1: One Server To Rule Th…Most of Them
- Part 2: WS + API: Getting Started
- Part 3: WS + API: Enter the Index.ts
- Part 4: Interlude: Protocols
- Part 5: WS + API: Web Socket Handler
- Part 6: WS + API: Getting the Message
- Part 7: Interlude: Redis & Redis-OM
- Part 8: WS + API: Actions Speak Louder Than Code
- Part 9: From Node to Deno
So far we’ve started the server and have added event handlers for web socket messages connection
, error
, close
, and message
, but it’s the last one which that’s going to make the server worthwhile because we need to be able to not just receive messages, but also to understand what those messages mean and how the server should react.
Back when I was writing Discord bots using DiscordJS, I really loved the way that new command handlers could be added just by dropping new files into a specific directory. This required no integration with the regular code base and although development and debugging would need to be done within the context of the bot’s code base, each of these new command files were worlds unto themselves. I wanted to replicate this paradigm with the web socket handler because I could see the potential for new message handlers in the future that I’m not aware of in the present. For example, while I am aware of how web sockets can facilitate a chat app, crafting one is not high on my list of priorities…but maybe some day, once the server is feature complete and operating without (many) issues.
WSMessageHandler
We saw that in the middleware for web sockets the server is loading a file called WSMessageHandler
.
const WSMessageHandlers = require('../Utils/WSMessageHandlers');
...
static _wsc:typeof WSMessageHandlers;
...
constructor(wss: WebSocketServer){
if (!(SocketServer._wsc instanceof WSMessageHandlers)) SocketServer._wsc = new WSMessageHandlers();
}
...
ws.on('message', (msg:Buffer)=>{
SocketServer._wsc.HandleMessage(ws, msg.toString());
})
The WSMessageHandlers
class instance is assigned to a static variable _wsc which is used when the current socket raises a message
event. We pass the message to the HandleMesage
method where we will parse the payload from the client.
const fs = require('node:fs');
const path = require('node:path');
const HandlerCollection = require('../Objects/HandlerCollection');
import { WebSocket } from "ws";
import { IMessage } from "../Objects/IMessage";
import { IHandler } from "../Objects/IHandler";
module.exports = class WSMessageHandlers{
// Local vars
static _handlerCollection = new HandlerCollection;
// Remember, we can overload, but the final sig
// needs to include all vars from overloads and
// default some/many/most/all in case of omissions.
constructor() {
this.GetHandlers();
}
// load the MessageHandlers into the _handlerCollection
// Do NOT nest handler files in directories.
GetHandlers(){
const filesPath = path.join(__dirname, '..', 'MessageHandlers');
const messageFiles = fs.readdirSync(filesPath);
for(const file of messageFiles){
const filePath = path.join(filesPath, file);
if (path.extname(filePath) === '.ts'){
const handler:IHandler = require(filePath);
if ('Name' in handler && 'Exec' in handler) {
WSMessageHandlers._handlerCollection.AddHandler(handler);
} else {
console.log(`[WARNING] The handler at ${filePath} is missing a required "name" or "execute" property.`);
}
}
}
}
HandleMessage(cn:WebSocket, msg:string){
const _msg:IMessage = JSON.parse(msg) as IMessage;
// Find the appropriate handler in _handlerCollection by msg.
const _handler:IHandler[] = WSMessageHandlers._handlerCollection.GetHandler(_msg.Name);
if (_handler){
// TODO: Need some null handling in HandleMessage when we don't have a handler defined.
_handler[0].Exec(cn, _msg);
}
}
}
The gist of this class is that we examine a particular folder within the project, and load every *.ts
file contained therein as a module, storing them in a static collection. While this is set to run when the server fires up, by making the collection variable static means we should be able to instantiate the class anywhere in the app and re-run the import whenever we want. This should allow for hot-reload of message handlers without the need to take down the server…theoretically.
Imports
The top two imports fs
and path
are Node-native libraries which allow us to work with the local filesystem to read directories and files.
HandlerCollection
is another class which contains a static collection that we’ll use to store our loaded files.
import { IHandler } from './IHandler';
module.exports = class HandlerCollection {
static _handlers: IHandler[] = [];
AddHandler(h:IHandler){
HandlerCollection._handlers.push(h);
console.log(`Loaded handler ${h.Name}.`);
}
GetHandler(n:string){
return HandlerCollection._handlers.filter((h:IHandler) => h.Name === n);
}
RemoveHandler(n:string){
var removeIndex = HandlerCollection._handlers.map((item:IHandler) => item.Name).indexOf(n);
if (removeIndex >= 0){
HandlerCollection._handlers.splice(removeIndex, 1);
console.log(`Removed handler ${n}`);
}
}
}
Aside from the collection, this class has a few methods that allow us to GET, SET, and REMOVE content to and from the collection.
Finally, we are loading a few types for web sockets, and two interfaces that define the messages sent from the client and the handler that we will expect our loaded files to assume.
WSMessagesHandlers
Class
GetHandlers
On instantiation, we’re going to hit the ground running by calling the local method GetHandlers
. This will check a specific project folder “MessageHandlers” and for every *.ts
file in there, load it as an import and put the import into the HandlerCollection
in-memory storage.
HandleMessage
Whenever the socket raises a message
event it will pass the contents of the msg
argument to this method. The string msg
will be converted into a form that the server recognizes, IMessage
, which enforces type and field requirements (this code section should be in a try...catch
block, so I need to go back an add that).
The server then attempts to pull out a single handler based on the Name property of the IMessage
matching the Name property of the handlers in the collection. If one is found, it is returned and the function defined in the matching handler is executed.
Conclusion
…wait…what is a matching handler? How do we know what a handler looks like?
In the next post, we’ll talk about that: the required form of a message handler, and the ways that custom handlers can be used for almost anything you want the server to respond to.