Hub
A Node.js WebSocket server and client with added features
Dependencies
- Node.js (version 14 or greater)
- Redis
Install
npm i @anephenix/hub
Features
- Isomorphic WebSocket client support
- Bi-directional RPC (Remote Procedure Call)
- Request-only RPC calls
- PubSub (Publish/Subscribe)
- Automatically unsubscribe clients from channels on disconnect
- Automatically resubscribe clients to channels on reconnect
- Authenticated Channels
- Restrict client channel publish capability on a per-client basis
- Use an existing HTTP/HTTPS server with the WebSocket server
- Allow client connections only from a list of url origins or ip addresses
Usage
Getting started
RPC (Remote Procedure Calls)
PubSub (Publish/Subscribe)
Advanced PubSub
Security
Getting started
Here is how to get started quickly.
Starting a server
You can run the WebSocket server with this code snippet:
const { Hub } = require('@anephenix/hub');
const hub = new Hub({ port: 4000 });
hub.listen();
Loading a client in the browser
And for the client, you can load this code:
import HubClient from '@anephenix/hub/lib/client';
const hubClient = new HubClient({ url: 'ws://localhost:4000' });
HubClient uses Sarus as the WebSocket client behind the scenes. If you want to
provide custom config options to Sarus, you can do so by using this code:
const hubClient = new HubClient({
url: 'ws://localhost:4000',
sarusConfig: { retryConnectionDelay: 500 },
});
Loading a client in Node.js
Traditionally WebSocket clients connect from the web browser, but with Hub it is
possible to create a WebSocket client from a program running in Node.js. Here is
an example:
const repl = require('repl');
const { HubClient } = require('@anephenix/hub');
const hubClient = new HubClient({ url: 'ws://localhost:3000' });
const replInstance = repl.start('> ');
replInstance.context.hubClient = hubClient;
In the example above, you have Node.js repl with a Hub WebSocket client
connecting to a Hub WebSocket server running at localhost:3000. You can then
make calls from the client, such as getting the clientId of the client:
hubClient.getClientId();
RPC (Remote Procedure Calls)
Hub has support for defining RPC functions, but with an added twist. Traditionally RPC functions are defined on the server and called from the client.
Hub supports that common use case, but also supports defining RPC functions on the client that the server can call.
We will show examples of both below:
Creating an RPC function on the server
const cryptocurrencies = {
bitcoin: 11393.9,
ethereum: 373.23,
litecoin: 50.35,
};
setInterval(() => {
Object.keys(cryptocurrencies).forEach((currency) => {
const movement = Math.random() > 0.5 ? 1 : -1;
const amount = Math.random();
cryptocurrencies[currency] += movement * amount;
});
}, 1000);
const getPriceFunction = ({ data, reply }) => {
let cryptocurrency = cryptocurrencies[data.cryptocurrency];
reply({ data: { cryptocurrency } });
};
hub.rpc.add('get-price', getPriceFunction);
Calling the RPC function from the client
Now let's say you want to get the price for ethereum from the client:
const request = {
action: 'get-price',
data: { cryptocurrency: 'ethereum' },
};
const { cryptocurrency } = await hubClient.rpc.send(request);
console.log({ cryptocurrency });
Creating an RPC function on the client
const getEnvironment = ({ reply }) => {
const { arch, platform, version } = process;
reply({ data: { arch, platform, version } });
};
hubClient.rpc.add('get-environment', getEnvironment);
Calling the RPC function from the server
const ws = hubServer.wss.clients.values().next().value;
const response = await hubServer.rpc.send({
ws,
action: 'get-environment',
});
Calling an RPC function without wanting a response back
In some cases you might want to make a request to an RPC function but not get
a reply back (such as sending an api key to a client). You can do that by
passing a noReply
boolean to the rpc.send
function, like in this example:
const response = await hubServer.rpc.send({
ws,
action: 'set-api-key',
data: { apiKey: 'eKam2aa3dah2jah4UtheeFaiPo6xahx5ohrohk5o' },
noReply: true,
});
The response will be a null
value.
PubSub (Publish/Subscribe)
Hub has support for PubSub, where the client subscribes to channels and unsubscribes from them, and where both the client and the server can publish messages to those channels.
Subscribing to a channel
await hubClient.subscribe('news');
Unsubscribing from a channel
await hubClient.unsubscribe('news');
Publishing a message from the client
await hubClient.publish('news', 'Some biscuits are in the kitchen');
If you want to send the message to all subscribers but exclude the sender, you can pass a third argument to the call:
await hubClient.publish('news', 'Some biscuits are in the kitchen', true);
Publishing a message from the server
const channel = 'news';
const message = 'And cake too!';
(async () => {
await hub.pubsub.publish({
data: { channel, message },
});
})();
Handling messages published for a channel
const channel = 'weather';
const weatherUpdates = (message) => {
const { temperature, conditions, humidity, wind } = message;
console.log({ temperature, conditions, humidity, wind });
};
hubClient.addChannelMessageHandler(channel, weatherUpdates);
Removing message handlers for a channel
hubClient.removeChannelMessageHandler(channel, weatherUpdates);
function logger(message) {
console.log({ message });
}
hubClient.removeChannelMessageHandler(channel, 'logger');
Handling client disconnects / reconnects
When a client disconnects from the server, the client will automatically be
unsubscribed from any channels that they were subscribed to. The server
handles this, meaning that the list of clients subscribed to channels is
always up-to-date.
When a client reconnects to the server, the client will automatically be
resubscribed to the channels that they were originally subscribed to. The
client handles this, as it maintains a list of channels currently subscribed
to, which can be inspected here:
hubClient.channels;
Handling client / channel subscriptions data
Hub by default will store data about client/channel subscriptions in memory.
This makes it easy to get started with using the library without needing to
setup databases to store the data.
However, we recommend that you setup a database like Redis to store that
data, so that you don't lose the data if the Node.js process that is running
Hub ends.
You can setup Hub to use Redis as a data store for client/channels
subscriptions data, as demonstrated in the example below:
const hub = new Hub({
port: 4000,
dataStoreType: 'redis',
dataStoreOptions: {
channelsKey: 'channels'
clientsKey: 'clients'
redisConfig: {
db: 1
}
}
});
The added benefit of using the Redis data store is that it supports horizontal scaling.
For example, say you have two instances of Hub (server A and server B), and two clients
(client A and client B). Both clients are subscribed to the channel 'news'.
If a message is published to the channel 'news' using server A, then the message will be
received by both servers A and B, and the message will be passed to clients that
are subscribers to that channel, in this case both Client A and client B.
This means that you don't have to worry about which clients are connected to which servers,
or which servers are receiving the publish actions. You can then run multiple instances of
Hub across multiple servers, and have a load balancer sit in front of the servers to handle
availability (making sure WebSocket connections go to available servers, and if a server
goes offline, that it can pass the reconnection attempt to another available server).
Creating channels that require authentication
There will likely be cases where you want to use channels that only some users can subscribe to.
Hub provides a way to add private channels by providing channel configurtions to the server, like
in this example below:
const channel = 'internal_announcements';
const authenticate = ({ socket, data }) => {
if (isAllowed(data.channel, socket.clientId)) return true;
if (isValidToken(data.token)) return true;
};
hub.pubsub.addChannelConfiguration({ channel, authenticate });
Then on the client, a user can subscribe and provide additional data to authenticate the channel
const channel = 'internal_announcements';
const token = 'ahghaCeciawi5aefi5oolah6ahc8Yeeshie5opai';
await hubClient.subscribe(channel, { token });
Adding wildcard channels configurations
There may be a case where you want to apply authentication across a range of channels without wanting
to add a channel configuration for each channel. There is support for wildcard channel configurations.
To illustrate, say you have a number of channels that are named like this:
- dashboard_IeK0iithee
- dashboard_aipe0Paith
- dashboard_ETh2ielah1
Rather than having to add channel configurations for each channel, you can add a wildcard channel
configuration like this:
const channel = 'dashboard_*';
const authenticate = ({ socket, data }) => {
if (isAllowed(data.channel, socket.clientId)) return true;
};
hub.pubsub.addChannelConfiguration({ channel, authenticate });
The dashboard_*
wildcard channel will then run across all channels that have
a name containing dashboard_
in them.
Enabling / disabling client publish capability
By default clients can publish messages to a channel. There may be some
channels where you do not want clients to be able to do this, or cases where
only some of the clients can publish messages.
In such cases, you can set a clientCanPublish
boolean flag when adding a
channel configuration, like in the example below:
const channel = 'announcements';
hub.pubsub.addChannelConfiguration({ channel, clientCanPublish: false });
If you need to enable/disable client publish on a client basis, you can pass a
function that receives the data and socket, like this:
const channel = 'panel_discussion';
const clientCanPublish = ({ data, socket }) => {
return isAllowed(socket.clientId) && isSafeToPublish(data.message);
};
hub.pubsub.addChannelConfiguration({ channel, clientCanPublish });
Security
Using-a-secure-server-with-hub
Hub by default will initialise a HTTP server to attach the WebSocket server to.
However, it is recommended to use HTTPS to ensure that connections are secure.
Hub allows you 2 ways to setup the server to run on https - either pass an
instance of a https server to Hub:
const https = require('https');
const fs = require('fs');
const { Hub } = require('@anephenix/hub');
const serverOptions = {
key: fs.readFileSync('PATH_TO_SSL_CERTIFICATE_KEY_FILE'),
cert: fs.readFileSync('PATH_TO_SSL_CERTIFICATE_FILE');
};
const httpsServer = https.createServer(serverOptions);
const hub = await new Hub({port: 4000, server: httpsServer});
Alternatively, you can pass the string 'https' with the https
server options passed as a serverOptions
property to Hub.
const fs = require('fs');
const { Hub } = require('@anephenix/hub');
const serverOptions = {
key: fs.readFileSync('PATH_TO_SSL_CERTIFICATE_KEY_FILE'),
cert: fs.readFileSync('PATH_TO_SSL_CERTIFICATE_FILE');
};
const hub = await new Hub({port: 4000, server: 'https', serverOptions });
When you use a https server with Hub, the url for connecting to the server
will use wss://
instead of ws://
.
Restricting where WebSockets can connect from
You can restrict the urls where WebSocket connections can be established by
passing an array of url origins to the allowedOrigins
property for a server:
const { Hub } = require('@anephenix/hub');
const hub = await new Hub({
port: 4000,
allowedOrigins: ['landscape.anephenix.com'],
});
This means that any attempted connections from websites not hosted on
'landscape.anephenix.com' will be closed by the server.
Alernatively, you can also restrict the IP Addresses that clients can make
WebSocket connections from:
const { Hub } = require('@anephenix/hub');
const hub = await new Hub({ port: 4000, allowedIpAddresses: ['76.76.21.21'] });
Kicking clients from the server
There may be cases where a client is misbehaving, and you want to kick them off the server. You can do that with this code
const ws = Array.from(hub.wss.clients)[0];
await hub.kick({ ws });
This will disable the client's automatic WebSocket reconnection code, and close the websocket connection.
However, if the person operating the client is versed in JavaScript, they can try and override the client code to reconnect again.
Banning clients from the server
You may want to ban a client from being able to reconnect again. You can do that by using this code:
const ws = Array.from(hub.wss.clients)[0];
await hub.kickAndBan({ ws });
If the client attempts to reconnect again, then they will be kicked off automatically.
Adding or removing ban rules for clients
Client kicking/banning works by using a list of ban rules to check clients against.
A ban rule is a combination of a client's id, hostname and ip address.
You can add ban rules to the system via this code:
const banRule = {
clientId: 'da1441a8-691a-42db-bb45-c63c6b7bd7c7',
host: 'signal.anephenix.com',
ipAddress: '92.41.162.30',
};
await hub.dataStore.addBanRule(banRule);
A ban rule can consist of only one or two properties as well, say the ipAddress:
const ipAddressBanRule = {
ipAddress: '92.41.162.30',
};
await hub.dataStore.addBanRule(ipAddressBanRule);
To remove the ban rule, you can use this code:
const banRule = {
clientId: 'da1441a8-691a-42db-bb45-c63c6b7bd7c7',
host: 'signal.anephenix.com',
ipAddress: '92.41.162.30',
};
await hub.dataStore.removeBanRule(banRule);
To get the list of ban rules, you can use this code:
await hub.dataStore.getBanRules();
To clear all of the ban rules:
await hub.dataStore.clearBanRules();
Running tests
To run tests, make sure that you have mkcert installed to generate some SSL certificates on your local machine.
npm run certs
npm t
npm run cucumber
License and Credits
© 2021 Anephenix OÜ. All rights reserved. Hub is licensed under the MIT licence.