@cryb/mesa
Advanced tools
Comparing version 1.4.7 to 1.5.0
@@ -142,7 +142,8 @@ "use strict"; | ||
} | ||
case 22: | ||
case 22: { | ||
this.authenticated = true; | ||
if (this.rules.indexOf('sends_user_object') > -1 && this.authenticationResolve) | ||
if (this.authenticationResolve) | ||
this.authenticationResolve(d); | ||
return; | ||
} | ||
} | ||
@@ -149,0 +150,0 @@ this.emit('message', message); |
@@ -7,1 +7,2 @@ export { default } from './server'; | ||
export { default as DispatchEvent } from './dispatcher/event'; | ||
export { default as Middleware, MiddlewareHandler } from './middleware/defs'; |
12
index.js
@@ -5,14 +5,14 @@ "use strict"; | ||
var server_1 = require("./server"); | ||
exports.default = server_1.default; | ||
Object.defineProperty(exports, "default", { enumerable: true, get: function () { return server_1.default; } }); | ||
var client_1 = require("./client"); | ||
exports.Client = client_1.default; | ||
Object.defineProperty(exports, "Client", { enumerable: true, get: function () { return client_1.default; } }); | ||
var message_1 = require("./server/message"); | ||
exports.Message = message_1.default; | ||
Object.defineProperty(exports, "Message", { enumerable: true, get: function () { return message_1.default; } }); | ||
// Portal | ||
var portal_1 = require("./portal"); | ||
exports.Portal = portal_1.default; | ||
Object.defineProperty(exports, "Portal", { enumerable: true, get: function () { return portal_1.default; } }); | ||
// Dispatcher | ||
var dispatcher_1 = require("./dispatcher"); | ||
exports.Dispatcher = dispatcher_1.default; | ||
Object.defineProperty(exports, "Dispatcher", { enumerable: true, get: function () { return dispatcher_1.default; } }); | ||
var event_1 = require("./dispatcher/event"); | ||
exports.DispatchEvent = event_1.default; | ||
Object.defineProperty(exports, "DispatchEvent", { enumerable: true, get: function () { return event_1.default; } }); |
{ | ||
"name": "@cryb/mesa", | ||
"version": "1.4.7", | ||
"version": "1.5.0", | ||
"description": "A scalable, modern & robust WebSocket wrapper", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
@@ -27,4 +27,4 @@ /// <reference types="node" /> | ||
private clientNamespace; | ||
private portalPubSubNamespace; | ||
private availablePortalsNamespace; | ||
private get portalPubSubNamespace(); | ||
private get availablePortalsNamespace(); | ||
private log; | ||
@@ -31,0 +31,0 @@ private parseConfig; |
@@ -58,3 +58,3 @@ "use strict"; | ||
} | ||
}).subscribe(this.portalPubSubNamespace()); | ||
}).subscribe(this.portalPubSubNamespace); | ||
} | ||
@@ -72,10 +72,10 @@ handleSocketUpdate(type, clientId) { | ||
this.publishReadyState(true); | ||
this.redis.sadd(this.availablePortalsNamespace(), this.id); | ||
this.redis.sadd(this.availablePortalsNamespace, this.id); | ||
this.log('published! ready to recieve updates on namespace', this.config.namespace); | ||
} | ||
setupCloseHandler() { | ||
death_1.default((signal, err) => { | ||
death_1.default(() => { | ||
this.log('shutting down...'); | ||
this.publishReadyState(false); | ||
this.redis.srem(this.availablePortalsNamespace(), this.id); | ||
this.redis.srem(this.availablePortalsNamespace, this.id); | ||
process.exit(0); | ||
@@ -85,3 +85,3 @@ }); | ||
publishReadyState(readyState) { | ||
this.publisher.publish(this.availablePortalsNamespace(), JSON.stringify({ | ||
this.publisher.publish(this.availablePortalsNamespace, JSON.stringify({ | ||
id: this.id, | ||
@@ -94,6 +94,6 @@ ready: readyState | ||
} | ||
portalPubSubNamespace() { | ||
get portalPubSubNamespace() { | ||
return this.clientNamespace('portal'); | ||
} | ||
availablePortalsNamespace() { | ||
get availablePortalsNamespace() { | ||
return this.clientNamespace('available_portals_pool'); | ||
@@ -100,0 +100,0 @@ } |
563
README.md
@@ -1,43 +0,6 @@ | ||
 | ||
# Mesa | ||
Mesa is a WebSocket library that provides extra features such as heartbeats, automatic reconnection handling and Pub/Sub support. | ||
_**Mesa** — Robust, reliable WebSockets_ | ||
[ws](https://www.npmjs.com/package/ws), which Mesa wraps, on its own usually isn't enough. It doesn't provide features out of the box required by modern applications which means many users either stick to [Socket.IO](https://socket.io) or write their own implementations around the `ws` package. Mesa was extracted from the WebSocket implementation in `@cryb/api` after we wanted to add robust WebSocket capabilities to other services. | ||
[](https://github.com/crybapp/mesa/graphs/contributors) [](https://github.com/crybapp/mesa/blob/master/LICENSE) [](https://patreon.com/cryb) | ||
## Docs | ||
* [Info](#info) | ||
* [Features](#features) | ||
* [Planned Features](#planned-features) | ||
* [Status](#status) | ||
* [Codebase](#codebase) | ||
* [Code Style](#code-style) | ||
* [Installation](#installation) | ||
* [Usage](#usage) | ||
* [Server Side](#server-side) | ||
* [Authenticating Clients](#authenticating-clients) | ||
* [Message Sync](#message-sync) | ||
* [Implementing a Custom Sync Interval](#implementing-a-custom-sync-interval) | ||
* [Disabling Initial Sync for Clients](#disabling-initial-sync-for-clients) | ||
* [Disabling Sync for Individual Messages](#disabling-sync-for-individual-messages) | ||
* [Dispatch Events](#dispatch-events) | ||
* [Portals](#portals) | ||
* [Client Side](#client-side) | ||
* [JavaScript](#javascript) | ||
* [Authentication](#authentication) | ||
* [Opcodes](#opcodes) | ||
* [Configuration](#configuration) | ||
* [Client Libraries](#client-libraries) | ||
* [Creating a Client Library](#creating-a-client-library) | ||
* [Future Libraries](#future-libraries) | ||
* [Questions / Issues](#questions--issues) | ||
## Info | ||
`@cryb/mesa` is a wrapper around the [ws](https://www.npmjs.com/package/ws) library that provides extra features such as heartbeats, automatic reconnection handling and Redis pub/sub support. | ||
`ws` on its own usually isn't enough. It doesn't provide features out of the box required by modern applications such as replication support and reconnection handling. Many users either stick to [Socket.IO](https://socket.io) or write their own implementations for authentication, reconnections and pub/sub around the `ws` package. | ||
Mesa was created to provide a wrapper around `ws` to allow developers to quickly deploy a WebSocket server with all the features they need a simple configuration update away. | ||
Modeled somewhat after Discord's WebSockets, Mesa was first created by [William](https://github.com/neoncloth) in a client project before making its way into `@cryb/api`. After a while we wanted to add WebSocket capabilities to other services we were working on utilising the robust WebSocket solution we had created in `@cryb/api`. Thus, Mesa was born. | ||
In a nutshell, Mesa provides a simple, configurable wrapper that provides support for pub/sub, authentication, heartbeats and more and has powered production applications with millions of users since mid-2019 | ||
@@ -47,30 +10,8 @@ | ||
* Heartbeat support | ||
* Message sync support | ||
* Reconnection support | ||
* Authentication support | ||
* Global dispatch events | ||
* Pub/sub support via Redis | ||
* Message redelivery support | ||
* Replciation support via Redis | ||
* *and many more...* | ||
#### Planned Features | ||
* Specification | ||
* Redis config support | ||
* Better error reporting | ||
* Custom message interface | ||
* Less dependency on Redis | ||
* Plugin / middleware support | ||
* Better disconnection handling | ||
### Status | ||
`@cryb/mesa` has been actively developed since mid-2019 internally and was open-sourced in December 2019 | ||
## Codebase | ||
The codebase for `@cryb/mesa` is written in JavaScript, utilising TypeScript and Node.js | ||
### Code Style | ||
We ask that you follow our [code style guidelines](https://github.com/crybapp/library/blob/master/code-style/STYLE.md) when contributing to this repository. | ||
We use TSLint in order to lint our code. Run `yarn lint` before committing any code to ensure it's clean. | ||
*Note: while we have most rules covered in our `tslint.json` config, it's good practice to familarise yourself with our code style guidelines* | ||
## Installation | ||
@@ -101,3 +42,3 @@ This library is available on the [NPM registry](https://www.npmjs.com/package/@cryb/mesa). To install, run: | ||
We provide expansive configuration support for customising Mesa to your needs. See [Configuration](#configuration) for options. | ||
We provide expansive configuration support for customising Mesa to your needs. See [Server Configuration](src/docs/server/configuration.md) for options. | ||
@@ -121,307 +62,61 @@ Mesa uses `EventEmitter` in order to inform the application of events. Here's an example of a simple Mesa application: | ||
From here, everything should be fairly self explanatory. We'll share more guides once we implement more features for Mesa | ||
#### Authenticating Clients | ||
Mesa supports client authentication through a simple API that can adapt to support your authentication infrastructure. To authenticate a user from Mesa, use the following API: | ||
Sending messages to clients or globally is easy. Simply import `Message` from Mesa and use the following API: | ||
```js | ||
const server = new Mesa({ | ||
port: 4000, | ||
/** | ||
* Optional: supply a namespace so different Mesa servers running on a cluster don't interfere with each other | ||
*/ | ||
namespace: 'api', | ||
/** | ||
* Optional: supply a Redis URI to make full use of Authentication via Pub/Sub | ||
*/ | ||
redis: 'redis://localhost:6379' | ||
}) | ||
// Sending globally | ||
mesa.send(new Message(0, { disabled: true }, 'MANIFEST_UPDATE')) | ||
mesa.on('connection', client => { | ||
/** | ||
* When a client connects and sends an authentication message, it can be handled here. | ||
* | ||
* This authentication method takes in a callback that is called with two parameters: data (sent from the client) and done (supplied by Mesa). | ||
* | ||
* This method is called when a client sends a opcode 2 message. For example, it would look like {"op": 2, d: { "token": ... }} | ||
*/ | ||
client.authenticate(async ({ token }, done) => { | ||
try { | ||
/** | ||
* Once authentication data has been supplied from the client message, you can authenticate via a call to a microservice or database lookup | ||
*/ | ||
const { data: user } = await axios.post('http://localhost:4500', { token }) | ||
const { info: { id } } = user | ||
// You can also limit to certain authenticated client ids | ||
mesa.send(new Message(0, { content: 'Hey!' }, 'NEW_MESSAGE'), ['0', '1', '2']) // Only send to connected clients with id 0, 1, 2 | ||
mesa.send(new Message(0, { userId: '1', status: 'online' }, 'STATUS_UPDATE'), ['*'], ['1']) // Send to all connected clients except client with id 1 | ||
/** | ||
* Once you have authenticated the user, call the done method supplied in the callback with two parameters. | ||
* | ||
* The first parameter should be an error incase there was an issue authenticating this user. If there was no issue, supply null. | ||
* | ||
* The second parameter should be an object containing the user ID and the user object. The user ID will be used for Redis Pub/Sub and will be available in the client.id property. If there was an error, do not supply this value or simply supply null | ||
*/ | ||
done(null, { id, user }) | ||
} catch(error) { | ||
// Feel free to use a try/catch to resolve done with an error | ||
done(error) | ||
} | ||
}) | ||
}) | ||
// Sending to clients | ||
client.send(new Message(0, {}, 'LOGOUT')) | ||
``` | ||
Our use of Redis Pub/Sub relies on a client being authenticated. If you haven't authenticated your client Mesa will not make use of Pub/Sub with this client. Other authenticated clients will have their messages proxied by Pub/Sub | ||
#### Message Sync | ||
Mesa suports message sync, allowing clients that have been disconnected either purposefully or unpurposefully to recieve any messages that couldn't be delivered. | ||
To enable message sync, add the following to your config: | ||
It's your call on how you wish to handle messages, but we recommend using a switch statement based on the type: | ||
```js | ||
const server = new Mesa({ | ||
port: 4000, | ||
// Redis is required for message sync | ||
redis: 'redis://localhost:6379', | ||
client.on('message', message => { | ||
const { data, type } = message | ||
sync: { | ||
enabled: true | ||
}, | ||
authentication: { | ||
// storeConnectedUsers is also required for message sync | ||
storeConnectedUsers: true | ||
switch(type) { | ||
case 'STATUS_UPDATE': | ||
handleStatusUpdate(data.status, client.id) | ||
break | ||
case 'TYPING_STATUS': | ||
setUserTyping(data.typing, client.id) | ||
break | ||
} | ||
}) | ||
``` | ||
```` | ||
Now any time a message is sent to an offline client, either using `Mesa.send` or `Dispatcher.dispatch`, it'll automatically be sent as soon as they connect. | ||
### Guides | ||
We supply a number of guides for fully utilising Mesa server: | ||
*Notes: Authentication via the `client.authenticate` API is required for message sync to work, and if you're using the Dispatcher API, make sure that `sync.enabled` is set to `true` in your Dispatcher config.* | ||
#### Replication | ||
* [Using Pub/Sub](src/docs/server/pubsub.md) | ||
* Replicate messages across multiple Mesa instances | ||
* [Using Namespaces](src/docs/server/namespaces.md) | ||
* Run multiple Mesa instances in different namespaces | ||
Clients will recieve undelivered messages in this format: | ||
```json | ||
{ "op": 0, "d": {}, "t": "EXAMPLE_MESSAGE", "s": 3 } | ||
``` | ||
#### Clients | ||
* [Authenticating Clients](src/docs/server/client/authentication.md) | ||
* Authenticate connecting clients | ||
The `s` property notates the sequence position of the message. This number is used to help clients reconstruct the order undelivered messages were supposed to be recieved in. | ||
#### Messages | ||
* [Redelivering Messages](src/docs/server/message/sync.md) | ||
* Ensure clients recieve missed messages upon reconnection | ||
* [Handling Messages using Portal](src/docs/server/message/portal.md) | ||
* Recieve and handle client messages from anywhere in your codebase | ||
* [Sending Messages using Dispatcher](src/docs/server/message/dispatcher.md) | ||
* Send messages to clients from anywhere in your codebase | ||
*Note: The sequence property begins counting at one instead of zero due to the way JavaScript handles numbers* | ||
#### Middleware | ||
* [Using Middleware](src/docs/server/middleware/using.md) | ||
* Extend your Mesa server with new functionality and minimal effort | ||
* [Creating Middleware](src/docs/server/middleware/creating.md) | ||
* Create powerful middleware handlers to hook into Mesa's event system | ||
##### Implementing a Custom Sync Interval | ||
If you want to implement a custom interval between message redeliveries, use the following configuration on the Mesa server: | ||
```js | ||
sync: { | ||
enabled: true, | ||
redeliveryInterval: 1000 // 1 second | ||
} | ||
``` | ||
##### Disabling Initial Sync for Clients | ||
We also support client configuration for message sync. For example, if a client is connecting to Mesa alongside reaching out to a REST API on its initial state load, the client can opt-out of recieving missed messages using the following API: | ||
```js | ||
client.authenticate({ token: fetchToken() }, { shouldSync: false }) | ||
``` | ||
If you want to only use sync on reconnects, look at the following example: | ||
```js | ||
client.on('connection', async ({ isInitialConnection }) => { | ||
console.log('Connected to Mesa') | ||
// Only sync on connections after first connection or on reconnections | ||
await client.authenticate({ token: fetchToken() }, { shouldSync: !isInitialConnection }) | ||
}) | ||
``` | ||
##### Disabling Sync for Individual Messages | ||
If you want to send a message from the server that isn't synced to clients, set `MessageOptions.sync` to `false` in your Message object options: | ||
```js | ||
client.send(new Message(0, { typing: true }, 'TYPING_UPDATE', { sync: false })) | ||
``` | ||
This is useful when sending messages which can be lost with little to no repercussions for the client application state, such as typing indicator updates | ||
#### Dispatch Events | ||
Dispatch events are server-side events that are used to send messages to Mesa clients throughout a large codebase. Codebases that split their code up between multiple f | ||
iles will find dispatch events particularly useful. | ||
To use dispatch events you'll need a Redis instance and Pub/Sub to be enabled on the core Mesa server. To create a dispatch event, import the `Dispatcher` module from Mesa: | ||
```js | ||
const { Dispatcher } = require('@cryb/mesa') | ||
// or using ES modules | ||
import { Dispatcher } from '@cryb/mesa' | ||
``` | ||
Then create a new Dispatcher instance and pass in your Redis URI or config: | ||
```js | ||
const dispatcher = new Dispatcher('redis://localhost:6379') | ||
``` | ||
We provide expansive configuration support for customising Dispatcher to your needs. Here's a rundown of options we provide: | ||
```ts | ||
{ | ||
// Optional: namespace for Redis events. This should match the namespace on the Mesa server you're targetting if that Mesa server has a namespace | ||
namespace?: string | ||
// Optional | ||
sync?: { | ||
// Enable / disable message sync. Defaults to false | ||
enabled: boolean | ||
} | ||
} | ||
``` | ||
To supply this config, pass it into the Dispatcher constructor as you would with any other configuration: | ||
```js | ||
const dispatcher = new Dispatcher('redis://localhost:6379', { | ||
namespace: 'api', | ||
sync: { | ||
enabled: true | ||
} | ||
}) | ||
``` | ||
Now that a Dispatcher instance has been created, use it to emit events to authenticated clients: | ||
```js | ||
// This sends a message to clients with the id 0 and 1. | ||
dispatcher.dispatch(new Message(0, { status: 'online' }, 'STATUS_UPDATE'), ['0', '1']) | ||
``` | ||
We also allow for a third option that filters out any client ids from the recipients. Here's an example: | ||
```js | ||
room.on('message', (content, author) => { | ||
// Supplying the third array will not send the message to the author of the message. | ||
// This is useful when using Mesa in conjunction with a REST API where state has already been updates for the author | ||
dispatcher.dispatch(new Message(0, { content, author }, 'NEW_MESSAGE'), room.members, [author]) | ||
}) | ||
``` | ||
If you want to dispatch a message to all connected clients, supply a single asterisk in the recipient array: | ||
```js | ||
dispatcher.dispatch(new Message(0, { alert: true, content: 'Hello World' }, 'GLOBAL_ALERT'), ['*']) | ||
``` | ||
*Note: the excluding option passed in as the third element will not work with global dispatch messages* | ||
<!-- We also support dispatch events which are events not sent to clients but are used to handle client connections. | ||
```js | ||
// This disconnects an authenticated client with the id of 0. | ||
dispatcher.dispatch(new DispatchEvent('DISCONNECT_CLIENT'), ['0']) | ||
``` | ||
In the future we'll create an API for creating and handling custom dispatch events. | ||
*Note: in the future we plan to migrate from Dispatcher connecting via Redis Pub/Sub to directly connecting to Mesa. The Dispatcher API is early, so please keep this in mind while writing implementations* --> | ||
#### Portals | ||
Portals allow you to handle messages sent from Mesa clients to a detatched gateway server from anywhere in your codebase. Portals are especially useful in large, replicated environments. | ||
First, enable Portals in your Mesa server config: | ||
```js | ||
const mesa = new Mesa({ | ||
port: 4000, | ||
portal: { | ||
enabled: true | ||
} | ||
}) | ||
``` | ||
We also supply an option called `MesaConfig.portal.distributeLoad`. This option is enabled by default and will send messages to Portal instances in order. Disabling this option will choose a random Portal to handle your message. | ||
There are other configuration options on the `MesaConfig.portal` object. For a full rundown, see [Configuration](#configuration). | ||
To use Portals, you'll need to import the `Portal` module from Mesa: | ||
```js | ||
const { Portal } = require('@cryb/mesa') | ||
// or using ES modules | ||
import { Portal } from '@cryb/mesa' | ||
``` | ||
Then create a new Portal instance and pass in your Redis URI or config: | ||
```js | ||
const portal = new Portal('redis://localhost:6379') | ||
``` | ||
We provide expansive configuration support for customising Portal to your needs. Here's a rundown of options we provide: | ||
```ts | ||
{ | ||
// Optional: namespace for Redis events. This should match the namespace on the Mesa server you're targetting if that Mesa server has a namespace | ||
namespace?: string | ||
// Optional: log Portal-related events on startup and closure. Defaults to false | ||
verbose?: boolean | ||
// Optional: ensure all events are sent to this Portal instance. Defaults to false | ||
reportAllEvents?: boolean | ||
} | ||
``` | ||
To supply this config, pass it into the Portal constructor as you would with any other configuration: | ||
```js | ||
const portal = new Portal('redis://localhost:6379', { | ||
namespace: 'api', | ||
verbose: true, | ||
reportAllEvents: false | ||
}) | ||
``` | ||
*Note: Due to the way Portals and Mesa are designed, Portals will halt your program from stopping to quickly remove itself from the pool of available portals. It will allow your program to exit as normal.* | ||
Once you have your Portal setup, you can listen to new messages using the `EventEmitter` API: | ||
```js | ||
portal.on('connection', () => { | ||
console.log('Client connected') | ||
}) | ||
portal.on('authentication', clientId => { | ||
console.log('Client authenticated with id', clientId) | ||
}) | ||
portal.on('message', message => { | ||
const { opcode, data, type } = message | ||
console.log('Recieved', opcode, data, type) | ||
}) | ||
portal.on('disconnection', clientId => { | ||
if (!clientId) | ||
return console.log('Client disconnected') | ||
console.log('Authenticated client with id', clientId, 'disconnected') | ||
}) | ||
``` | ||
By default, messages sent to Mesa servers are only sent to a single Portal in order to ensure events are not handled in two different places. If you want to capture all events using a Portal, set `reportAllEvents` to `true` in you Portal config. | ||
*Note: Unless you are using `reportAllEvents` or have a single Portal instance running, you should never update application state using Portals. There is no guarentee a single Portal will recieve the same `connection`, `authentication`, `message`, and `disconnection` events for the same client. You should use Portals to forward messages to clients or update an existing database.* | ||
An example of a Chat application handling events using Portals would look something like this: | ||
```js | ||
import { Portal, Dispatcher, Message } from '@cryb/portal' | ||
import { setStatus, fetchRoomByMemberId } from './chat' | ||
const namespace = 'chat' | ||
const redisUri = 'redis://localhost:6379' | ||
const portal = new Portal(redisUri, { namespace }) | ||
const dispatcher = new Dispatcher(redisUri, { namespace }) | ||
// We recommend you update client status on portal.authentication and not portal.connected | ||
portal.on('authentication', clientId => { | ||
setStatus('online', clientId) | ||
}) | ||
portal.on('message', message => { | ||
const { members } = fetchRoomByMemberId(message.clientId) | ||
dispatcher.dispatch(new Message(0, { content: message.content }, 'NEW_MESSAGE'), members, [message.clientId]) | ||
}) | ||
portal.on('disconnection', clientId => { | ||
setStatus('offline', clientId) | ||
}) | ||
``` | ||
### Client Side | ||
We currently provide client libraries for Node-based JavaScript. For a browser-based client library, see [mesa-js-client](https://github.com/neoncloth/mesa-js-client) | ||
*Note: we currently provide client libraries for Node-based JavaScript. For a browser-based client library, see [mesa-js-client](https://github.com/neoncloth/mesa-js-client).* | ||
#### <a name="client-authentication"></a> JavaScript | ||
Import the Client export from the library as you would with any other Node package: | ||
@@ -438,19 +133,6 @@ ```js | ||
``` | ||
*Note: the URL provided needs to be the standard WebSocket connection URI for your Mesa server* | ||
*Note: the URL provided needs to be the standard WebSocket connection URI for your Mesa server.* | ||
We provide expansive configuration support for customising the Mesa client to your needs. Here's a rundown of options we provide: | ||
```ts | ||
{ | ||
// Optional: enable/disable auto connection to the Mesa server once the client object has been instantiated. Enabled by default. Once disabled, use the 'connect()' method in order to connect the client to the Mesa server | ||
autoConnect?: boolean | ||
} | ||
``` | ||
We provide expansive configuration support for customising the Mesa client to your needs. See [Client Configuration](src/docs/client/configuration.md) for options. | ||
To supply this config, pass it into the Client constructor as you would with any other configuration: | ||
```js | ||
const client = new Client('ws://localhost:4000', { | ||
// Options go here | ||
}) | ||
``` | ||
We use the EventEmitter in order to inform the application of events from the Mesa server. Here's an example of a simple client application: | ||
@@ -475,136 +157,29 @@ ```js | ||
##### Authentication | ||
Mesa interacts with the server in order to authenticate the client using a simple API. In order to authenticate with the server, you can use the `Client.authenticate` API. See the following example: | ||
Sending messages to the server works the same way as sending messages to clients from Mesa: | ||
```js | ||
const client = new Client('ws://localhost:4000') | ||
client.on('connection', async () => { | ||
console.log('Client connected') | ||
const user = await client.authenticate({ token: fetchToken() }) | ||
console.log(`Hello ${user.name}!`) | ||
}) | ||
client.send(new Message(0, { status: 'online' }, 'STATUS_UPDATE')) | ||
``` | ||
We allow clients to provide a configuration for authenticating with Mesa, alongside their authorization object. Here's a rundown of options we provide: | ||
```ts | ||
{ | ||
// Optional: specifies if the server should send any missed messages as per the Sync feature. Defaults to true | ||
shouldSync?: boolean | ||
} | ||
``` | ||
Handling messages is identical to how messages are handled on the server, so again it's your choice on how you choose to implement this | ||
### Opcodes | ||
Mesa relies on opcodes to identify different event types. While internal Mesa events uses opcodes 1 to 22, these may be useful to know for building a custom client for example. | ||
### Guides | ||
We supply a number of guides for fully utilising Mesa client: | ||
We recommend that you keep to opcode 0 for sending / recieving events via Mesa to minimalise errors and interference with internal Mesa events | ||
* [Authentication](src/docs/client/authentication.md) | ||
* Authenticate clients against your Mesa server | ||
| **Code** | **Name** | **Client Action** | **Description** | | ||
|----------|--------------------|-------------------|------------------------------------------------------------------------------------------| | ||
| 0 | Dispatch | Send/Receive | Sent by both Mesa and the client to transfer events | | ||
| 1 | Heartbeat | Send/Recieve | Sent by both Mesa and the client for ping checking | | ||
| 2 | Authentication | Send | Sent by the client to authenticate with Mesa | | ||
| 5 | Internal Event | N/A | Sent and recieved by internal server components | | ||
| 10 | Hello | Recieve | Sent by Mesa alongside server information for client setup | | ||
| 11 | Heartbeat ACK | Receive | Sent by Mesa to acknowledge a heartbeat has been received | | ||
| 22 | Authentication ACK | Receive | Sent by Mesa alongside user information to acknowledge the client has been authenticated | | ||
## Extras | ||
* [Chat App](https://chat.mesa.ws) | ||
* chat.mesa.ws is an example chat app using `Mesa.Server` and `mesa-js-client`. [Source Code](https://github.com/neoncloth/mech) | ||
* [Echo Server](https://echo.mesa.ws) | ||
* echo.mesa.ws is an echo server for testing Mesa connections | ||
* [Gateway Server](https://github.com/neoncloth/mega) | ||
* Mega is a gateway server utilising Mesa that can be configured via environment variables | ||
* [Client Libraries](/src/docs/client-libraries.md) | ||
* Our guidance on using available client libraries or creating your own | ||
### Configuration | ||
Mesa's Server component allows for the following configuration to be passed in during initialization: | ||
```ts | ||
{ | ||
// Optional: port that Mesa should listen on. Defaults to 4000 | ||
port: number | ||
// Optional: path that Mesa should listen on. | ||
path: string | ||
// Optional: namespace for Redis events. If you have multiple Mesa instances running on a cluster, you should use this | ||
namespace: string | ||
## License | ||
MIT | ||
// Optional: allow Mesa to use an already established HTTP server for listening | ||
server: http.Server | https.Server | ||
// Optional: support for pub/sub via Redis | ||
redis: Redis.RedisOptions | string | ||
// Optional | ||
client?: { | ||
// Optional: enforce the same Mesa version between server and client. Defaults to false | ||
enforceEqualVersions?: boolean | ||
} | ||
// Optional | ||
options?: { | ||
// Optional: store messages on the client object. This setting applies to both server and client instances. Defaults to false | ||
storeMessages?: boolean | ||
} | ||
// Optional | ||
sync?: { | ||
// Enable / disable message sync. Defaults to false | ||
enabled: boolean | ||
// Optional: the interval in ms of message redeliveries. Defaults to 0ms | ||
redeliveryInterval?: number | ||
} | ||
// Optional | ||
portal?: { | ||
// Enable / disable portals. Defaults to false | ||
enabled: boolean | ||
// Optional: try and distribute messages between Portals as best as possible. Defaults to true | ||
distributeLoad?: boolean | ||
} | ||
// Optional | ||
heartbeat?: { | ||
// Enable / disable heartbeats. Defaults to false | ||
enabled: boolean | ||
// Optional: interval in ms for how often heartbeats should be sent to clients. Defaults to 10000ms | ||
interval?: number | ||
// Optional: how many heartbeats Mesa should send before closing the connection. Defaults to 3 | ||
maxAttempts?: number | ||
} | ||
// Optional | ||
reconnect?: { | ||
// Enable / disconnect reconnects. Defaults to false | ||
enabled: boolean | ||
// Optional: interval in ms for how often a client should try to reconnect once disconnected from a Mesa server. Defaults to 5000ms | ||
interval?: number | ||
} | ||
// Optional | ||
authentication?: { | ||
// Optional: interval in ms for how long a client has to send authentication data before being disconnected from a Mesa server. Defaults to 10000ms | ||
timeout?: number | ||
// Optional: messages sent using Mesa.send will not be sent to unauthenticated clients. Defaults to false | ||
required?: boolean | ||
// Optional: send the user object to the client once authentication is complete. Defaults to true | ||
sendUserObject?: boolean | ||
// Optional: disconnect the user if authentication failed. Defaults to true | ||
disconnectOnFail?: boolean | ||
// Optional: store the IDs of connected users in a Redis set called connected_clients. Defaults to true | ||
storeConnectedUsers?: boolean | ||
} | ||
} | ||
``` | ||
## Client Libraries | ||
While we have an official Client implementation for Node.js in this library, we do offer client libraries for other languages. Here's a list of official or community maintained client libraries: | ||
* [mesa-js-client](https://github.com/neoncloth/mesa-js-client) for browser-based JavaScript. Maintained by William from Cryb | ||
* [mesa-react-native](https://github.com/seshchat/mesa-react-native) for React Native. Maintained by the SESH team | ||
### Creating a Client Library | ||
We'd love for the community to create implementations of the Mesa client. Make sure to title the library in the style of `mesa-lang-client`. For example, a Go library would take the name of `mesa-go-client`. | ||
In the future we'll publish a specification so it's easier to understand how the client interacts with a Mesa server, but for now please look over [`client.ts`](https://github.com/crybapp/mesa/blob/master/src/client/index.ts). If you do create a client library, please let us know [on our Discord](https://discord.gg/ShTATH4) | ||
### Future Libraries | ||
We'd love to see client implementations of Mesa in the all languages, but these are the languages we have our eye on—ordered by priority: | ||
* [Swift](https://swift.org) | ||
* [Go](https://golang.org) | ||
## Questions / Issues | ||
If you have an issues with `@cryb/mesa`, please either open a GitHub issue, contact a maintainer or join the [Cryb Discord Server](https://discord.gg/ShTATH4) and ask in #tech-support |
@@ -76,3 +76,3 @@ "use strict"; | ||
this.heartbeatAttempts = 0; | ||
this.send(new message_1.default(1, {})); | ||
this.send(new message_1.default(1, {}), true); | ||
} | ||
@@ -83,3 +83,3 @@ else { | ||
return this.disconnect(); | ||
this.send(new message_1.default(1, { tries: this.heartbeatAttempts, max: this.heartbeatMaxAttempts })); | ||
this.send(new message_1.default(1, { tries: this.heartbeatAttempts, max: this.heartbeatMaxAttempts }), true); | ||
} | ||
@@ -109,2 +109,3 @@ this.heartbeatCount += 1; | ||
this.server.sendPortalableMessage(message, this); | ||
this.server.handleMiddlewareEvent('onMessageRecieved', message, this); | ||
if (this.server.serverOptions.storeMessages) | ||
@@ -133,5 +134,6 @@ this.messages.recieved.push(message); | ||
if (!this.authenticated) | ||
this.send(new message_1.default(22, this.server.authenticationConfig.sendUserObject ? user : {})); | ||
this.send(new message_1.default(22, this.server.authenticationConfig.sendUserObject ? user : {}), true); | ||
this.authenticated = true; | ||
this.server.registerAuthentication(this); | ||
this.server.handleMiddlewareEvent('onAuthenticated', this); | ||
} | ||
@@ -145,2 +147,3 @@ registerDisconnection(code, reason) { | ||
this.server.emit('disconnection', code, reason); | ||
this.server.handleMiddlewareEvent('onDisconnection', this, code, reason); | ||
this.server.registerDisconnection(this); | ||
@@ -172,2 +175,3 @@ } | ||
}, messageRedeliveryInterval || 0); | ||
this.server.handleMiddlewareEvent('onRedeliverUndeliverableMessages', messages, this); | ||
this.clearUndeliveredMessages(); | ||
@@ -174,0 +178,0 @@ } |
@@ -9,2 +9,3 @@ /// <reference types="node" /> | ||
import Message from './message'; | ||
import Middleware, { MiddlewareEvent } from '../middleware/defs'; | ||
export declare type RedisConfig = string | Redis.RedisOptions; | ||
@@ -78,12 +79,20 @@ export interface IClientConfig { | ||
private portalIndex; | ||
private middlewareHandlers; | ||
constructor(config?: IServerConfig); | ||
send(message: Message, _recipients?: string[], excluding?: string[]): Promise<number | void>; | ||
send(message: Message, _recipients?: string[], excluding?: string[]): Promise<void>; | ||
private _send; | ||
private _sendPubSub; | ||
private authenticatedClients; | ||
private get authenticatedClientIds(); | ||
use(middleware: Middleware): void; | ||
handleMiddlewareEvent(type: MiddlewareEvent, ...args: any[]): Promise<void>; | ||
registerAuthentication(client: Client): void; | ||
get hasMiddleware(): boolean; | ||
registerDisconnection(disconnectingClient: Client): void; | ||
close(): void; | ||
sendPortalableMessage(_message: Message, client: Client): void; | ||
pubSubNamespace(): string; | ||
get pubSubNamespace(): string; | ||
private setupCloseHandler; | ||
private setup; | ||
private parseConfig; | ||
private _send; | ||
private setupRedis; | ||
@@ -98,5 +107,9 @@ private sendInternalPortalMessage; | ||
private getNamespace; | ||
private portalPubSubNamespace; | ||
private availablePortalsNamespace; | ||
getMiddlewareNamespace(prefix: string, name: string): string; | ||
mapMiddlewareNamespace(prefixes: string[], name: string): string[]; | ||
private get portalPubSubNamespace(); | ||
private get availablePortalsNamespace(); | ||
private get connectedClientsNamespace(); | ||
private get connectedClientsCountNamespace(); | ||
} | ||
export default Server; |
@@ -6,2 +6,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
const death_1 = __importDefault(require("death")); | ||
const events_1 = require("events"); | ||
@@ -20,2 +21,3 @@ const ws_1 = __importDefault(require("ws")); | ||
this.portalIndex = 0; | ||
this.middlewareHandlers = []; | ||
config = this.parseConfig(config); | ||
@@ -25,30 +27,55 @@ this.setup(config); | ||
async send(message, _recipients, excluding) { | ||
// Remove excluded recipients from the _recipients array | ||
if (_recipients && excluding) | ||
_recipients = _recipients.filter(recipient => excluding.indexOf(recipient) === -1); | ||
if (!this.redis && !_recipients) | ||
return this._send(message, this.clients); | ||
if (this.redis && _recipients && this.syncConfig.enabled) { | ||
const namespace = this.getNamespace('connected_clients'); | ||
const onlineRecipients = []; | ||
const offlineRecipients = []; | ||
if (_recipients && this.syncConfig.enabled) | ||
for (let i = 0; i < _recipients.length; i++) { | ||
const recipient = _recipients[i]; | ||
const isRecipientConnected = (await this.redis.sismember(namespace, _recipients[i])) === 1; | ||
(isRecipientConnected ? onlineRecipients : offlineRecipients).push(recipient); | ||
// Global message | ||
if (!_recipients) { | ||
if (this.redis) | ||
this._sendPubSub(message, ['*']); | ||
else | ||
this._send(message, this.clients); | ||
} | ||
if (this.redis) { | ||
function isIdOnReplica(id) { | ||
return _recipients.indexOf(id) > -1; | ||
} | ||
const idsOnReplica = this.authenticatedClientIds.filter(isIdOnReplica); | ||
let idsOnCluster = this.authenticatedClientIds.filter(id => !isIdOnReplica(id)); | ||
// If sync is enabled | ||
if (this.syncConfig.enabled) { | ||
// Get the namespace of the connected clients | ||
const namespace = this.getNamespace('connected_clients'); | ||
// Create empty recipients lists | ||
const onlineRecipients = []; | ||
const offlineRecipients = []; | ||
// For each recipients get if the client is online or not | ||
const pipeline = this.redis.pipeline(); | ||
for (let i = 0; i < _recipients.length; i++) | ||
pipeline.sismember(namespace, _recipients[i]); | ||
// Execute the pipeline and add the client id to the online or offline recipients list | ||
const rawMembers = await pipeline.exec(); | ||
for (let i = 0; i < rawMembers.length; i++) { | ||
const [err, online] = rawMembers[i]; | ||
if (err) | ||
continue; | ||
if (online) | ||
onlineRecipients.push(_recipients[i]); | ||
else | ||
offlineRecipients.push(_recipients[i]); | ||
} | ||
if (onlineRecipients.length > 0) | ||
this.publisher.publish(this.pubSubNamespace(), JSON.stringify({ | ||
message: message.serialize(true, { sentByServer: true, sentInternally: true }), | ||
recipients: onlineRecipients | ||
})); | ||
if (offlineRecipients.length > 0) | ||
offlineRecipients.forEach(recipient => this.handleUndeliverableMessage(message, recipient)); | ||
return; | ||
// If there are some offline recipients then handle the undeliverable messages | ||
if (offlineRecipients.length > 0) { | ||
offlineRecipients.forEach(recipient => this.handleUndeliverableMessage(message, recipient)); | ||
this.handleMiddlewareEvent('onUndeliverableMessageSent', message, offlineRecipients); | ||
} | ||
// Remove any offline recipients from the idsOnCluster list | ||
// Note that we don't do this for the idsOnReplica list as those ids are checked from the online membrs on this replica | ||
idsOnCluster = idsOnCluster.filter(id => offlineRecipients.indexOf(id) === -1); | ||
} | ||
const clientsOnReplica = this.authenticatedClients(idsOnReplica); | ||
if (clientsOnReplica.length > 0) | ||
this._send(message, clientsOnReplica); | ||
if (idsOnCluster.length > 0) | ||
this._sendPubSub(message, idsOnCluster); | ||
} | ||
if (this.redis) | ||
return this.publisher.publish(this.pubSubNamespace(), JSON.stringify({ | ||
message: message.serialize(true, { sentByServer: true, sentInternally: true }), | ||
recipients: _recipients || ['*'] | ||
})); | ||
else { | ||
@@ -59,2 +86,43 @@ const recipients = this.clients.filter(({ id }) => _recipients.indexOf(id) > -1); | ||
} | ||
_send(message, recipients) { | ||
// Authentication.required rule | ||
if (this.authenticationConfig.required) | ||
recipients = recipients.filter(({ authenticated }) => !!authenticated); | ||
// Don't send if no recipients | ||
if (recipients.length === 0) | ||
return; | ||
recipients.forEach(recipient => recipient.send(message, true)); | ||
this.handleMiddlewareEvent('onMessageSent', message, recipients, true); | ||
} | ||
_sendPubSub(message, recipientIds) { | ||
const internalMessage = { | ||
message: message.serialize(true, { sentByServer: true, sentInternally: true }), | ||
recipients: recipientIds || ['*'] | ||
}; | ||
this.publisher.publish(this.pubSubNamespace, JSON.stringify(internalMessage)); | ||
} | ||
authenticatedClients(ids) { | ||
return this.clients.filter(client => client.authenticated).filter(client => ids.indexOf(client.id) > -1); | ||
} | ||
get authenticatedClientIds() { | ||
return this.clients.filter(client => client.authenticated).map(client => client.id); | ||
} | ||
use(middleware) { | ||
const configured = middleware(this); | ||
this.middlewareHandlers.push(configured); | ||
} | ||
async handleMiddlewareEvent(type, ...args) { | ||
if (!this.hasMiddleware) | ||
return; | ||
for (let i = 0; i < this.middlewareHandlers.length; i++) { | ||
const handler = this.middlewareHandlers[i]; | ||
const eventHandler = handler[type]; | ||
if (!eventHandler) | ||
continue; | ||
try { | ||
await eventHandler(...args); | ||
} | ||
catch (error) { } | ||
} | ||
} | ||
registerAuthentication(client) { | ||
@@ -66,2 +134,5 @@ this.sendInternalPortalMessage({ | ||
} | ||
get hasMiddleware() { | ||
return this.middlewareHandlers.length > 0; | ||
} | ||
registerDisconnection(disconnectingClient) { | ||
@@ -74,2 +145,4 @@ const clientIndex = this.clients.findIndex(client => client.serverId === disconnectingClient.serverId); | ||
}); | ||
if (this.redis) | ||
this.redis.decr(this.connectedClientsCountNamespace); | ||
} | ||
@@ -88,5 +161,17 @@ close() { | ||
} | ||
pubSubNamespace() { | ||
get pubSubNamespace() { | ||
return this.getNamespace('ws'); | ||
} | ||
setupCloseHandler() { | ||
death_1.default(async (signal) => { | ||
if (this.redis && this.clients.length > 0) { | ||
await this.redis.decrby(this.connectedClientsCountNamespace, this.clients.length); | ||
if (this.authenticationConfig.storeConnectedUsers) { | ||
const idsOnReplica = this.authenticatedClientIds; | ||
await this.redis.srem(this.connectedClientsNamespace, ...idsOnReplica); | ||
} | ||
} | ||
process.exit(signal); | ||
}); | ||
} | ||
setup(config) { | ||
@@ -104,2 +189,3 @@ if (this.wss) | ||
this.wss.on('connection', (socket, req) => this.registerConnection(socket, req)); | ||
this.setupCloseHandler(); | ||
} | ||
@@ -128,11 +214,2 @@ parseConfig(_config) { | ||
} | ||
_send(message, recipients) { | ||
// Authentication.required rule | ||
if (this.authenticationConfig.required) | ||
recipients = recipients.filter(({ authenticated }) => !!authenticated); | ||
// Don't send if no recipients | ||
if (recipients.length === 0) | ||
return; | ||
recipients.forEach(recipient => recipient.send(message, true)); | ||
} | ||
// Setup | ||
@@ -147,4 +224,4 @@ setupRedis(redisConfig) { | ||
this.loadInitialState(); | ||
const pubSubNamespace = this.pubSubNamespace(); | ||
const availablePortalsNamespace = this.availablePortalsNamespace(); | ||
const pubSubNamespace = this.pubSubNamespace; | ||
const availablePortalsNamespace = this.availablePortalsNamespace; | ||
subscriber.on('message', async (channel, data) => { | ||
@@ -185,7 +262,7 @@ let json; | ||
chosenPortal = this.portals[Math.floor(Math.random() * this.portals.length)]; | ||
this.publisher.publish(this.portalPubSubNamespace(), JSON.stringify(Object.assign(Object.assign({}, internalMessage), { portalId: chosenPortal }))); | ||
this.publisher.publish(this.portalPubSubNamespace, JSON.stringify(Object.assign(Object.assign({}, internalMessage), { portalId: chosenPortal }))); | ||
} | ||
// State Management | ||
async loadInitialState() { | ||
this.portals = await this.redis.smembers(this.availablePortalsNamespace()); | ||
this.portals = await this.redis.smembers(this.availablePortalsNamespace); | ||
} | ||
@@ -210,2 +287,5 @@ handlePortalUpdate(update) { | ||
}); | ||
this.handleMiddlewareEvent('onConnection', this); | ||
if (this.redis) | ||
this.redis.incr(this.connectedClientsCountNamespace); | ||
} | ||
@@ -220,3 +300,6 @@ handleInternalMessage(internalMessage) { | ||
recipients = this.clients.filter(client => _recipients.indexOf(client.id) > -1); | ||
if (recipients.length === 0) | ||
return; | ||
recipients.forEach(client => client.send(message, true)); | ||
this.handleMiddlewareEvent('onMessageSent', message, recipients, false); | ||
} | ||
@@ -243,9 +326,22 @@ async handleUndeliverableMessage(message, recipient) { | ||
} | ||
portalPubSubNamespace() { | ||
getMiddlewareNamespace(prefix, name) { | ||
const key = `${prefix}_mw-${name}`; | ||
return this.namespace ? `${key}_${this.namespace}` : key; | ||
} | ||
mapMiddlewareNamespace(prefixes, name) { | ||
return prefixes.map(prefix => this.getMiddlewareNamespace(prefix, name)); | ||
} | ||
get portalPubSubNamespace() { | ||
return this.getNamespace('portal'); | ||
} | ||
availablePortalsNamespace() { | ||
get availablePortalsNamespace() { | ||
return this.getNamespace('available_portals_pool'); | ||
} | ||
get connectedClientsNamespace() { | ||
return this.getNamespace('connected_clients'); | ||
} | ||
get connectedClientsCountNamespace() { | ||
return this.getNamespace('connected_clients_count'); | ||
} | ||
} | ||
exports.default = Server; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.parseRules = exports.parseConfig = void 0; | ||
exports.parseConfig = (_config, keys, values) => { | ||
@@ -4,0 +5,0 @@ const config = Object.assign({}, _config); |
@@ -6,2 +6,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.getVersion = void 0; | ||
const fs_1 = __importDefault(require("fs")); | ||
@@ -8,0 +9,0 @@ function getVersion() { |
@@ -6,2 +6,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.createRedisClient = void 0; | ||
const ioredis_1 = __importDefault(require("ioredis")); | ||
@@ -8,0 +9,0 @@ exports.createRedisClient = (config) => { |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.generateId = void 0; | ||
function generateId() { | ||
@@ -4,0 +5,0 @@ return Math.random().toString(36).substring(2) + Date.now().toString(36); |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.handleUndeliveredMessage = void 0; | ||
exports.handleUndeliveredMessage = async (message, recipient, client, namespace) => { | ||
@@ -4,0 +5,0 @@ if (message.options && !message.options.sync) |
90622
47
1427
181