0.0.22 • Published 1 year ago

@anephenix/hub v0.0.22

Weekly downloads
-
License
MIT
Repository
-
Last release
1 year ago

Hub

A Node.js WebSocket server and client with added features

npm version CircleCI Coverage Status Maintainability Test Coverage

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:

// Dependencies
const { Hub } = require('@anephenix/hub');

// Initialize hub to listen on port 4000
const hub = new Hub({ port: 4000 });

// Start listening
hub.listen();
Loading a client in the browser

And for the client, you can load this code:

/* Dependencies

	You will want to import just the HubClient library
	if you are using a library to import and transpile
	modules like WebPack for Next.js

*/
import HubClient from '@anephenix/hub/lib/client';

// Create an instance of HubClient
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:

// Create an instance of HubClient
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:

// Dependencies
const repl = require('repl');
const { HubClient } = require('@anephenix/hub');

// Initialise the client
const hubClient = new HubClient({ url: 'ws://localhost:3000' });

// Start the REPL and make hubClient available
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
// Here's some example data of say cryptocurrency prices
const cryptocurrencies = {
	bitcoin: 11393.9,
	ethereum: 373.23,
	litecoin: 50.35,
};

// This simulates price movements, so that requests to the rpc
// function will returning changing prices.
setInterval(() => {
	Object.keys(cryptocurrencies).forEach((currency) => {
		const movement = Math.random() > 0.5 ? 1 : -1;
		const amount = Math.random();
		cryptocurrencies[currency] += movement * amount;
	});
}, 1000);

// Here we define the function to be added as an RPC function
const getPriceFunction = ({ data, reply }) => {
	let cryptocurrency = cryptocurrencies[data.cryptocurrency];
	reply({ data: { cryptocurrency } });
};

// We then attach that function to the RPC action 'get-price'
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:

// Setup a request to get the price of ethereum
const request = {
	action: 'get-price',
	data: { cryptocurrency: 'ethereum' },
};
// Send that RPC request to the server
const { cryptocurrency } = await hubClient.rpc.send(request);

// Log the response from the data
console.log({ cryptocurrency });
Creating an RPC function on the client
// Create an RPC function to call on the client
const getEnvironment = ({ reply }) => {
	// Get some details from a Node CLI running on a server
	const { arch, platform, version } = process;
	reply({ data: { arch, platform, version } });
};
// Add that function for the 'get-environment RPC call'
hubClient.rpc.add('get-environment', getEnvironment);
Calling the RPC function from the server
// Fetch a WebSocket client, the first in the list
const ws = hubServer.wss.clients.values().next().value;
// Make an RPC request to that WebSocket client
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);

// You can also remove the function by referring to its name
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' // by default it is hub-channels
		clientsKey: 'clients' // by default it is hub-clients
		/*
		* This is the same config options that can be passed into the redis NPM
		* module, with details here:
		* https://www.npmjs.com/package/redis#options-object-properties
		*/
		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';
/*
 * Here we create a function that is called every time a client tries to
 * subscribe to a channel with a given name
 */
const authenticate = ({ socket, data }) => {
	// We have access to the socket of the client and the data they pass in
	// the subscribe request.
	//
	// isAllowed and isValid are just example functions that the developer can
	// define to perform the backend authentication for the subscription
	// request.
	if (isAllowed(data.channel, socket.clientId)) return true;
	if (isValidToken(data.token)) return true;
	// The function must return true is the client is allowed to subscribe
};

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:

// The wildcard matching character is *
const channel = 'dashboard_*';
const authenticate = ({ socket, data }) => {
	// For implementing authentication specific to each channel,
	// the channel is available in the data object
	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 }) => {
	// Here you can inspect the publish data and the socket
	// of the client trying to publish
	//
	// isAllowed && isSafeToPublish are example functions
	//
	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

// Let's take the 1st client in the list of connected clients as an example
const ws = Array.from(hub.wss.clients)[0];
// Call kick
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:

// Let's take the 1st client in the list of connected clients as an example
const ws = Array.from(hub.wss.clients)[0];
// Call kick
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

© 2020 Anephenix OÜ. All rights reserved. Hub is licensed under the MIT licence.

0.0.22

1 year ago

0.0.21

1 year ago

0.0.20

2 years ago

0.0.19

2 years ago

0.0.18

2 years ago

0.0.17

2 years ago

0.0.16

2 years ago

0.0.15

2 years ago

0.0.14

2 years ago

0.0.13

2 years ago

0.0.12

2 years ago

0.0.11

2 years ago

0.0.10

2 years ago

0.0.9

2 years ago

0.0.8

2 years ago

0.0.7

2 years ago

0.0.6

2 years ago

0.0.5

2 years ago

0.0.4

2 years ago

0.0.3

2 years ago

0.0.2

2 years ago

0.0.1

2 years ago