Sockets
There might be rare occasions where you need to extend the existing socket interface; e.g. after adding a new database table to CARE and you want to make this data available to the frontend. In this chapter we outline the steps necessary for either extending an existing socket or creating an entirely new one.
Note
Please also see the code conventions for the socket interface in Code Conventions
Sockets in CARE
The socket architecture is realized in the backend; in the following all paths are provided relative to the
directory backend/webserver
including all relevant components.
During start-up of the webserver all sockets specified in the sockets
directory that extends the Socket
base class are loaded.
Each socket is specified in an individual .js
-file exporting a class that extends the Socket
base class. Hence, adding
a socket means creating such a new file and overriding the abstract class.
This base class (specified in Socket.js
) defines several convenience methods to communicate with the frontend, as
well as attributes and methods to interact with the web server class starting the respective socket. In the following
sections we elaborate which methods and attributes you should use or override to realize your own service.
Conceptually, a socket class encapsulates a socket-io connection established by the webserver, which you can use to listen to certain messages and which you can use to send back messages. For each frontend client that connects to the webserver, all socket classes are instantiated anew having an associated client id. This means, all messages you send and receive via the socket attribute are always specific to this one client connection; in other words, there is no state transfer between different connections or broadcasting possible. However, some of this functionality is made available through the server object – we address these options in the Extending a Socket section below.
Creating a Socket
Let’s say we want to add a new socket called TestSocket
to CARE. First, we add a new file test.js
to the
backend/webserver/sockets
directory that exports the new socket class:
const Socket = require("../Socket.js");
/**
* <doc>
*/
module.exports = class TestSocket extends Socket {
constructor(server, io, socket) {
super(server, io, socket);
}
}
While this would already add the socket automatically to CARE on restart of the webserver, you should always override
the init()
method of the base class. Here, you will add the message listeners; for now, we will just log that the
socket has been initialized.
/**
* HEADER BOILERPLATE...
*/
module.exports = class TestSocket extends Socket {
constructor(server, io, socket) {
super(server, io, socket);
}
init() {
this.logger.debug(`TestSocket was created for client ${this.socket.id}`);
}
}
In the next section we elaborate on how to populate a socket by message listeners and response functionality.
Extending a Socket
Let’s assume we want to extend a socket TestSocket
defined in backend/webserver/sockets/test.js
. Generally,
socket logic should be kept lean and easy. The primary responsibility of a socket is to forward a new client request
to another module for processing (e.g. the database interface), handle possible errors and send the results back to
the client. In the following we explain the possible use-cases for extending a socket
Listening to a New Message Type
Let’s say we want to listen to new message types testIncrement
and testReset
that provides a data object parameters.
For the sake of brevity we do not interact with the database, but write to a class attribute.
/**
* HEADER BOILERPLATE...
*/
module.exports = class TestSocket extends Socket {
constructor(server, io, socket) {
super(server, io, socket);
this.testVar = 0;
}
/* ... */
// method for updating the variable
updateTestVar(newVal){
this.testVar = newVal;
return this.testVar;
}
init() {
/* ... */
// listen to testIncrement
this.socket.on("testIncrement", (data) => {
try {
this.socket.emit("testResult", {success: true, val: this.updateTestVar(this.testVar + data.inc)});
} catch (e) {
this.logger.error(e);
}
});
// listen to testReset
this.socket.on("testReset", () => {
try {
this.socket.emit("testResult", {success: true, val: this.updateTestVar(0)});
} catch (e) {
this.logger.error(e);
}
});
}
}
Let’s decompose the steps to realize this example. First, we extend the init()
method adding event listeners
on the socket using the on(msg, callback)
function of the socket.io client. We then call the class method that
allows us to modify the state of the test variable (this.updateTestVar(...)
). Finally, we return the resulting
value to the client via emit(msg, data)
with the resulting value.
Error and Rights Management
When interacting with the database the key challenge is error handling, marshalling (i.e. the translation of the DB
data representation into a suitable format for the frontend) and rights management.
For now, we assume that we want to call an already defined database a model Test
specified in
backend/db/models/test.js
and integrate this call into the above example.
Let’s also assume that only administrators are allowed to change this value.
Note
Please refer to the guide on how to extend the database and add interface methods in Database.
/**
* HEADER BOILERPLATE...
*/
module.exports = class TestSocket extends Socket {
/* ... */
// method for updating the variable
async updateTestVar(newVal){
// use base class method to check for admin rights
if (this.isAdmin()) {
try {
const result = await this.models["test"].updateById("x", {val: newVal});
this.socket.emit("testResult", {success: true, val: result});
} catch (e) {
this.socket.emit("testResult", {success: false, message: "Failed to update test!"});
this.logger.error("DB error while updating test" + JSON.stringify(e));
}
} else {
// respond with a negative message
this.socket.emit("testResult", {success: false, message: "User rights and argument mismatch"});
}
}
init() {
/* ... */
// listen to testIncrement
this.socket.on("testIncrement", async (data) => {
try {
await this.updateTestVar(this.testVar + data.inc);
} catch (e) {
this.logger.error(e);
}
});
// listen to testReset
this.socket.on("testReset", async () => {
try {
await this.updateTestVar(0);
} catch (e) {
this.logger.error(e);
}
}
}
}
Let’s decompose the example again! We first update the updateTestVar
to use the dbUpdateTest
function interfacing
the database. Before making the actual call to the database, we check whether the user is an admin through the base
class isAdmin()
method accessing the backend user information associated with the current connection. Because the
database query might fail, we add a try-catch-block around it. In case of an error at any stage, we send a negative
testResult back to the client.
Note
It is crucial that you do full error handling on the socket level, i.e. at some point all exceptions should have been caught by a catch block. Otherwise, the webserver can crash due to minor errors during database interaction.
Broadcasting Responses
To realize collaboration features, it might be desirable to join clients into rooms and multicast messages to these groups. In exceptional cases, even a broadcast might make sense. You should use these two options very sparingly, because they imply a lot of network traffic.
To join a client to a socketio room, you can simply use the join(name)
function on the socket object:
this.socket.join("roomName");
To multicast a message to a room, you need to access the io object of the base class.
this.io.to("roomName").emit("msg", data);
Testing
Please think about how to test your socket. In general, you should test the functionality in isolation. We refer to the section on Tests for more information on how to test your code.