diff --git a/.gitignore b/.gitignore index c2658d7..b8ffe08 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ node_modules/ +package-lock.json + diff --git a/LICENSE b/LICENSE index a5848a5..ca250f2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ (The MIT License) -Copyright (c) 2013-2018 SocketCluster.io +Copyright (c) 2013-2023 SocketCluster.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md index af87ff3..d75d075 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,97 @@ -# socketcluster-server -Minimal server module for SocketCluster +# SocketCluster server +Minimal server module for SocketCluster. -This is a stand-alone server module for SocketCluster. This module offers the most flexibility when creating a SocketCluster service but requires the most work to setup. -The repository for the full-featured framework is here: https://github.com/SocketCluster/socketcluster +This is a stand-alone server module for SocketCluster. +SocketCluster's protocol is backwards compatible with the SocketCluster protocol. ## Setting up -You will need to install ```socketcluster-server``` and ```socketcluster-client``` (https://github.com/SocketCluster/socketcluster-client) separately. +You will need to install both ```socketcluster-server``` and ```socketcluster-client``` (https://github.com/SocketCluster/socketcluster-client). To install this module: -```npm install socketcluster-server``` +```bash +npm install socketcluster-server +``` -## Using with basic http(s) module (example) +## Usage You need to attach it to an existing Node.js http or https server (example): ```js -var http = require('http'); -var socketClusterServer = require('socketcluster-server'); - -var httpServer = http.createServer(); -var scServer = socketClusterServer.attach(httpServer); - -scServer.on('connection', function (socket) { - // ... Handle new socket connections here -}); +const http = require('http'); +const socketClusterServer = require('socketcluster-server'); + +let httpServer = http.createServer(); +let agServer = socketClusterServer.attach(httpServer); + +(async () => { + // Handle new inbound sockets. + for await (let {socket} of agServer.listener('connection')) { + + (async () => { + // Set up a loop to handle and respond to RPCs for a procedure. + for await (let req of socket.procedure('customProc')) { + if (req.data.bad) { + let error = new Error('Server failed to execute the procedure'); + error.name = 'BadCustomError'; + req.error(error); + } else { + req.end('Success'); + } + } + })(); + + (async () => { + // Set up a loop to handle remote transmitted events. + for await (let data of socket.receiver('customRemoteEvent')) { + // ... + } + })(); + + } +})(); httpServer.listen(8000); ``` -## Using with Express (example) +For more detailed examples of how to use SocketCluster, see `test/integration.js`. +Also, see tests from the `socketcluster-client` module. -```js -var http = require('http'); -var socketClusterServer = require('socketcluster-server'); -var serveStatic = require('serve-static'); -var path = require('path'); -var app = require('express')(); +SocketCluster can work without the `for-await-of` loop; a `while` loop with `await` statements can be used instead. +See https://github.com/SocketCluster/stream-demux#usage -app.use(serveStatic(path.resolve(__dirname, 'public'))); +## Compatibility mode -var httpServer = http.createServer(); +For compatibility with existing SocketCluster clients, set the `protocolVersion` to `1` and make sure that the `path` matches your old client path: -// Attach express to our httpServer -httpServer.on('request', app); +```js +let agServer = socketClusterServer.attach(httpServer, { + protocolVersion: 1, + path: '/socketcluster/' +}); +``` -// Attach socketcluster-server to our httpServer -var scServer = socketClusterServer.attach(httpServer); +## Running the tests -scServer.on('connection', function (socket) { - // ... Handle new socket connections here -}); +- Clone this repo: `git clone git@github.com:SocketCluster/socketcluster-server.git` +- Navigate to project directory: `cd socketcluster-server` +- Install all dependencies: `npm install` +- Run the tests: `npm test` -httpServer.listen(8000); -``` +## Benefits of async `Iterable` over `EventEmitter` + +- **More readable**: Code is written sequentially from top to bottom. It avoids event handler callback hell. It's also much easier to write and read complex integration test scenarios. +- **More succinct**: Event streams can be easily chained, filtered and combined using a declarative syntax (e.g. using async generators). +- **More manageable**: No need to remember to unbind listeners with `removeListener(...)`; just `break` out of the `for-await-of` loop to stop consuming. This also encourages a more declarative style of coding which reduces the likelihood of memory leaks and unintended side effects. +- **Less error-prone**: Each event/RPC/message can be processed sequentially in the same order that they were sent without missing any data; even if asynchronous calls are made inside middleware or listeners. On the other hand, with `EventEmitter`, the listener function for the same event cannot be prevented from running multiple times in parallel; also, asynchronous calls within middleware and listeners can affect the final order of actions; all this can cause unintended side effects. + +## License -Note that the full SocketCluster framework (https://github.com/SocketCluster/socketcluster) uses this module behind the scenes so the API is exactly the same and it works with the socketcluster-client out of the box. -The main difference with using socketcluster-server is that you won't get features like: +(The MIT License) -- Automatic scalability across multiple CPU cores. -- Resilience; you are responsible for respawning the process if it crashes. -- Convenience; It requires more work up front to get working (not good for beginners). -- Pub/sub channels won't scale across multiple socketcluster-server processes/hosts by default.\* +Copyright (c) 2013-2023 SocketCluster.io -\* Note that the ```socketClusterServer.attach(httpServer, options);``` takes an optional options argument which can have a ```brokerEngine``` property - By default, socketcluster-server -uses ```sc-simple-broker``` which is a basic single-process in-memory broker. If you want to add your own brokerEngine (for example to scale your socketcluster-servers across multiple cores/hosts), then you might want to look at how sc-simple-broker was implemented. +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The full SocketCluster framework uses a different broker engine: ```sc-broker-cluster```(https://github.com/SocketCluster/sc-broker-cluster) - This is a more complex brokerEngine - It allows messages to be brokered between -multiple processes and can be synchronized with remote hosts too so you can get both horizontal and vertical scalability. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -The main benefit of this module is that it gives you maximum flexibility. You just need to attach it to a Node.js http server so you can use it alongside pretty much any framework. +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/action.js b/action.js new file mode 100644 index 0000000..1d4d3d7 --- /dev/null +++ b/action.js @@ -0,0 +1,40 @@ +const scErrors = require('sc-errors'); +const InvalidActionError = scErrors.InvalidActionError; + +function AGAction() { + this.outcome = null; + this.promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + + this.allow = (packet) => { + if (this.outcome) { + throw new InvalidActionError(`AGAction ${this.type} has already been ${this.outcome}; cannot allow`); + } + this.outcome = 'allowed'; + this._resolve(packet); + }; + + this.block = (error) => { + if (this.outcome) { + throw new InvalidActionError(`AGAction ${this.type} has already been ${this.outcome}; cannot block`); + } + this.outcome = 'blocked'; + this._reject(error); + }; +} + +AGAction.prototype.HANDSHAKE_WS = AGAction.HANDSHAKE_WS = 'handshakeWS'; +AGAction.prototype.HANDSHAKE_SC = AGAction.HANDSHAKE_SC = 'handshakeSC'; + +AGAction.prototype.MESSAGE = AGAction.MESSAGE = 'message'; + +AGAction.prototype.TRANSMIT = AGAction.TRANSMIT = 'transmit'; +AGAction.prototype.INVOKE = AGAction.INVOKE = 'invoke'; +AGAction.prototype.SUBSCRIBE = AGAction.SUBSCRIBE = 'subscribe'; +AGAction.prototype.PUBLISH_IN = AGAction.PUBLISH_IN = 'publishIn'; +AGAction.prototype.PUBLISH_OUT = AGAction.PUBLISH_OUT = 'publishOut'; +AGAction.prototype.AUTHENTICATE = AGAction.AUTHENTICATE = 'authenticate'; + +module.exports = AGAction; diff --git a/index.js b/index.js index 13188db..e230110 100644 --- a/index.js +++ b/index.js @@ -5,20 +5,28 @@ const http = require('http'); /** - * Expose SCServer constructor. + * Expose AGServer constructor. * * @api public */ -module.exports.SCServer = require('./scserver'); +module.exports.AGServer = require('./server'); /** - * Expose SCServerSocket constructor. + * Expose AGServerSocket constructor. * * @api public */ -module.exports.SCServerSocket = require('./scserversocket'); +module.exports.AGServerSocket = require('./serversocket'); + +/** + * Expose AGRequest constructor. + * + * @api public + */ + +module.exports.AGRequest = require('ag-request'); /** * Creates an http.Server exclusively used for WS upgrades. @@ -26,7 +34,7 @@ module.exports.SCServerSocket = require('./scserversocket'); * @param {Number} port * @param {Function} callback * @param {Object} options - * @return {SCServer} websocket cluster server + * @return {AGServer} websocket cluster server * @api public */ @@ -36,7 +44,7 @@ module.exports.listen = function (port, options, fn) { options = {}; } - let server = http.createServer(function (req, res) { + let server = http.createServer((req, res) => { res.writeHead(501); res.end('Not Implemented'); }); @@ -53,7 +61,7 @@ module.exports.listen = function (port, options, fn) { * * @param {http.Server} server * @param {Object} options - * @return {SCServer} websocket cluster server + * @return {AGServer} websocket cluster server * @api public */ @@ -62,6 +70,6 @@ module.exports.attach = function (server, options) { options = {}; } options.httpServer = server; - let socketClusterServer = new module.exports.SCServer(options); + let socketClusterServer = new module.exports.AGServer(options); return socketClusterServer; }; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 5add926..0000000 --- a/package-lock.json +++ /dev/null @@ -1,427 +0,0 @@ -{ - "name": "socketcluster-server", - "version": "14.3.1", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "async": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/async/-/async-2.3.0.tgz", - "integrity": "sha1-EBPRBRBH3TIP4k5JTVxm7K9hR9k=", - "requires": { - "lodash": "4.17.5" - } - }, - "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true - }, - "base-64": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", - "integrity": "sha1-eAqZyE59YAJgNhURxId2E78k9rs=", - "dev": true - }, - "base64id": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", - "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=" - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "1.0.0", - "concat-map": "0.0.1" - } - }, - "browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" - }, - "clone": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.1.tgz", - "integrity": "sha1-0hfR6WERjjrJpLi7oyhVU79kfNs=", - "dev": true - }, - "commander": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", - "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", - "dev": true - }, - "component-emitter": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", - "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - }, - "dependencies": { - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", - "dev": true - }, - "ecdsa-sig-formatter": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz", - "integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=", - "requires": { - "safe-buffer": "5.1.2" - } - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", - "dev": true, - "requires": { - "fs.realpath": "1.0.0", - "inflight": "1.0.6", - "inherits": "2.0.3", - "minimatch": "3.0.4", - "once": "1.4.0", - "path-is-absolute": "1.0.1" - } - }, - "growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "he": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", - "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "1.4.0", - "wrappy": "1.0.2" - } - }, - "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true - }, - "jsonwebtoken": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz", - "integrity": "sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag==", - "requires": { - "jws": "3.1.5", - "lodash.includes": "4.3.0", - "lodash.isboolean": "3.0.3", - "lodash.isinteger": "4.0.4", - "lodash.isnumber": "3.0.3", - "lodash.isplainobject": "4.0.6", - "lodash.isstring": "4.0.1", - "lodash.once": "4.1.1", - "ms": "2.1.1" - } - }, - "jwa": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz", - "integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==", - "requires": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.10", - "safe-buffer": "5.1.2" - } - }, - "jws": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz", - "integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==", - "requires": { - "jwa": "1.1.6", - "safe-buffer": "5.1.2" - } - }, - "linked-list": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/linked-list/-/linked-list-0.1.0.tgz", - "integrity": "sha1-eYsP+X0bkqT9CEgPVa6k6dSdN78=", - "dev": true - }, - "localStorage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/localStorage/-/localStorage-1.0.3.tgz", - "integrity": "sha1-5riaV7t2ChVqOMyH4PJVD27UE9g=", - "dev": true - }, - "lodash": { - "version": "4.17.5", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.5.tgz", - "integrity": "sha512-svL3uiZf1RwhH+cWrfZn3A4+U58wbP0tGVTLQPbjplZxZ8ROD9VLuNgsRniTlLe7OlSqR79RUehXgpBW/s0IQw==" - }, - "lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" - }, - "lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" - }, - "lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" - }, - "lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" - }, - "lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" - }, - "lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" - }, - "lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" - }, - "lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "requires": { - "brace-expansion": "1.1.11" - } - }, - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true - }, - "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", - "dev": true, - "requires": { - "minimist": "0.0.8" - } - }, - "mocha": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", - "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", - "dev": true, - "requires": { - "browser-stdout": "1.3.1", - "commander": "2.15.1", - "debug": "3.1.0", - "diff": "3.5.0", - "escape-string-regexp": "1.0.5", - "glob": "7.1.2", - "growl": "1.10.5", - "he": "1.1.1", - "minimatch": "3.0.4", - "mkdirp": "0.5.1", - "supports-color": "5.4.0" - } - }, - "ms": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1.0.2" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", - "dev": true - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "sc-auth": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/sc-auth/-/sc-auth-5.0.2.tgz", - "integrity": "sha512-Le3YBsFjzv5g6wIH6Y+vD+KFkK0HDXiaWy1Gm4nXtYebMQUyNYSf1cS83MtHrYzVEMlhYElRva1b0bvZ0hBqQw==", - "requires": { - "jsonwebtoken": "8.3.0", - "sc-errors": "1.4.1" - } - }, - "sc-channel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/sc-channel/-/sc-channel-1.2.0.tgz", - "integrity": "sha512-M3gdq8PlKg0zWJSisWqAsMmTVxYRTpVRqw4CWAdKBgAfVKumFcTjoCV0hYu7lgUXccCtCD8Wk9VkkE+IXCxmZA==", - "requires": { - "component-emitter": "1.2.1" - } - }, - "sc-errors": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sc-errors/-/sc-errors-1.4.1.tgz", - "integrity": "sha512-dBn92iIonpChTxYLgKkIT/PCApvmYT6EPIbRvbQKTgY6tbEbIy8XVUv4pGyKwEK4nCmvX4TKXcN0iXC6tNW6rQ==" - }, - "sc-formatter": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sc-formatter/-/sc-formatter-3.0.2.tgz", - "integrity": "sha512-9PbqYBpCq+OoEeRQ3QfFIGE6qwjjBcd2j7UjgDlhnZbtSnuGgHdcRklPKYGuYFH82V/dwd+AIpu8XvA1zqTd+A==" - }, - "sc-simple-broker": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/sc-simple-broker/-/sc-simple-broker-2.1.3.tgz", - "integrity": "sha512-ldt0ybOS5fVZSMea5Z8qVu7lmDBTy0qO9BD6TseJjRuPx+g+stfSqmPAb0RsCsQUXRH8A1koCbwsuUnI9BOxvw==", - "requires": { - "sc-channel": "1.2.0" - } - }, - "sc-uws": { - "version": "10.148.2", - "resolved": "https://registry.npmjs.org/sc-uws/-/sc-uws-10.148.2.tgz", - "integrity": "sha512-wGXiwsORev5O3OOewsAYi1WVyMeNFMQ4bw/Qg/6g0C0J9vsEs8xnxf19hovAAQrOq6sMVrcxCNa2k1rBiDsDzw==" - }, - "socketcluster-client": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/socketcluster-client/-/socketcluster-client-14.2.0.tgz", - "integrity": "sha512-hibnSjupT+1JZlN608bxGwwaV7wqw36iBCSc0oXj7D+mv6DOR86+tSHWCPBaHWFkZK8ORW7v2BWSFuHSAzQzrw==", - "dev": true, - "requires": { - "base-64": "0.1.0", - "clone": "2.1.1", - "component-emitter": "1.2.1", - "linked-list": "0.1.0", - "querystring": "0.2.0", - "sc-channel": "1.2.0", - "sc-errors": "1.4.1", - "sc-formatter": "3.0.2", - "uuid": "3.2.1", - "ws": "5.1.1" - } - }, - "supports-color": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", - "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", - "dev": true, - "requires": { - "has-flag": "3.0.0" - } - }, - "uuid": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", - "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==" - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "ws": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-5.1.1.tgz", - "integrity": "sha512-bOusvpCb09TOBLbpMKszd45WKC2KPtxiyiHanv+H2DE3Az+1db5a/L7sVJZVDPUC1Br8f0SKRr1KjLpD1U/IAw==", - "requires": { - "async-limiter": "1.0.0" - }, - "dependencies": { - "async-limiter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", - "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" - } - } - } - } -} diff --git a/package.json b/package.json index c9a1426..8c86ae4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socketcluster-server", - "version": "14.3.1", + "version": "19.2.0", "description": "Server module for SocketCluster", "main": "index.js", "repository": { @@ -8,25 +8,25 @@ "url": "git://github.com/SocketCluster/socketcluster-server.git" }, "dependencies": { - "async": "2.6.1", - "async-stream-emitter": "^1.1.0", - "base64id": "1.0.0", - "lodash.clonedeep": "4.5.0", - "sc-auth": "^6.0.0", - "sc-errors": "^2.0.0", - "sc-formatter": "^3.0.2", - "sc-simple-broker": "^3.0.0", - "stream-demux": "^4.0.4", - "uuid": "3.2.1", - "ws": "6.1.2" + "ag-auth": "^2.1.0", + "ag-request": "^1.1.0", + "ag-simple-broker": "^6.0.1", + "async-stream-emitter": "^7.0.1", + "base64id": "^2.0.0", + "clone-deep": "^4.0.1", + "sc-errors": "^3.0.0", + "sc-formatter": "^4.0.0", + "stream-demux": "^10.0.1", + "writable-consumable-stream": "^4.1.0", + "ws": "^8.18.0" }, "devDependencies": { "localStorage": "^1.0.3", - "mocha": "5.2.0", - "socketcluster-client": "^14.2.0" + "mocha": "^10.2.0", + "socketcluster-client": "*" }, "scripts": { - "test": "mocha --reporter spec --timeout 3000 --slow 3000" + "test": "mocha --reporter spec --timeout 10000 --slow 10000" }, "keywords": [ "websocket", diff --git a/response.js b/response.js deleted file mode 100644 index 0e20a13..0000000 --- a/response.js +++ /dev/null @@ -1,55 +0,0 @@ -const scErrors = require('sc-errors'); -const InvalidActionError = scErrors.InvalidActionError; - -function Response(socket, id) { - this.socket = socket; - this.id = id; - this.sent = false; -} - -Response.prototype._respond = function (responseData, options) { - if (this.sent) { - throw new InvalidActionError(`Response ${this.id} has already been sent`); - } else { - this.sent = true; - this.socket.sendObject(responseData, options); - } -}; - -Response.prototype.end = function (data, options) { - if (this.id) { - let responseData = { - rid: this.id - }; - if (data !== undefined) { - responseData.data = data; - } - this._respond(responseData, options); - } -}; - -Response.prototype.error = function (error, data, options) { - if (this.id) { - let err = scErrors.dehydrateError(error); - - let responseData = { - rid: this.id, - error: err - }; - if (data !== undefined) { - responseData.data = data; - } - - this._respond(responseData, options); - } -}; - -Response.prototype.callback = function (error, data, options) { - if (error) { - this.error(error, data, options); - } else { - this.end(data, options); - } -}; - -module.exports.Response = Response; diff --git a/scserver.js b/scserver.js deleted file mode 100644 index b63d801..0000000 --- a/scserver.js +++ /dev/null @@ -1,1148 +0,0 @@ -const SCServerSocket = require('./scserversocket'); -const AuthEngine = require('sc-auth').AuthEngine; -const formatter = require('sc-formatter'); -const base64id = require('base64id'); -const async = require('async'); -const url = require('url'); -const crypto = require('crypto'); -const uuid = require('uuid'); -const SCSimpleBroker = require('sc-simple-broker').SCSimpleBroker; -const AsyncStreamEmitter = require('async-stream-emitter'); - -const scErrors = require('sc-errors'); -const AuthTokenExpiredError = scErrors.AuthTokenExpiredError; -const AuthTokenInvalidError = scErrors.AuthTokenInvalidError; -const AuthTokenNotBeforeError = scErrors.AuthTokenNotBeforeError; -const AuthTokenError = scErrors.AuthTokenError; -const SilentMiddlewareBlockedError = scErrors.SilentMiddlewareBlockedError; -const InvalidArgumentsError = scErrors.InvalidArgumentsError; -const InvalidOptionsError = scErrors.InvalidOptionsError; -const InvalidActionError = scErrors.InvalidActionError; -const BrokerError = scErrors.BrokerError; -const ServerProtocolError = scErrors.ServerProtocolError; - - -function SCServer(options) { - AsyncStreamEmitter.call(this); - - let opts = { - brokerEngine: new SCSimpleBroker(), - wsEngine: 'ws', - wsEngineServerOptions: {}, - maxPayload: null, - allowClientPublish: true, - ackTimeout: 10000, - handshakeTimeout: 10000, - pingTimeout: 20000, - pingTimeoutDisabled: false, - pingInterval: 8000, - origins: '*:*', - appName: uuid.v4(), - path: '/socketcluster/', - authDefaultExpiry: 86400, - authSignAsync: false, - authVerifyAsync: true, - pubSubBatchDuration: null, - middlewareEmitWarnings: true - }; - - this.options = Object.assign(opts, options); - - this.MIDDLEWARE_HANDSHAKE_WS = 'handshakeWS'; - this.MIDDLEWARE_HANDSHAKE_SC = 'handshakeSC'; - this.MIDDLEWARE_TRANSMIT = 'transmit'; - this.MIDDLEWARE_INVOKE = 'invoke'; - this.MIDDLEWARE_SUBSCRIBE = 'subscribe'; - this.MIDDLEWARE_PUBLISH_IN = 'publishIn'; - this.MIDDLEWARE_PUBLISH_OUT = 'publishOut'; - this.MIDDLEWARE_AUTHENTICATE = 'authenticate'; - - // Deprecated - this.MIDDLEWARE_PUBLISH = this.MIDDLEWARE_PUBLISH_IN; - - this._middleware = {}; - this._middleware[this.MIDDLEWARE_HANDSHAKE_WS] = []; - this._middleware[this.MIDDLEWARE_HANDSHAKE_SC] = []; - this._middleware[this.MIDDLEWARE_TRANSMIT] = []; - this._middleware[this.MIDDLEWARE_INVOKE] = []; - this._middleware[this.MIDDLEWARE_SUBSCRIBE] = []; - this._middleware[this.MIDDLEWARE_PUBLISH_IN] = []; - this._middleware[this.MIDDLEWARE_PUBLISH_OUT] = []; - this._middleware[this.MIDDLEWARE_AUTHENTICATE] = []; - - this.origins = opts.origins; - this._allowAllOrigins = this.origins.indexOf('*:*') !== -1; - - this.ackTimeout = opts.ackTimeout; - this.handshakeTimeout = opts.handshakeTimeout; - this.pingInterval = opts.pingInterval; - this.pingTimeout = opts.pingTimeout; - this.pingTimeoutDisabled = opts.pingTimeoutDisabled; - this.allowClientPublish = opts.allowClientPublish; - this.perMessageDeflate = opts.perMessageDeflate; - this.httpServer = opts.httpServer; - this.socketChannelLimit = opts.socketChannelLimit; - - this.brokerEngine = opts.brokerEngine; - this.appName = opts.appName || ''; - this.middlewareEmitWarnings = opts.middlewareEmitWarnings; - - // Make sure there is always a leading and a trailing slash in the WS path. - this._path = opts.path.replace(/\/?$/, '/').replace(/^\/?/, '/'); - - if (this.brokerEngine.isReady) { - this.isReady = true; - this.emit('ready', {}); - } else { - this.isReady = false; - (async () => { - await this.brokerEngine.listener('ready').once(); - this.isReady = true; - this.emit('ready', {}); - })(); - } - - let wsEngine = typeof opts.wsEngine === 'string' ? require(opts.wsEngine) : opts.wsEngine; - if (!wsEngine || !wsEngine.Server) { - throw new InvalidOptionsError( - 'The wsEngine option must be a path or module name which points ' + - 'to a valid WebSocket engine module with a compatible interface' - ); - } - let WSServer = wsEngine.Server; - - if (opts.authPrivateKey != null || opts.authPublicKey != null) { - if (opts.authPrivateKey == null) { - throw new InvalidOptionsError( - 'The authPrivateKey option must be specified if authPublicKey is specified' - ); - } else if (opts.authPublicKey == null) { - throw new InvalidOptionsError( - 'The authPublicKey option must be specified if authPrivateKey is specified' - ); - } - this.signatureKey = opts.authPrivateKey; - this.verificationKey = opts.authPublicKey; - } else { - if (opts.authKey == null) { - opts.authKey = crypto.randomBytes(32).toString('hex'); - } - this.signatureKey = opts.authKey; - this.verificationKey = opts.authKey; - } - - this.authVerifyAsync = opts.authVerifyAsync; - this.authSignAsync = opts.authSignAsync; - - this.defaultVerificationOptions = { - async: this.authVerifyAsync - }; - if (opts.authVerifyAlgorithms != null) { - this.defaultVerificationOptions.algorithms = opts.authVerifyAlgorithms; - } else if (opts.authAlgorithm != null) { - this.defaultVerificationOptions.algorithms = [opts.authAlgorithm]; - } - - this.defaultSignatureOptions = { - expiresIn: opts.authDefaultExpiry, - async: this.authSignAsync - }; - if (opts.authAlgorithm != null) { - this.defaultSignatureOptions.algorithm = opts.authAlgorithm; - } - - if (opts.authEngine) { - this.auth = opts.authEngine; - } else { - // Default authentication engine - this.auth = new AuthEngine(); - } - - if (opts.codecEngine) { - this.codec = opts.codecEngine; - } else { - // Default codec engine - this.codec = formatter; - } - - this.clients = {}; - this.clientsCount = 0; - - this.pendingClients = {}; - this.pendingClientsCount = 0; - - this.exchange = this.brokerEngine.exchange(); - - let wsServerOptions = opts.wsEngineServerOptions || {}; - wsServerOptions.server = this.httpServer; - wsServerOptions.verifyClient = this.verifyHandshake.bind(this); - - if (wsServerOptions.path == null && this._path != null) { - wsServerOptions.path = this._path; - } - if (wsServerOptions.perMessageDeflate == null && this.perMessageDeflate != null) { - wsServerOptions.perMessageDeflate = this.perMessageDeflate; - } - if (wsServerOptions.handleProtocols == null && opts.handleProtocols != null) { - wsServerOptions.handleProtocols = opts.handleProtocols; - } - if (wsServerOptions.maxPayload == null && opts.maxPayload != null) { - wsServerOptions.maxPayload = opts.maxPayload; - } - if (wsServerOptions.clientTracking == null) { - wsServerOptions.clientTracking = false; - } - - this.wsServer = new WSServer(wsServerOptions); - - this.wsServer.on('error', this._handleServerError.bind(this)); - this.wsServer.on('connection', this._handleSocketConnection.bind(this)); -} - -SCServer.prototype = Object.create(AsyncStreamEmitter.prototype); - -SCServer.prototype.setAuthEngine = function (authEngine) { - this.auth = authEngine; -}; - -SCServer.prototype.setCodecEngine = function (codecEngine) { - this.codec = codecEngine; -}; - -SCServer.prototype.emitError = function (error) { - this.emit('error', {error}); -}; - -SCServer.prototype.emitWarning = function (warning) { - this.emit('warning', {warning}); -}; - -SCServer.prototype._handleServerError = function (error) { - if (typeof error === 'string') { - error = new ServerProtocolError(error); - } - this.emitError(error); -}; - -SCServer.prototype._handleSocketErrors = async function (socket) { - // A socket error will show up as a warning on the server. - for await (let event of socket.listener('error')) { - this.emitWarning(event.error); - } -}; - -SCServer.prototype._handleHandshakeTimeout = function (scSocket) { - scSocket.disconnect(4005); -}; - -SCServer.prototype._subscribeSocket = async function (socket, channelOptions) { - if (!channelOptions) { - throw new InvalidActionError(`Socket ${socket.id} provided a malformated channel payload`); - } - - if (this.socketChannelLimit && socket.channelSubscriptionsCount >= this.socketChannelLimit) { - throw new InvalidActionError( - `Socket ${socket.id} tried to exceed the channel subscription limit of ${this.socketChannelLimit}` - ); - } - - let channelName = channelOptions.channel; - - if (typeof channelName !== 'string') { - throw new InvalidActionError(`Socket ${socket.id} provided an invalid channel name`); - } - - if (socket.channelSubscriptionsCount == null) { - socket.channelSubscriptionsCount = 0; - } - if (socket.channelSubscriptions[channelName] == null) { - socket.channelSubscriptions[channelName] = true; - socket.channelSubscriptionsCount++; - } - - try { - await this.brokerEngine.subscribeSocket(socket, channelName); - } catch (err) { - delete socket.channelSubscriptions[channelName]; - socket.channelSubscriptionsCount--; - throw err; - } - socket.emit('subscribe', { - channel: channelName, - subscribeOptions: channelOptions - }); - this.emit('subscription', { - socket, - channel: channelName, - subscribeOptions: channelOptions - }); -}; - -SCServer.prototype._unsubscribeSocketFromAllChannels = function (socket) { - Object.keys(socket.channelSubscriptions).forEach((channelName) => { - this._unsubscribeSocket(socket, channelName); - }); -}; - -SCServer.prototype._unsubscribeSocket = function (socket, channel) { - if (typeof channel !== 'string') { - throw new InvalidActionError( - `Socket ${socket.id} tried to unsubscribe from an invalid channel name` - ); - } - if (!socket.channelSubscriptions[channel]) { - throw new InvalidActionError( - `Socket ${socket.id} tried to unsubscribe from a channel which it is not subscribed to` - ); - } - - delete socket.channelSubscriptions[channel]; - if (socket.channelSubscriptionsCount != null) { - socket.channelSubscriptionsCount--; - } - - this.brokerEngine.unsubscribeSocket(socket, channel); - - socket.emit('unsubscribe', {channel}); - this.emit('unsubscription', {socket, channel}); -}; - -SCServer.prototype._processTokenError = function (err) { - let authError = null; - let isBadToken = true; - - if (err) { - if (err.name === 'TokenExpiredError') { - authError = new AuthTokenExpiredError(err.message, err.expiredAt); - } else if (err.name === 'JsonWebTokenError') { - authError = new AuthTokenInvalidError(err.message); - } else if (err.name === 'NotBeforeError') { - authError = new AuthTokenNotBeforeError(err.message, err.date); - // In this case, the token is good; it's just not active yet. - isBadToken = false; - } else { - authError = new AuthTokenError(err.message); - } - } - - return { - authError: authError, - isBadToken: isBadToken - }; -}; - -SCServer.prototype._emitBadAuthTokenError = function (scSocket, error, signedAuthToken) { - let badAuthStatus = { - authError: error, - signedAuthToken: signedAuthToken - }; - scSocket.emit('badAuthToken', { - authError: error, - signedAuthToken: signedAuthToken - }); - this.emit('badSocketAuthToken', { - socket: scSocket, - authError: error, - signedAuthToken: signedAuthToken - }); -}; - -SCServer.prototype._processAuthToken = function (scSocket, signedAuthToken, callback) { - let verificationOptions = Object.assign({socket: scSocket}, this.defaultVerificationOptions); - - let handleVerifyTokenResult = (result) => { - let err = result.error; - let token = result.token; - - let oldAuthState = scSocket.authState; - if (token) { - scSocket.signedAuthToken = signedAuthToken; - scSocket.authToken = token; - scSocket.authState = scSocket.AUTHENTICATED; - } else { - scSocket.signedAuthToken = null; - scSocket.authToken = null; - scSocket.authState = scSocket.UNAUTHENTICATED; - } - - // If the socket is authenticated, pass it through the MIDDLEWARE_AUTHENTICATE middleware. - // If the token is bad, we will tell the client to remove it. - // If there is an error but the token is good, then we will send back a 'quiet' error instead - // (as part of the status object only). - if (scSocket.authToken) { - this._passThroughAuthenticateMiddleware({ - socket: scSocket, - signedAuthToken: scSocket.signedAuthToken, - authToken: scSocket.authToken - }, (middlewareError, isBadToken) => { - if (middlewareError) { - scSocket.authToken = null; - scSocket.authState = scSocket.UNAUTHENTICATED; - if (isBadToken) { - this._emitBadAuthTokenError(scSocket, middlewareError, signedAuthToken); - } - } - // If an error is passed back from the authenticate middleware, it will be treated as a - // server warning and not a socket error. - callback(middlewareError, isBadToken || false, oldAuthState); - }); - } else { - let errorData = this._processTokenError(err); - - // If the error is related to the JWT being badly formatted, then we will - // treat the error as a socket error. - if (err && signedAuthToken != null) { - scSocket.emitError(errorData.authError); - if (errorData.isBadToken) { - this._emitBadAuthTokenError(scSocket, errorData.authError, signedAuthToken); - } - } - callback(errorData.authError, errorData.isBadToken, oldAuthState); - } - }; - - let verifyTokenResult; - let verifyTokenError; - - try { - verifyTokenResult = this.auth.verifyToken(signedAuthToken, this.verificationKey, verificationOptions); - } catch (err) { - verifyTokenError = err; - } - - if (verifyTokenResult instanceof Promise) { - (async () => { - let result = {}; - try { - result.token = await verifyTokenResult; - } catch (err) { - result.error = err; - } - handleVerifyTokenResult(result); - })(); - } else { - let result = { - token: verifyTokenResult, - error: verifyTokenError - }; - handleVerifyTokenResult(result); - } -}; - -SCServer.prototype._handleSocketConnection = function (wsSocket, upgradeReq) { - if (!wsSocket.upgradeReq) { - // Normalize ws modules to match. - wsSocket.upgradeReq = upgradeReq; - } - - let id = this.generateId(); - - let scSocket = new SCServerSocket(id, this, wsSocket); - scSocket.exchange = this.exchange; - - this._handleSocketErrors(scSocket); - - this.pendingClients[id] = scSocket; - this.pendingClientsCount++; - - let handleSocketAuthenticate = async () => { - for await (let rpc of scSocket.procedure('#authenticate')) { - let signedAuthToken = rpc.data; - - this._processAuthToken(scSocket, signedAuthToken, (err, isBadToken, oldAuthState) => { - if (err) { - if (isBadToken) { - scSocket.deauthenticate(); - } - } else { - scSocket.triggerAuthenticationEvents(oldAuthState); - } - if (err && isBadToken) { - rpc.error(err); - } else { - let authStatus = { - isAuthenticated: !!scSocket.authToken, - authError: scErrors.dehydrateError(err) - }; - rpc.end(authStatus); - } - }); - } - }; - handleSocketAuthenticate(); - - let handleSocketRemoveAuthToken = async () => { - for await (let data of scSocket.receiver('#removeAuthToken')) { - scSocket.deauthenticateSelf(); - } - }; - handleSocketRemoveAuthToken(); - - let handleSocketSubscribe = async () => { - for await (let rpc of scSocket.procedure('#subscribe')) { - let channelOptions = rpc.data; - - if (!channelOptions) { - channelOptions = {}; - } else if (typeof channelOptions === 'string') { - channelOptions = { - channel: channelOptions - }; - } - - (async () => { - if (scSocket.state === scSocket.OPEN) { - try { - await this._subscribeSocket(scSocket, channelOptions); - } catch (err) { - let error = new BrokerError(`Failed to subscribe socket to the ${channelOptions.channel} channel - ${err}`); - rpc.error(error); - scSocket.emitError(error); - return; - } - if (channelOptions.batch) { - rpc.end(undefined, {batch: true}); - return; - } - rpc.end(); - return; - } - // This is an invalid state; it means the client tried to subscribe before - // having completed the handshake. - let error = new InvalidActionError('Cannot subscribe socket to a channel before it has completed the handshake'); - rpc.error(error); - this.emitWarning(error); - })(); - } - }; - handleSocketSubscribe(); - - let handleSocketUnsubscribe = async () => { - for await (let rpc of scSocket.procedure('#unsubscribe')) { - let channel = rpc.data; - let error; - try { - this._unsubscribeSocket(scSocket, channel); - } catch (err) { - error = new BrokerError( - `Failed to unsubscribe socket from the ${channel} channel - ${err}` - ); - } - if (error) { - rpc.error(error); - scSocket.emitError(error); - } else { - rpc.end(); - } - } - }; - handleSocketUnsubscribe(); - - let cleanupSocket = (type, code, reason) => { - clearTimeout(scSocket._handshakeTimeoutRef); - - scSocket.closeProcedure('#handshake'); - scSocket.closeProcedure('#authenticate'); - scSocket.closeProcedure('#subscribe'); - scSocket.closeProcedure('#unsubscribe'); - scSocket.closeReceiver('#removeAuthToken'); - scSocket.closeListener('authenticate'); - scSocket.closeListener('authStateChange'); - scSocket.closeListener('deauthenticate'); - - let isClientFullyConnected = !!this.clients[id]; - - if (isClientFullyConnected) { - delete this.clients[id]; - this.clientsCount--; - } - - let isClientPending = !!this.pendingClients[id]; - if (isClientPending) { - delete this.pendingClients[id]; - this.pendingClientsCount--; - } - - if (type === 'disconnect') { - this.emit('disconnection', { - socket: scSocket, - code, - reason - }); - } else if (type === 'abort') { - this.emit('connectionAbort', { - socket: scSocket, - code, - reason - }); - } - this.emit('closure', { - socket: scSocket, - code, - reason - }); - - this._unsubscribeSocketFromAllChannels(scSocket); - }; - - let handleSocketDisconnect = async () => { - let event = await scSocket.listener('disconnect').once(); - cleanupSocket('disconnect', event.code, event.data); - }; - handleSocketDisconnect(); - - let handleSocketAbort = async () => { - let event = await scSocket.listener('connectAbort').once(); - cleanupSocket('abort', event.code, event.data); - }; - handleSocketAbort(); - - scSocket._handshakeTimeoutRef = setTimeout(this._handleHandshakeTimeout.bind(this, scSocket), this.handshakeTimeout); - - let handleSocketHandshake = async () => { - for await (let rpc of scSocket.procedure('#handshake')) { - let data = rpc.data || {}; - let signedAuthToken = data.authToken || null; - clearTimeout(scSocket._handshakeTimeoutRef); - - this._passThroughHandshakeSCMiddleware({ - socket: scSocket - }, (err, statusCode) => { - if (err) { - if (err.statusCode == null) { - err.statusCode = statusCode; - } - rpc.error(err); - scSocket.disconnect(err.statusCode); - return; - } - this._processAuthToken(scSocket, signedAuthToken, (err, isBadToken, oldAuthState) => { - if (scSocket.state === scSocket.CLOSED) { - return; - } - - let clientSocketStatus = { - id: scSocket.id, - pingTimeout: this.pingTimeout - }; - let serverSocketStatus = { - id: scSocket.id, - pingTimeout: this.pingTimeout - }; - - if (err) { - if (signedAuthToken != null) { - // Because the token is optional as part of the handshake, we don't count - // it as an error if the token wasn't provided. - clientSocketStatus.authError = scErrors.dehydrateError(err); - serverSocketStatus.authError = err; - - if (isBadToken) { - scSocket.deauthenticate(); - } - } - } - clientSocketStatus.isAuthenticated = !!scSocket.authToken; - serverSocketStatus.isAuthenticated = clientSocketStatus.isAuthenticated; - - if (this.pendingClients[id]) { - delete this.pendingClients[id]; - this.pendingClientsCount--; - } - this.clients[id] = scSocket; - this.clientsCount++; - - scSocket.state = scSocket.OPEN; - - if (clientSocketStatus.isAuthenticated) { - // Needs to be executed after the connection event to allow - // consumers to be setup from inside the connection loop. - (async () => { - await this.listener('connection').once(); - scSocket.triggerAuthenticationEvents(oldAuthState); - })(); - } - - scSocket.emit('connect', serverSocketStatus); - this.emit('connection', {socket: scSocket, ...serverSocketStatus}); - - // Treat authentication failure as a 'soft' error - rpc.end(clientSocketStatus); - }); - }); - } - }; - handleSocketHandshake(); - - // Emit event to signal that a socket handshake has been initiated. - this.emit('handshake', {socket: scSocket}); -}; - -SCServer.prototype.close = function () { - this.isReady = false; - return new Promise((resolve, reject) => { - this.wsServer.close((err) => { - if (err) { - reject(err); - return; - } - resolve(); - }); - }); -}; - -SCServer.prototype.getPath = function () { - return this._path; -}; - -SCServer.prototype.generateId = function () { - return base64id.generateId(); -}; - -SCServer.prototype.addMiddleware = function (type, middleware) { - if (!this._middleware[type]) { - throw new InvalidArgumentsError(`Middleware type "${type}" is not supported`); - // Read more: https://socketcluster.io/#!/docs/middleware-and-authorization - } - this._middleware[type].push(middleware); -}; - -SCServer.prototype.removeMiddleware = function (type, middleware) { - let middlewareFunctions = this._middleware[type]; - - this._middleware[type] = middlewareFunctions.filter((fn) => { - return fn !== middleware; - }); -}; - -SCServer.prototype.verifyHandshake = function (info, callback) { - let req = info.req; - let origin = info.origin; - if (origin === 'null' || origin == null) { - origin = '*'; - } - let ok = false; - - if (this._allowAllOrigins) { - ok = true; - } else { - try { - let parts = url.parse(origin); - parts.port = parts.port || 80; - ok = ~this.origins.indexOf(parts.hostname + ':' + parts.port) || - ~this.origins.indexOf(parts.hostname + ':*') || - ~this.origins.indexOf('*:' + parts.port); - } catch (e) {} - } - - if (ok) { - let handshakeMiddleware = this._middleware[this.MIDDLEWARE_HANDSHAKE_WS]; - if (handshakeMiddleware.length) { - let callbackInvoked = false; - async.applyEachSeries(handshakeMiddleware, req, (err) => { - if (callbackInvoked) { - this.emitWarning( - new InvalidActionError( - `Callback for ${this.MIDDLEWARE_HANDSHAKE_WS} middleware was already invoked` - ) - ); - } else { - callbackInvoked = true; - if (err) { - if (err === true || err.silent) { - err = new SilentMiddlewareBlockedError( - `Action was silently blocked by ${this.MIDDLEWARE_HANDSHAKE_WS} middleware`, - this.MIDDLEWARE_HANDSHAKE_WS - ); - } else if (this.middlewareEmitWarnings) { - this.emitWarning(err); - } - callback(false, 401, typeof err === 'string' ? err : err.message); - } else { - callback(true); - } - } - }); - } else { - callback(true); - } - } else { - let err = new ServerProtocolError( - `Failed to authorize socket handshake - Invalid origin: ${origin}` - ); - this.emitWarning(err); - callback(false, 403, err.message); - } -}; - -SCServer.prototype._isReservedRemoteEvent = function (event) { - return typeof event === 'string' && event.indexOf('#') === 0; -}; - -SCServer.prototype.verifyInboundRemoteEvent = function (requestOptions, callback) { - let socket = requestOptions.socket; - let token = socket.getAuthToken(); - if (this.isAuthTokenExpired(token)) { - requestOptions.authTokenExpiredError = new AuthTokenExpiredError( - 'The socket auth token has expired', - token.exp - ); - - socket.deauthenticate(); - } - - this._passThroughMiddleware(requestOptions, callback); -}; - -SCServer.prototype.isAuthTokenExpired = function (token) { - if (token && token.exp != null) { - let currentTime = Date.now(); - let expiryMilliseconds = token.exp * 1000; - return currentTime > expiryMilliseconds; - } - return false; -}; - -SCServer.prototype._processPublishAction = function (options, request, callback) { - let callbackInvoked = false; - - if (this.allowClientPublish) { - let eventData = options.data || {}; - request.channel = eventData.channel; - request.data = eventData.data; - - async.applyEachSeries(this._middleware[this.MIDDLEWARE_PUBLISH_IN], request, - (err) => { - if (callbackInvoked) { - this.emitWarning( - new InvalidActionError( - `Callback for ${this.MIDDLEWARE_PUBLISH_IN} middleware was already invoked` - ) - ); - } else { - callbackInvoked = true; - if (request.data !== undefined) { - eventData.data = request.data; - } - if (err) { - if (err === true || err.silent) { - err = new SilentMiddlewareBlockedError( - `Action was silently blocked by ${this.MIDDLEWARE_PUBLISH_IN} middleware`, - this.MIDDLEWARE_PUBLISH_IN - ); - } else if (this.middlewareEmitWarnings) { - this.emitWarning(err); - } - callback(err, eventData, request.ackData); - } else { - if (typeof request.channel !== 'string') { - err = new BrokerError( - `Socket ${request.socket.id} tried to publish to an invalid ${request.channel} channel` - ); - this.emitWarning(err); - callback(err, eventData, request.ackData); - return; - } - (async () => { - let error; - try { - await this.exchange.publish(request.channel, request.data); - } catch (err) { - error = err; - this.emitWarning(error); - } - callback(error, eventData, request.ackData); - })(); - } - } - } - ); - } else { - let noPublishError = new InvalidActionError('Client publish feature is disabled'); - this.emitWarning(noPublishError); - callback(noPublishError); - } -}; - -SCServer.prototype._processSubscribeAction = function (options, request, callback) { - let callbackInvoked = false; - - let eventData = options.data || {}; - request.channel = eventData.channel; - request.waitForAuth = eventData.waitForAuth; - request.data = eventData.data; - - if (request.waitForAuth && request.authTokenExpiredError) { - // If the channel has the waitForAuth flag set, then we will handle the expiry quietly - // and we won't pass this request through the subscribe middleware. - callback(request.authTokenExpiredError, eventData); - } else { - async.applyEachSeries(this._middleware[this.MIDDLEWARE_SUBSCRIBE], request, - (err) => { - if (callbackInvoked) { - this.emitWarning( - new InvalidActionError( - `Callback for ${this.MIDDLEWARE_SUBSCRIBE} middleware was already invoked` - ) - ); - } else { - callbackInvoked = true; - if (err) { - if (err === true || err.silent) { - err = new SilentMiddlewareBlockedError( - `Action was silently blocked by ${this.MIDDLEWARE_SUBSCRIBE} middleware`, - this.MIDDLEWARE_SUBSCRIBE - ); - } else if (this.middlewareEmitWarnings) { - this.emitWarning(err); - } - } - if (request.data !== undefined) { - eventData.data = request.data; - } - callback(err, eventData); - } - } - ); - } -}; - -SCServer.prototype._processTransmitAction = function (options, request, callback) { - let callbackInvoked = false; - - request.event = options.event; - request.data = options.data; - - async.applyEachSeries(this._middleware[this.MIDDLEWARE_TRANSMIT], request, - (err) => { - if (callbackInvoked) { - this.emitWarning( - new InvalidActionError( - `Callback for ${this.MIDDLEWARE_TRANSMIT} middleware was already invoked` - ) - ); - } else { - callbackInvoked = true; - if (err) { - if (err === true || err.silent) { - err = new SilentMiddlewareBlockedError( - `Action was silently blocked by ${this.MIDDLEWARE_TRANSMIT} middleware`, - this.MIDDLEWARE_TRANSMIT - ); - } else if (this.middlewareEmitWarnings) { - this.emitWarning(err); - } - } - callback(err, request.data); - } - } - ); -}; - -SCServer.prototype._processInvokeAction = function (options, request, callback) { - let callbackInvoked = false; - - request.event = options.event; - request.data = options.data; - - async.applyEachSeries(this._middleware[this.MIDDLEWARE_INVOKE], request, - (err) => { - if (callbackInvoked) { - this.emitWarning( - new InvalidActionError( - `Callback for ${this.MIDDLEWARE_INVOKE} middleware was already invoked` - ) - ); - } else { - callbackInvoked = true; - if (err) { - if (err === true || err.silent) { - err = new SilentMiddlewareBlockedError( - `Action was silently blocked by ${this.MIDDLEWARE_INVOKE} middleware`, - this.MIDDLEWARE_INVOKE - ); - } else if (this.middlewareEmitWarnings) { - this.emitWarning(err); - } - } - callback(err, request.data); - } - } - ); -}; - -SCServer.prototype._passThroughMiddleware = function (options, callback) { - let request = { - socket: options.socket - }; - - if (options.authTokenExpiredError != null) { - request.authTokenExpiredError = options.authTokenExpiredError; - } - - let event = options.event; - - if (options.cid == null) { - // If transmit. - if (this._isReservedRemoteEvent(event)) { - if (event === '#publish') { - this._processPublishAction(options, request, callback); - } else if (event === '#removeAuthToken') { - callback(null, options.data); - } else { - let error = new InvalidActionError(`The reserved transmitted event ${event} is not supported`); - callback(error); - } - } else { - this._processTransmitAction(options, request, callback); - } - } else { - // If invoke/RPC. - if (this._isReservedRemoteEvent(event)) { - if (event === '#subscribe') { - this._processSubscribeAction(options, request, callback); - } else if (event === '#publish') { - this._processPublishAction(options, request, callback); - } else if ( - event === '#handshake' || - event === '#authenticate' || - event === '#unsubscribe' - ) { - callback(null, options.data); - } else { - let error = new InvalidActionError(`The reserved invoked event ${event} is not supported`); - callback(error); - } - } else { - this._processInvokeAction(options, request, callback); - } - } -}; - -SCServer.prototype._passThroughAuthenticateMiddleware = function (options, callback) { - let callbackInvoked = false; - - let request = { - socket: options.socket, - authToken: options.authToken - }; - - async.applyEachSeries(this._middleware[this.MIDDLEWARE_AUTHENTICATE], request, - (err, results) => { - if (callbackInvoked) { - this.emitWarning( - new InvalidActionError( - `Callback for ${this.MIDDLEWARE_AUTHENTICATE} middleware was already invoked` - ) - ); - } else { - callbackInvoked = true; - let isBadToken = false; - if (results.length) { - isBadToken = results[results.length - 1] || false; - } - if (err) { - if (err === true || err.silent) { - err = new SilentMiddlewareBlockedError( - `Action was silently blocked by ${this.MIDDLEWARE_AUTHENTICATE} middleware`, - this.MIDDLEWARE_AUTHENTICATE - ); - } else if (this.middlewareEmitWarnings) { - this.emitWarning(err); - } - } - callback(err, isBadToken); - } - } - ); -}; - -SCServer.prototype._passThroughHandshakeSCMiddleware = function (options, callback) { - let callbackInvoked = false; - - let request = { - socket: options.socket - }; - - async.applyEachSeries(this._middleware[this.MIDDLEWARE_HANDSHAKE_SC], request, - (err, results) => { - if (callbackInvoked) { - this.emitWarning( - new InvalidActionError( - `Callback for ${this.MIDDLEWARE_HANDSHAKE_SC} middleware was already invoked` - ) - ); - } else { - callbackInvoked = true; - let statusCode; - if (results.length) { - statusCode = results[results.length - 1] || 4008; - } else { - statusCode = 4008; - } - if (err) { - if (err.statusCode != null) { - statusCode = err.statusCode; - } - if (err === true || err.silent) { - err = new SilentMiddlewareBlockedError( - `Action was silently blocked by ${this.MIDDLEWARE_HANDSHAKE_SC} middleware`, - this.MIDDLEWARE_HANDSHAKE_SC - ); - } else if (this.middlewareEmitWarnings) { - this.emitWarning(err); - } - } - callback(err, statusCode); - } - } - ); -}; - -SCServer.prototype.verifyOutboundEvent = function (socket, eventName, eventData, options, callback) { - let callbackInvoked = false; - - if (eventName === '#publish') { - let request = { - socket: socket, - channel: eventData.channel, - data: eventData.data - }; - async.applyEachSeries(this._middleware[this.MIDDLEWARE_PUBLISH_OUT], request, - (err) => { - if (callbackInvoked) { - this.emitWarning( - new InvalidActionError( - `Callback for ${this.MIDDLEWARE_PUBLISH_OUT} middleware was already invoked` - ) - ); - } else { - callbackInvoked = true; - if (request.data !== undefined) { - eventData.data = request.data; - } - if (err) { - if (err === true || err.silent) { - err = new SilentMiddlewareBlockedError( - `Action was silently blocked by ${this.MIDDLEWARE_PUBLISH_OUT} middleware`, - this.MIDDLEWARE_PUBLISH_OUT - ); - } else if (this.middlewareEmitWarnings) { - this.emitWarning(err); - } - callback(err, eventData); - } else { - if (options && request.useCache) { - options.useCache = true; - } - callback(null, eventData); - } - } - } - ); - } else { - callback(null, eventData); - } -}; - -module.exports = SCServer; diff --git a/scserversocket.js b/scserversocket.js deleted file mode 100644 index ba0841c..0000000 --- a/scserversocket.js +++ /dev/null @@ -1,586 +0,0 @@ -const cloneDeep = require('lodash.clonedeep'); -const StreamDemux = require('stream-demux'); -const AsyncStreamEmitter = require('async-stream-emitter'); -const Response = require('./response').Response; - -const scErrors = require('sc-errors'); -const InvalidArgumentsError = scErrors.InvalidArgumentsError; -const SocketProtocolError = scErrors.SocketProtocolError; -const TimeoutError = scErrors.TimeoutError; -const InvalidActionError = scErrors.InvalidActionError; -const AuthError = scErrors.AuthError; - - -function SCServerSocket(id, server, socket) { - AsyncStreamEmitter.call(this); - - this._autoAckRPCs = { - '#publish': 1 - }; - - this.id = id; - this.server = server; - this.socket = socket; - this.state = this.CONNECTING; - this.authState = this.UNAUTHENTICATED; - this.active = true; - - this._receiverDemux = new StreamDemux(); - this._procedureDemux = new StreamDemux(); - - this.request = this.socket.upgradeReq || {}; - - if (this.request.connection) { - this.remoteAddress = this.request.connection.remoteAddress; - this.remoteFamily = this.request.connection.remoteFamily; - this.remotePort = this.request.connection.remotePort; - } else { - this.remoteAddress = this.request.remoteAddress; - this.remoteFamily = this.request.remoteFamily; - this.remotePort = this.request.remotePort; - } - if (this.request.forwardedForAddress) { - this.forwardedForAddress = this.request.forwardedForAddress; - } - - this._cid = 1; - this._callbackMap = {}; - this._batchSendList = []; - - this.channelSubscriptions = {}; - this.channelSubscriptionsCount = 0; - - this.socket.on('error', (err) => { - this.emitError(err); - }); - - this.socket.on('close', (code, data) => { - this._onSCClose(code, data); - }); - - if (!this.server.pingTimeoutDisabled) { - this._pingIntervalTicker = setInterval(this._sendPing.bind(this), this.server.pingInterval); - } - this._resetPongTimeout(); - - // Receive incoming raw messages - this.socket.on('message', (message, flags) => { - this._resetPongTimeout(); - - this.emit('message', {message}); - - let obj; - try { - obj = this.decode(message); - } catch (err) { - if (err.name === 'Error') { - err.name = 'InvalidMessageError'; - } - this.emitError(err); - return; - } - - // If pong - if (obj === '#2') { - let token = this.getAuthToken(); - if (this.server.isAuthTokenExpired(token)) { - this.deauthenticate(); - } - } else { - if (Array.isArray(obj)) { - let len = obj.length; - for (let i = 0; i < len; i++) { - this._handleRemoteEventObject(obj[i], message); - } - } else { - this._handleRemoteEventObject(obj, message); - } - } - }); -} - -SCServerSocket.prototype = Object.create(AsyncStreamEmitter.prototype); - -SCServerSocket.CONNECTING = SCServerSocket.prototype.CONNECTING = 'connecting'; -SCServerSocket.OPEN = SCServerSocket.prototype.OPEN = 'open'; -SCServerSocket.CLOSED = SCServerSocket.prototype.CLOSED = 'closed'; - -SCServerSocket.AUTHENTICATED = SCServerSocket.prototype.AUTHENTICATED = 'authenticated'; -SCServerSocket.UNAUTHENTICATED = SCServerSocket.prototype.UNAUTHENTICATED = 'unauthenticated'; - -SCServerSocket.ignoreStatuses = scErrors.socketProtocolIgnoreStatuses; -SCServerSocket.errorStatuses = scErrors.socketProtocolErrorStatuses; - -SCServerSocket.prototype.receiver = function (receiverName) { - return this._receiverDemux.stream(receiverName); -}; - -SCServerSocket.prototype.closeReceiver = function (receiverName) { - this._receiverDemux.close(receiverName); -}; - -SCServerSocket.prototype.procedure = function (procedureName) { - return this._procedureDemux.stream(procedureName); -}; - -SCServerSocket.prototype.closeProcedure = function (procedureName) { - this._procedureDemux.close(procedureName); -}; - -SCServerSocket.prototype._sendPing = function () { - if (this.state !== this.CLOSED) { - this.sendObject('#1'); - } -}; - -SCServerSocket.prototype._handleRemoteEventObject = function (obj, message) { - if (obj && obj.event != null) { - let eventName = obj.event; - - let requestOptions = { - socket: this, - event: eventName, - data: obj.data, - }; - - if (obj.cid == null) { - this.server.verifyInboundRemoteEvent(requestOptions, (err, newEventData) => { - if (!err) { - this._receiverDemux.write(eventName, newEventData); - } - }); - } else { - requestOptions.cid = obj.cid; - let response = new Response(this, requestOptions.cid); - this.server.verifyInboundRemoteEvent(requestOptions, (err, newEventData, ackData) => { - if (err) { - response.error(err); - } else { - if (this._autoAckRPCs[eventName]) { - if (ackData !== undefined) { - response.end(ackData); - } else { - response.end(); - } - } else { - this._procedureDemux.write(eventName, { - data: newEventData, - end: (data) => { - response.end(data); - }, - error: (err) => { - response.error(err); - } - }); - } - } - }); - } - } else if (obj && obj.rid != null) { - // If incoming message is a response to a previously sent message - let ret = this._callbackMap[obj.rid]; - if (ret) { - clearTimeout(ret.timeout); - delete this._callbackMap[obj.rid]; - let rehydratedError = scErrors.hydrateError(obj.error); - ret.callback(rehydratedError, obj.data); - } - } else { - // The last remaining case is to treat the message as raw - this.emit('raw', {message}); - } -}; - -SCServerSocket.prototype._resetPongTimeout = function () { - if (this.server.pingTimeoutDisabled) { - return; - } - clearTimeout(this._pingTimeoutTicker); - this._pingTimeoutTicker = setTimeout(() => { - this._onSCClose(4001); - this.socket.close(4001); - }, this.server.pingTimeout); -}; - -SCServerSocket.prototype._nextCallId = function () { - return this._cid++; -}; - -SCServerSocket.prototype.getState = function () { - return this.state; -}; - -SCServerSocket.prototype.getBytesReceived = function () { - return this.socket.bytesReceived; -}; - -SCServerSocket.prototype.emitError = function (error) { - this.emit('error', { - error - }); -}; - -SCServerSocket.prototype._onSCClose = function (code, reason) { - clearInterval(this._pingIntervalTicker); - clearTimeout(this._pingTimeoutTicker); - - if (this.state !== this.CLOSED) { - let prevState = this.state; - this.state = this.CLOSED; - - if (prevState === this.CONNECTING) { - this.emit('connectAbort', {code, reason}); - } else { - this.emit('disconnect', {code, reason}); - } - this.emit('close', {code, reason}); - - if (!SCServerSocket.ignoreStatuses[code]) { - let closeMessage; - if (reason) { - let reasonString; - if (typeof reason === 'object') { - try { - reasonString = JSON.stringify(reason); - } catch (error) { - reasonString = reason.toString(); - } - } else { - reasonString = reason; - } - closeMessage = `Socket connection closed with status code ${code} and reason: ${reasonString}`; - } else { - closeMessage = `Socket connection closed with status code ${code}`; - } - let err = new SocketProtocolError(SCServerSocket.errorStatuses[code] || closeMessage, code); - this.emitError(err); - } - } -}; - -SCServerSocket.prototype.disconnect = function (code, data) { - code = code || 1000; - - if (typeof code !== 'number') { - let err = new InvalidArgumentsError('If specified, the code argument must be a number'); - this.emitError(err); - } - - if (this.state !== this.CLOSED) { - this._onSCClose(code, data); - this.socket.close(code, data); - } -}; - -SCServerSocket.prototype.destroy = function (code, data) { - this.active = false; - this.disconnect(code, data); -}; - -SCServerSocket.prototype.terminate = function () { - this.socket.terminate(); -}; - -SCServerSocket.prototype.send = function (data, options) { - this.socket.send(data, options, (err) => { - if (err) { - this._onSCClose(1006, err.toString()); - } - }); -}; - -SCServerSocket.prototype.decode = function (message) { - return this.server.codec.decode(message); -}; - -SCServerSocket.prototype.encode = function (object) { - return this.server.codec.encode(object); -}; - -SCServerSocket.prototype.sendObjectBatch = function (object) { - this._batchSendList.push(object); - if (this._batchTimeout) { - return; - } - - this._batchTimeout = setTimeout(() => { - delete this._batchTimeout; - if (this._batchSendList.length) { - let str; - try { - str = this.encode(this._batchSendList); - } catch (err) { - this.emitError(err); - } - if (str != null) { - this.send(str); - } - this._batchSendList = []; - } - }, this.server.options.pubSubBatchDuration || 0); -}; - -SCServerSocket.prototype.sendObjectSingle = function (object) { - let str; - try { - str = this.encode(object); - } catch (err) { - this.emitError(err); - } - if (str != null) { - this.send(str); - } -}; - -SCServerSocket.prototype.sendObject = function (object, options) { - if (options && options.batch) { - this.sendObjectBatch(object); - } else { - this.sendObjectSingle(object); - } -}; - -SCServerSocket.prototype.transmit = function (event, data, options) { - this.server.verifyOutboundEvent(this, event, data, options, (err, newData) => { - let eventObject = { - event: event - }; - if (newData !== undefined) { - eventObject.data = newData; - } - - if (!err) { - if (options && options.useCache && options.stringifiedData != null) { - // Optimized - this.send(options.stringifiedData); - } else { - this.sendObject(eventObject); - } - } - }); - return Promise.resolve(); -}; - -SCServerSocket.prototype.invoke = function (event, data, options) { - return new Promise((resolve, reject) => { - this.server.verifyOutboundEvent(this, event, data, options, (err, newData) => { - if (err) { - reject(err); - return; - } - let eventObject = { - event: event, - cid: this._nextCallId() - }; - if (newData !== undefined) { - eventObject.data = newData; - } - - let timeout = setTimeout(() => { - let error = new TimeoutError(`Event response for "${event}" timed out`); - delete this._callbackMap[eventObject.cid]; - reject(error); - }, this.server.ackTimeout); - - this._callbackMap[eventObject.cid] = { - callback: (err, result) => { - if (err) { - reject(err); - return; - } - resolve(result); - }, - timeout: timeout - }; - - if (options && options.useCache && options.stringifiedData != null) { - // Optimized - this.send(options.stringifiedData); - } else { - this.sendObject(eventObject); - } - }); - }); -}; - -SCServerSocket.prototype.triggerAuthenticationEvents = function (oldAuthState) { - if (oldAuthState !== this.AUTHENTICATED) { - let stateChangeData = { - oldAuthState, - newAuthState: this.authState, - authToken: this.authToken - }; - this.emit('authStateChange', stateChangeData); - this.server.emit('authenticationStateChange', { - socket: this, - ...stateChangeData - }); - } - this.emit('authenticate', {authToken: this.authToken}); - this.server.emit('authentication', { - socket: this, - authToken: this.authToken - }); -}; - -SCServerSocket.prototype.setAuthToken = async function (data, options) { - let authToken = cloneDeep(data); - let oldAuthState = this.authState; - this.authState = this.AUTHENTICATED; - - if (options == null) { - options = {}; - } else { - options = cloneDeep(options); - if (options.algorithm != null) { - delete options.algorithm; - let err = new InvalidArgumentsError( - 'Cannot change auth token algorithm at runtime - It must be specified as a config option on launch' - ); - this.emitError(err); - } - } - - options.mutatePayload = true; - let rejectOnFailedDelivery = options.rejectOnFailedDelivery; - delete options.rejectOnFailedDelivery; - let defaultSignatureOptions = this.server.defaultSignatureOptions; - - // We cannot have the exp claim on the token and the expiresIn option - // set at the same time or else auth.signToken will throw an error. - let expiresIn; - if (options.expiresIn == null) { - expiresIn = defaultSignatureOptions.expiresIn; - } else { - expiresIn = options.expiresIn; - } - if (authToken) { - if (authToken.exp == null) { - options.expiresIn = expiresIn; - } else { - delete options.expiresIn; - } - } else { - options.expiresIn = expiresIn; - } - - // Always use the default sync/async signing mode since it cannot be changed at runtime. - if (defaultSignatureOptions.async != null) { - options.async = defaultSignatureOptions.async; - } - // Always use the default algorithm since it cannot be changed at runtime. - if (defaultSignatureOptions.algorithm != null) { - options.algorithm = defaultSignatureOptions.algorithm; - } - - this.authToken = authToken; - - let handleAuthTokenSignFail = (error) => { - this.emitError(error); - this._onSCClose(4002, error.toString()); - this.socket.close(4002); - throw error; - }; - - let sendAuthTokenToClient = async (signedToken) => { - let tokenData = { - token: signedToken - }; - try { - return await this.invoke('#setAuthToken', tokenData); - } catch (err) { - throw new AuthError(`Failed to deliver auth token to client - ${err}`); - } - }; - - let signTokenResult; - - try { - signTokenResult = this.server.auth.signToken(authToken, this.server.signatureKey, options); - } catch (err) { - handleAuthTokenSignFail(err); - } - - let signedAuthToken; - if (signTokenResult instanceof Promise) { - try { - signedAuthToken = await signTokenResult; - } catch (err) { - handleAuthTokenSignFail(err); - } - } else { - signedAuthToken = signTokenResult; - } - if (this.authToken === authToken) { - this.signedAuthToken = signedAuthToken; - this.emit('authTokenSigned', {signedAuthToken}); - } - - this.triggerAuthenticationEvents(oldAuthState); - try { - await sendAuthTokenToClient(signedAuthToken); - } catch (err) { - this.emitError(err); - if (rejectOnFailedDelivery) { - throw err; - } - } -}; - -SCServerSocket.prototype.getAuthToken = function () { - return this.authToken; -}; - -SCServerSocket.prototype.deauthenticateSelf = function () { - let oldAuthState = this.authState; - let oldAuthToken = this.authToken; - this.signedAuthToken = null; - this.authToken = null; - this.authState = this.UNAUTHENTICATED; - if (oldAuthState !== this.UNAUTHENTICATED) { - let stateChangeData = { - oldAuthState, - newAuthState: this.authState - }; - this.emit('authStateChange', stateChangeData); - this.server.emit('authenticationStateChange', { - socket: this, - ...stateChangeData - }); - } - this.emit('deauthenticate', {oldAuthToken}); - this.server.emit('deauthentication', { - socket: this, - oldAuthToken - }); -}; - -SCServerSocket.prototype.deauthenticate = function () { - this.deauthenticateSelf(); - return this.invoke('#removeAuthToken'); -}; - -SCServerSocket.prototype.kickOut = function (channel, message) { - if (channel == null) { - Object.keys(this.channelSubscriptions).forEach((channelName) => { - delete this.channelSubscriptions[channelName]; - this.channelSubscriptionsCount--; - this.transmit('#kickOut', {message: message, channel: channelName}); - }); - } else { - delete this.channelSubscriptions[channel]; - this.channelSubscriptionsCount--; - this.transmit('#kickOut', {message: message, channel: channel}); - } - return this.server.brokerEngine.unsubscribeSocket(this, channel); -}; - -SCServerSocket.prototype.subscriptions = function () { - return Object.keys(this.channelSubscriptions); -}; - -SCServerSocket.prototype.isSubscribed = function (channel) { - return !!this.channelSubscriptions[channel]; -}; - -module.exports = SCServerSocket; diff --git a/server.js b/server.js new file mode 100644 index 0000000..3a76461 --- /dev/null +++ b/server.js @@ -0,0 +1,396 @@ +const AGServerSocket = require('./serversocket'); +const AuthEngine = require('ag-auth'); +const formatter = require('sc-formatter'); +const base64id = require('base64id'); +const url = require('url'); +const crypto = require('crypto'); +const AGSimpleBroker = require('ag-simple-broker'); +const AsyncStreamEmitter = require('async-stream-emitter'); +const WritableConsumableStream = require('writable-consumable-stream'); +const AGAction = require('./action'); + +const scErrors = require('sc-errors'); +const SilentMiddlewareBlockedError = scErrors.SilentMiddlewareBlockedError; +const InvalidArgumentsError = scErrors.InvalidArgumentsError; +const InvalidOptionsError = scErrors.InvalidOptionsError; +const InvalidActionError = scErrors.InvalidActionError; +const ServerProtocolError = scErrors.ServerProtocolError; + +function AGServer(options) { + AsyncStreamEmitter.call(this); + + let opts = { + brokerEngine: new AGSimpleBroker(), + wsEngine: 'ws', + wsEngineServerOptions: {}, + maxPayload: null, + allowClientPublish: true, + ackTimeout: 10000, + handshakeTimeout: 10000, + strictHandshake: true, + pingTimeout: 30000, + pingTimeoutDisabled: false, + pingInterval: 8000, + origins: '*:*', + path: '/socketcluster/', + protocolVersion: 2, + authDefaultExpiry: 86400, + batchOnHandshake: false, + batchOnHandshakeDuration: 400, + batchInterval: 50, + middlewareEmitFailures: true, + socketStreamCleanupMode: 'kill', + cloneData: false + }; + + this.options = Object.assign(opts, options); + + this._middleware = {}; + + this.origins = opts.origins; + this._allowAllOrigins = this.origins.indexOf('*:*') !== -1; + + this.ackTimeout = opts.ackTimeout; + this.handshakeTimeout = opts.handshakeTimeout; + this.pingInterval = opts.pingInterval; + this.pingTimeout = opts.pingTimeout; + this.pingTimeoutDisabled = opts.pingTimeoutDisabled; + this.allowClientPublish = opts.allowClientPublish; + this.perMessageDeflate = opts.perMessageDeflate; + this.httpServer = opts.httpServer; + this.socketChannelLimit = opts.socketChannelLimit; + this.protocolVersion = opts.protocolVersion; + this.strictHandshake = opts.strictHandshake; + + this.brokerEngine = opts.brokerEngine; + this.middlewareEmitFailures = opts.middlewareEmitFailures; + + this._path = opts.path; + + (async () => { + for await (let {error} of this.brokerEngine.listener('error')) { + this.emitWarning(error); + } + })(); + + if (this.brokerEngine.isReady) { + this.isReady = true; + this.emit('ready', {}); + } else { + this.isReady = false; + (async () => { + await this.brokerEngine.listener('ready').once(); + this.isReady = true; + this.emit('ready', {}); + })(); + } + + let wsEngine = typeof opts.wsEngine === 'string' ? require(opts.wsEngine) : opts.wsEngine; + if (!wsEngine || !wsEngine.Server) { + throw new InvalidOptionsError( + 'The wsEngine option must be a path or module name which points ' + + 'to a valid WebSocket engine module with a compatible interface' + ); + } + let WSServer = wsEngine.Server; + + if (opts.authPrivateKey != null || opts.authPublicKey != null) { + if (opts.authPrivateKey == null) { + throw new InvalidOptionsError( + 'The authPrivateKey option must be specified if authPublicKey is specified' + ); + } else if (opts.authPublicKey == null) { + throw new InvalidOptionsError( + 'The authPublicKey option must be specified if authPrivateKey is specified' + ); + } + this.signatureKey = opts.authPrivateKey; + this.verificationKey = opts.authPublicKey; + } else { + if (opts.authKey == null) { + opts.authKey = crypto.randomBytes(32).toString('hex'); + } + this.signatureKey = opts.authKey; + this.verificationKey = opts.authKey; + } + + this.defaultVerificationOptions = {}; + if (opts.authVerifyAlgorithms != null) { + this.defaultVerificationOptions.algorithms = opts.authVerifyAlgorithms; + } else if (opts.authAlgorithm != null) { + this.defaultVerificationOptions.algorithms = [opts.authAlgorithm]; + } + + this.defaultSignatureOptions = { + expiresIn: opts.authDefaultExpiry + }; + if (opts.authAlgorithm != null) { + this.defaultSignatureOptions.algorithm = opts.authAlgorithm; + } + + if (opts.authEngine) { + this.auth = opts.authEngine; + } else { + // Default authentication engine + this.auth = new AuthEngine(); + } + + if (opts.codecEngine) { + this.codec = opts.codecEngine; + } else { + // Default codec engine + this.codec = formatter; + } + this.brokerEngine.setCodecEngine(this.codec); + this.exchange = this.brokerEngine.exchange(); + + this.clients = {}; + this.clientsCount = 0; + + this.pendingClients = {}; + this.pendingClientsCount = 0; + + let wsServerOptions = opts.wsEngineServerOptions || {}; + wsServerOptions.server = this.httpServer; + wsServerOptions.verifyClient = this.verifyHandshake.bind(this); + + if (wsServerOptions.path == null && this._path != null) { + wsServerOptions.path = this._path; + } + if (wsServerOptions.perMessageDeflate == null && this.perMessageDeflate != null) { + wsServerOptions.perMessageDeflate = this.perMessageDeflate; + } + if (wsServerOptions.handleProtocols == null && opts.handleProtocols != null) { + wsServerOptions.handleProtocols = opts.handleProtocols; + } + if (wsServerOptions.maxPayload == null && opts.maxPayload != null) { + wsServerOptions.maxPayload = opts.maxPayload; + } + if (wsServerOptions.clientTracking == null) { + wsServerOptions.clientTracking = false; + } + + this.wsServer = new WSServer(wsServerOptions); + + this.wsServer.on('error', this._handleServerError.bind(this)); + this.wsServer.on('connection', this._handleSocketConnection.bind(this)); +} + +AGServer.prototype = Object.create(AsyncStreamEmitter.prototype); + +AGServer.prototype.SYMBOL_MIDDLEWARE_HANDSHAKE_STREAM = AGServer.SYMBOL_MIDDLEWARE_HANDSHAKE_STREAM = Symbol('handshakeStream'); + +AGServer.prototype.MIDDLEWARE_HANDSHAKE = AGServer.MIDDLEWARE_HANDSHAKE = 'handshake'; +AGServer.prototype.MIDDLEWARE_INBOUND_RAW = AGServer.MIDDLEWARE_INBOUND_RAW = 'inboundRaw'; +AGServer.prototype.MIDDLEWARE_INBOUND = AGServer.MIDDLEWARE_INBOUND = 'inbound'; +AGServer.prototype.MIDDLEWARE_OUTBOUND = AGServer.MIDDLEWARE_OUTBOUND = 'outbound'; + +AGServer.prototype.setAuthEngine = function (authEngine) { + this.auth = authEngine; +}; + +AGServer.prototype.setCodecEngine = function (codecEngine) { + this.codec = codecEngine; + this.brokerEngine.setCodecEngine(codecEngine); +}; + +AGServer.prototype.emitError = function (error) { + this.emit('error', {error}); +}; + +AGServer.prototype.emitWarning = function (warning) { + this.emit('warning', {warning}); +}; + +AGServer.prototype._handleServerError = function (error) { + if (typeof error === 'string') { + error = new ServerProtocolError(error); + } + this.emitError(error); +}; + +AGServer.prototype._handleSocketConnection = function (wsSocket, upgradeReq) { + if (!wsSocket.upgradeReq) { + // Normalize ws modules to match. + wsSocket.upgradeReq = upgradeReq; + } + + let socketId = this.generateId(); + + let agSocket = new AGServerSocket(socketId, this, wsSocket, this.protocolVersion); + agSocket.exchange = this.exchange; + + let inboundRawMiddleware = this._middleware[this.MIDDLEWARE_INBOUND_RAW]; + if (inboundRawMiddleware) { + inboundRawMiddleware(agSocket.middlewareInboundRawStream); + } + + let inboundMiddleware = this._middleware[this.MIDDLEWARE_INBOUND]; + if (inboundMiddleware) { + inboundMiddleware(agSocket.middlewareInboundStream); + } + + let outboundMiddleware = this._middleware[this.MIDDLEWARE_OUTBOUND]; + if (outboundMiddleware) { + outboundMiddleware(agSocket.middlewareOutboundStream); + } + + // Emit event to signal that a socket handshake has been initiated. + this.emit('handshake', {socket: agSocket}); +}; + +AGServer.prototype.close = function (keepSocketsOpen) { + this.isReady = false; + return new Promise((resolve, reject) => { + this.wsServer.close((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + if (!keepSocketsOpen) { + for (let socket of Object.values(this.clients)) { + socket.terminate(); + } + } + }); +}; + +AGServer.prototype.getPath = function () { + return this._path; +}; + +AGServer.prototype.generateId = function () { + return base64id.generateId(); +}; + +AGServer.prototype.setMiddleware = function (type, middleware) { + if ( + type !== this.MIDDLEWARE_HANDSHAKE && + type !== this.MIDDLEWARE_INBOUND_RAW && + type !== this.MIDDLEWARE_INBOUND && + type !== this.MIDDLEWARE_OUTBOUND + ) { + throw new InvalidArgumentsError( + `Middleware ${type} type is not supported` + ); + } + if (this._middleware[type]) { + throw new InvalidActionError(`Middleware ${type} type has already been set`); + } + this._middleware[type] = middleware; +}; + +AGServer.prototype.removeMiddleware = function (type) { + delete this._middleware[type]; +}; + +AGServer.prototype.hasMiddleware = function (type) { + return !!this._middleware[type]; +}; + +AGServer.prototype._processMiddlewareAction = async function (middlewareStream, action, socket) { + if (!this.hasMiddleware(middlewareStream.type)) { + return {data: action.data, options: null}; + } + middlewareStream.write(action); + + let newData; + let options = null; + try { + let result = await action.promise; + if (result) { + newData = result.data; + options = result.options; + } + } catch (error) { + let clientError; + if (!error) { + error = new SilentMiddlewareBlockedError( + `The ${action.type} AGAction was blocked by ${middlewareStream.type} middleware`, + middlewareStream.type + ); + clientError = error; + } else if (error.silent) { + clientError = new SilentMiddlewareBlockedError( + `The ${action.type} AGAction was blocked by ${middlewareStream.type} middleware`, + middlewareStream.type + ); + } else { + clientError = error; + } + if (this.middlewareEmitFailures) { + if (socket) { + socket.emitError(error); + } else { + this.emitWarning(error); + } + } + throw clientError; + } + + if (newData === undefined) { + newData = action.data; + } + + return {data: newData, options}; +}; + +AGServer.prototype.verifyHandshake = async function (info, callback) { + let req = info.req; + let origin = info.origin; + if (typeof origin !== 'string' || origin === 'null') { + origin = '*'; + } + let ok = false; + + if (this._allowAllOrigins) { + ok = true; + } else { + try { + let parts = url.parse(origin); + parts.port = parts.port || (parts.protocol === 'https:' ? 443 : 80); + ok = ~this.origins.indexOf(parts.hostname + ':' + parts.port) || + ~this.origins.indexOf(parts.hostname + ':*') || + ~this.origins.indexOf('*:' + parts.port); + } catch (e) {} + } + + let middlewareHandshakeStream = new WritableConsumableStream(); + middlewareHandshakeStream.type = this.MIDDLEWARE_HANDSHAKE; + + req[this.SYMBOL_MIDDLEWARE_HANDSHAKE_STREAM] = middlewareHandshakeStream; + + let handshakeMiddleware = this._middleware[this.MIDDLEWARE_HANDSHAKE]; + if (handshakeMiddleware) { + handshakeMiddleware(middlewareHandshakeStream); + } + + let action = new AGAction(); + action.request = req; + action.type = AGAction.HANDSHAKE_WS; + + try { + await this._processMiddlewareAction(middlewareHandshakeStream, action); + } catch (error) { + middlewareHandshakeStream.close(); + callback(false, 401, typeof error === 'string' ? error : error.message); + return; + } + + if (ok) { + callback(true); + return; + } + + let error = new ServerProtocolError( + `Failed to authorize socket handshake - Invalid origin: ${origin}` + ); + this.emitWarning(error); + + middlewareHandshakeStream.close(); + callback(false, 403, error.message); +}; + +module.exports = AGServer; diff --git a/serversocket.js b/serversocket.js new file mode 100644 index 0000000..3071f17 --- /dev/null +++ b/serversocket.js @@ -0,0 +1,1575 @@ +const cloneDeep = require('clone-deep'); +const WritableConsumableStream = require('writable-consumable-stream'); +const StreamDemux = require('stream-demux'); +const AsyncStreamEmitter = require('async-stream-emitter'); +const AGAction = require('./action'); +const AGRequest = require('ag-request'); + +const scErrors = require('sc-errors'); +const InvalidArgumentsError = scErrors.InvalidArgumentsError; +const SocketProtocolError = scErrors.SocketProtocolError; +const TimeoutError = scErrors.TimeoutError; +const BadConnectionError = scErrors.BadConnectionError; +const InvalidActionError = scErrors.InvalidActionError; +const AuthError = scErrors.AuthError; +const AuthTokenExpiredError = scErrors.AuthTokenExpiredError; +const AuthTokenInvalidError = scErrors.AuthTokenInvalidError; +const AuthTokenNotBeforeError = scErrors.AuthTokenNotBeforeError; +const AuthTokenError = scErrors.AuthTokenError; +const BrokerError = scErrors.BrokerError; + +const HANDSHAKE_REJECTION_STATUS_CODE = 4008; + +function AGServerSocket(id, server, socket, protocolVersion) { + AsyncStreamEmitter.call(this); + + this.id = id; + this.server = server; + this.socket = socket; + this.state = this.CONNECTING; + this.authState = this.UNAUTHENTICATED; + this.protocolVersion = protocolVersion; + + this._receiverDemux = new StreamDemux(); + this._procedureDemux = new StreamDemux(); + + this.request = this.socket.upgradeReq; + + this.inboundReceivedMessageCount = 0; + this.inboundProcessedMessageCount = 0; + + this.outboundPreparedMessageCount = 0; + this.outboundSentMessageCount = 0; + + this.createRequest = this.server.options.requestCreator || this.defaultRequestCreator; + this.cloneData = this.server.options.cloneData; + + this.inboundMessageStream = new WritableConsumableStream(); + this.outboundPacketStream = new WritableConsumableStream(); + + this.middlewareHandshakeStream = this.request[this.server.SYMBOL_MIDDLEWARE_HANDSHAKE_STREAM]; + + this.middlewareInboundRawStream = new WritableConsumableStream(); + this.middlewareInboundRawStream.type = this.server.MIDDLEWARE_INBOUND_RAW; + + this.middlewareInboundStream = new WritableConsumableStream(); + this.middlewareInboundStream.type = this.server.MIDDLEWARE_INBOUND; + + this.middlewareOutboundStream = new WritableConsumableStream(); + this.middlewareOutboundStream.type = this.server.MIDDLEWARE_OUTBOUND; + + if (this.request.connection) { + this.remoteAddress = this.request.connection.remoteAddress; + this.remoteFamily = this.request.connection.remoteFamily; + this.remotePort = this.request.connection.remotePort; + } else { + this.remoteAddress = this.request.remoteAddress; + this.remoteFamily = this.request.remoteFamily; + this.remotePort = this.request.remotePort; + } + if (this.request.forwardedForAddress) { + this.forwardedForAddress = this.request.forwardedForAddress; + } + + this.isBufferingBatch = false; + this.isBatching = false; + this.batchOnHandshake = this.server.options.batchOnHandshake; + this.batchOnHandshakeDuration = this.server.options.batchOnHandshakeDuration; + this.batchInterval = this.server.options.batchInterval; + this._batchBuffer = []; + + this._batchingIntervalId = null; + this._cid = 1; + this._callbackMap = {}; + + this.channelSubscriptions = {}; + this.channelSubscriptionsCount = 0; + + this.socket.on('error', (err) => { + this.emitError(err); + }); + + this.socket.on('close', (code, reasonBuffer) => { + let reason = reasonBuffer.toString(); + this._destroy(code, reason); + }); + + let pongMessage; + if (this.protocolVersion === 1) { + pongMessage = '#2'; + this._sendPing = () => { + if (this.state !== this.CLOSED) { + this.send('#1'); + } + }; + } else { + pongMessage = ''; + this._sendPing = () => { + if (this.state !== this.CLOSED) { + this.send(''); + } + }; + } + + if (!this.server.pingTimeoutDisabled) { + this._pingIntervalTicker = setInterval(() => { + this._sendPing(); + }, this.server.pingInterval); + } + this._resetPongTimeout(); + + this._handshakeTimeoutRef = setTimeout(() => { + this._handleHandshakeTimeout(); + }, this.server.handshakeTimeout); + + this.server.pendingClients[this.id] = this; + this.server.pendingClientsCount++; + + this._handleInboundMessageStream(pongMessage); + this._handleOutboundPacketStream(); + + // Receive incoming raw messages + this.socket.on('message', async (messageBuffer, isBinary) => { + let message = isBinary ? messageBuffer : messageBuffer.toString(); + this.inboundReceivedMessageCount++; + + let isPong = message === pongMessage; + + if (isPong) { + this._resetPongTimeout(); + } + + if (this.server.hasMiddleware(this.server.MIDDLEWARE_INBOUND_RAW)) { + let action = new AGAction(); + action.socket = this; + action.type = AGAction.MESSAGE; + action.data = message; + + try { + let {data} = await this.server._processMiddlewareAction(this.middlewareInboundRawStream, action, this); + message = data; + } catch (error) { + this.inboundProcessedMessageCount++; + return; + } + } + + this.inboundMessageStream.write(message); + this.emit('message', {message}); + }); +} + +AGServerSocket.prototype = Object.create(AsyncStreamEmitter.prototype); + +AGServerSocket.CONNECTING = AGServerSocket.prototype.CONNECTING = 'connecting'; +AGServerSocket.OPEN = AGServerSocket.prototype.OPEN = 'open'; +AGServerSocket.CLOSED = AGServerSocket.prototype.CLOSED = 'closed'; + +AGServerSocket.AUTHENTICATED = AGServerSocket.prototype.AUTHENTICATED = 'authenticated'; +AGServerSocket.UNAUTHENTICATED = AGServerSocket.prototype.UNAUTHENTICATED = 'unauthenticated'; + +AGServerSocket.ignoreStatuses = scErrors.socketProtocolIgnoreStatuses; +AGServerSocket.errorStatuses = scErrors.socketProtocolErrorStatuses; + +AGServerSocket.prototype.getBackpressure = function () { + return Math.max( + this.getInboundBackpressure(), + this.getOutboundBackpressure(), + this.getAllListenersBackpressure(), + this.getAllReceiversBackpressure(), + this.getAllProceduresBackpressure() + ); +}; + +AGServerSocket.prototype.getInboundBackpressure = function () { + return this.inboundReceivedMessageCount - this.inboundProcessedMessageCount; +}; + +AGServerSocket.prototype.getOutboundBackpressure = function () { + return this.outboundPreparedMessageCount - this.outboundSentMessageCount; +}; + +AGServerSocket.prototype._startBatchOnHandshake = function () { + this._startBatching(); + setTimeout(() => { + if (!this.isBatching) { + this._stopBatching(); + } + }, this.batchOnHandshakeDuration); +}; + +AGServerSocket.prototype.defaultRequestCreator = function (socket, id, procedureName, data) { + return new AGRequest(socket, id, procedureName, data); +}; + +// ---- Receiver logic ---- + +AGServerSocket.prototype.receiver = function (receiverName) { + return this._receiverDemux.stream(receiverName); +}; + +AGServerSocket.prototype.closeReceiver = function (receiverName) { + this._receiverDemux.close(receiverName); +}; + +AGServerSocket.prototype.closeAllReceivers = function () { + this._receiverDemux.closeAll(); +}; + +AGServerSocket.prototype.killReceiver = function (receiverName) { + this._receiverDemux.kill(receiverName); +}; + +AGServerSocket.prototype.killAllReceivers = function () { + this._receiverDemux.killAll(); +}; + +AGServerSocket.prototype.killReceiverConsumer = function (consumerId) { + this._receiverDemux.killConsumer(consumerId); +}; + +AGServerSocket.prototype.getReceiverConsumerStats = function (consumerId) { + return this._receiverDemux.getConsumerStats(consumerId); +}; + +AGServerSocket.prototype.getReceiverConsumerStatsList = function (receiverName) { + return this._receiverDemux.getConsumerStatsList(receiverName); +}; + +AGServerSocket.prototype.getAllReceiversConsumerStatsList = function () { + return this._receiverDemux.getConsumerStatsListAll(); +}; + +AGServerSocket.prototype.getReceiverBackpressure = function (receiverName) { + return this._receiverDemux.getBackpressure(receiverName); +}; + +AGServerSocket.prototype.getAllReceiversBackpressure = function () { + return this._receiverDemux.getBackpressureAll(); +}; + +AGServerSocket.prototype.getReceiverConsumerBackpressure = function (consumerId) { + return this._receiverDemux.getConsumerBackpressure(consumerId); +}; + +AGServerSocket.prototype.hasReceiverConsumer = function (receiverName, consumerId) { + return this._receiverDemux.hasConsumer(receiverName, consumerId); +}; + +AGServerSocket.prototype.hasAnyReceiverConsumer = function (consumerId) { + return this._receiverDemux.hasConsumerAll(consumerId); +}; + +// ---- Procedure logic ---- + +AGServerSocket.prototype.procedure = function (procedureName) { + return this._procedureDemux.stream(procedureName); +}; + +AGServerSocket.prototype.closeProcedure = function (procedureName) { + this._procedureDemux.close(procedureName); +}; + +AGServerSocket.prototype.closeAllProcedures = function () { + this._procedureDemux.closeAll(); +}; + +AGServerSocket.prototype.killProcedure = function (procedureName) { + this._procedureDemux.kill(procedureName); +}; + +AGServerSocket.prototype.killAllProcedures = function () { + this._procedureDemux.killAll(); +}; + +AGServerSocket.prototype.killProcedureConsumer = function (consumerId) { + this._procedureDemux.killConsumer(consumerId); +}; + +AGServerSocket.prototype.getProcedureConsumerStats = function (consumerId) { + return this._procedureDemux.getConsumerStats(consumerId); +}; + +AGServerSocket.prototype.getProcedureConsumerStatsList = function (procedureName) { + return this._procedureDemux.getConsumerStatsList(procedureName); +}; + +AGServerSocket.prototype.getAllProceduresConsumerStatsList = function () { + return this._procedureDemux.getConsumerStatsListAll(); +}; + +AGServerSocket.prototype.getProcedureBackpressure = function (procedureName) { + return this._procedureDemux.getBackpressure(procedureName); +}; + +AGServerSocket.prototype.getAllProceduresBackpressure = function () { + return this._procedureDemux.getBackpressureAll(); +}; + +AGServerSocket.prototype.getProcedureConsumerBackpressure = function (consumerId) { + return this._procedureDemux.getConsumerBackpressure(consumerId); +}; + +AGServerSocket.prototype.hasProcedureConsumer = function (procedureName, consumerId) { + return this._procedureDemux.hasConsumer(procedureName, consumerId); +}; + +AGServerSocket.prototype.hasAnyProcedureConsumer = function (consumerId) { + return this._procedureDemux.hasConsumerAll(consumerId); +}; + +AGServerSocket.prototype._handleInboundMessageStream = async function (pongMessage) { + for await (let message of this.inboundMessageStream) { + this.inboundProcessedMessageCount++; + let isPong = message === pongMessage; + + if (isPong) { + if (this.server.strictHandshake && this.state === this.CONNECTING) { + this._destroy(4009); + this.socket.close(4009); + continue; + } + let token = this.getAuthToken(); + if (this.isAuthTokenExpired(token)) { + this.deauthenticate(); + } + continue; + } + + let packet; + try { + packet = this.decode(message); + } catch (error) { + if (error.name === 'Error') { + error.name = 'InvalidMessageError'; + } + this.emitError(error); + if (this.server.strictHandshake && this.state === this.CONNECTING) { + this._destroy(4009); + this.socket.close(4009); + } + continue; + } + + if (Array.isArray(packet)) { + let len = packet.length; + for (let i = 0; i < len; i++) { + await this._processInboundPacket(packet[i], message); + } + } else { + await this._processInboundPacket(packet, message); + } + } +}; + +AGServerSocket.prototype._handleHandshakeTimeout = function () { + this.disconnect(4005); +}; + +AGServerSocket.prototype._processHandshakeRequest = async function (request) { + let data = request.data || {}; + let signedAuthToken = data.authToken || null; + clearTimeout(this._handshakeTimeoutRef); + + let authInfo = await this._validateAuthToken(signedAuthToken); + + let action = new AGAction(); + action.request = this.request; + action.socket = this; + action.type = AGAction.HANDSHAKE_SC; + action.data = authInfo; + + try { + await this.server._processMiddlewareAction(this.middlewareHandshakeStream, action); + } catch (error) { + if (error.statusCode == null) { + error.statusCode = HANDSHAKE_REJECTION_STATUS_CODE; + } + request.error(error); + this.disconnect(error.statusCode); + return; + } + + let clientSocketStatus = { + id: this.id, + pingTimeout: this.server.pingTimeout + }; + let serverSocketStatus = { + id: this.id, + pingTimeout: this.server.pingTimeout + }; + + let oldAuthState = this.authState; + try { + await this._processAuthentication(authInfo); + if (this.state === this.CLOSED) { + return; + } + } catch (error) { + if (signedAuthToken != null) { + // Because the token is optional as part of the handshake, we don't count + // it as an error if the token wasn't provided. + clientSocketStatus.authError = scErrors.dehydrateError(error); + serverSocketStatus.authError = error; + + if (error.isBadToken) { + this.deauthenticate(); + } + } + } + clientSocketStatus.isAuthenticated = !!this.authToken; + serverSocketStatus.isAuthenticated = clientSocketStatus.isAuthenticated; + + if (this.server.pendingClients[this.id]) { + delete this.server.pendingClients[this.id]; + this.server.pendingClientsCount--; + } + this.server.clients[this.id] = this; + this.server.clientsCount++; + + this.state = this.OPEN; + + if (clientSocketStatus.isAuthenticated) { + // Needs to be executed after the connection event to allow + // consumers to be setup from inside the connection loop. + (async () => { + await this.listener('connect').once(); + this.triggerAuthenticationEvents(oldAuthState); + })(); + } + + // Treat authentication failure as a 'soft' error + request.end(clientSocketStatus); + + if (this.batchOnHandshake) { + this._startBatchOnHandshake(); + } + + this.emit('connect', serverSocketStatus); + this.server.emit('connection', {socket: this, ...serverSocketStatus}); + + this.middlewareHandshakeStream.close(); +}; + +AGServerSocket.prototype._processAuthenticateRequest = async function (request) { + let signedAuthToken = request.data; + let oldAuthState = this.authState; + let authInfo = await this._validateAuthToken(signedAuthToken); + try { + await this._processAuthentication(authInfo); + } catch (error) { + if (error.isBadToken) { + this.deauthenticate(); + request.error(error); + return; + } + + request.end({ + isAuthenticated: !!this.authToken, + authError: signedAuthToken == null ? null : scErrors.dehydrateError(error) + }); + return; + } + this.triggerAuthenticationEvents(oldAuthState); + request.end({ + isAuthenticated: !!this.authToken, + authError: null + }); +}; + +AGServerSocket.prototype._subscribeSocket = async function (channelName, subscriptionOptions) { + if (this.server.socketChannelLimit && this.channelSubscriptionsCount >= this.server.socketChannelLimit) { + throw new InvalidActionError( + `Socket ${this.id} tried to exceed the channel subscription limit of ${this.server.socketChannelLimit}` + ); + } + + if (this.channelSubscriptionsCount == null) { + this.channelSubscriptionsCount = 0; + } + if (this.channelSubscriptions[channelName] == null) { + this.channelSubscriptions[channelName] = true; + this.channelSubscriptionsCount++; + } + + try { + await this.server.brokerEngine.subscribeSocket(this, channelName); + } catch (error) { + delete this.channelSubscriptions[channelName]; + this.channelSubscriptionsCount--; + throw error; + } + this.emit('subscribe', { + channel: channelName, + subscriptionOptions + }); + this.server.emit('subscription', { + socket: this, + channel: channelName, + subscriptionOptions + }); +}; + +AGServerSocket.prototype._processSubscribeRequest = async function (request) { + if (this.state === this.OPEN) { + let subscriptionOptions = Object.assign({}, request.data); + let channelName = subscriptionOptions.channel; + delete subscriptionOptions.channel; + + try { + await this._subscribeSocket(channelName, subscriptionOptions); + } catch (err) { + let error = new BrokerError(`Failed to subscribe socket to the ${channelName} channel - ${err}`); + this.emitError(error); + request.error(error); + return; + } + + request.end(); + return; + } + // This is an invalid state; it means the client tried to subscribe before + // having completed the handshake. + let error = new InvalidActionError('Cannot subscribe socket to a channel before it has completed the handshake'); + this.emitError(error); + request.error(error); +}; + +AGServerSocket.prototype._unsubscribeFromAllChannels = function () { + const channels = Object.keys(this.channelSubscriptions); + return Promise.all(channels.map((channel) => this._unsubscribe(channel))); +}; + +AGServerSocket.prototype._unsubscribe = async function (channel) { + if (!this.channelSubscriptions[channel]) { + throw new InvalidActionError( + `Socket ${this.id} tried to unsubscribe from a channel which it is not subscribed to` + ); + } + try { + await this.server.brokerEngine.unsubscribeSocket(this, channel); + delete this.channelSubscriptions[channel]; + if (this.channelSubscriptionsCount != null) { + this.channelSubscriptionsCount--; + } + this.emit('unsubscribe', {channel}); + this.server.emit('unsubscription', {socket: this, channel}); + } catch (err) { + const error = new BrokerError( + `Failed to unsubscribe socket from the ${channel} channel - ${err}` + ); + this.emitError(error); + } +}; + +AGServerSocket.prototype._processUnsubscribePacket = async function (packet) { + let channel = packet.data; + try { + await this._unsubscribe(channel); + } catch (err) { + let error = new BrokerError( + `Failed to unsubscribe socket from the ${channel} channel - ${err}` + ); + this.emitError(error); + } +}; + +AGServerSocket.prototype._processUnsubscribeRequest = async function (request) { + let channel = request.data; + try { + await this._unsubscribe(channel); + } catch (err) { + let error = new BrokerError( + `Failed to unsubscribe socket from the ${channel} channel - ${err}` + ); + this.emitError(error); + request.error(error); + return; + } + request.end(); +}; + +AGServerSocket.prototype._processInboundPublishPacket = async function (packet) { + try { + await this.server.exchange.invokePublish(packet.data.channel, packet.data.data); + } catch (error) { + this.emitError(error); + } +}; + +AGServerSocket.prototype._processInboundPublishRequest = async function (request) { + try { + await this.server.exchange.invokePublish(request.data.channel, request.data.data); + } catch (error) { + this.emitError(error); + request.error(error); + return; + } + request.end(); +}; + +AGServerSocket.prototype._processInboundPacket = async function (packet, message) { + if (packet && typeof packet.event === 'string') { + let eventName = packet.event; + let isRPC = typeof packet.cid === 'number'; + + if (eventName === '#handshake') { + if (!isRPC) { + let error = new InvalidActionError('Handshake request was malformatted'); + this.emitError(error); + this._destroy(HANDSHAKE_REJECTION_STATUS_CODE); + this.socket.close(HANDSHAKE_REJECTION_STATUS_CODE); + return; + } + let request = this.createRequest(this, packet.cid, eventName, packet.data); + await this._processHandshakeRequest(request); + this._procedureDemux.write(eventName, request); + return; + } + if (this.server.strictHandshake && this.state === this.CONNECTING) { + this._destroy(4009); + this.socket.close(4009); + return; + } + if (eventName === '#authenticate') { + if (!isRPC) { + let error = new InvalidActionError('Authenticate request was malformatted'); + this.emitError(error); + this._destroy(HANDSHAKE_REJECTION_STATUS_CODE); + this.socket.close(HANDSHAKE_REJECTION_STATUS_CODE); + return; + } + // Let AGServer handle these events. + let request = this.createRequest(this, packet.cid, eventName, packet.data); + await this._processAuthenticateRequest(request); + this._procedureDemux.write(eventName, request); + return; + } + if (eventName === '#removeAuthToken') { + this.deauthenticateSelf(); + this._receiverDemux.write(eventName, packet.data); + return; + } + + let action = new AGAction(); + action.socket = this; + + let tokenExpiredError = this._processAuthTokenExpiry(); + if (tokenExpiredError) { + action.authTokenExpiredError = tokenExpiredError; + } + + let isPublish = eventName === '#publish'; + let isSubscribe = eventName === '#subscribe'; + let isUnsubscribe = eventName === '#unsubscribe'; + + if (isPublish) { + if (!this.server.allowClientPublish) { + let error = new InvalidActionError('Client publish feature is disabled'); + this.emitError(error); + + if (isRPC) { + let request = this.createRequest(this, packet.cid, eventName, packet.data); + request.error(error); + } + return; + } + if (!packet.data || typeof packet.data.channel !== 'string') { + let error = new InvalidActionError('Publish channel name was malformatted'); + this.emitError(error); + + if (isRPC) { + let request = this.createRequest(this, packet.cid, eventName, packet.data); + request.error(error); + } + return; + } + action.type = AGAction.PUBLISH_IN; + action.channel = packet.data.channel; + action.data = packet.data.data; + } else if (isSubscribe) { + if (!packet.data || typeof packet.data.channel !== 'string') { + let error = new InvalidActionError('Subscribe channel name was malformatted'); + this.emitError(error); + + if (isRPC) { + let request = this.createRequest(this, packet.cid, eventName, packet.data); + request.error(error); + } + return; + } + action.type = AGAction.SUBSCRIBE; + action.channel = packet.data.channel; + action.data = packet.data.data; + } else if (isUnsubscribe) { + if (typeof packet.data !== 'string') { + let error = new InvalidActionError('Unsubscribe channel name was malformatted'); + this.emitError(error); + + if (isRPC) { + let request = this.createRequest(this, packet.cid, eventName, packet.data); + request.error(error); + } + return; + } + if (isRPC) { + let request = this.createRequest(this, packet.cid, eventName, packet.data); + await this._processUnsubscribeRequest(request); + this._procedureDemux.write(eventName, request); + return; + } + await this._processUnsubscribePacket(packet); + this._receiverDemux.write(eventName, packet.data); + return; + } else { + if (isRPC) { + action.type = AGAction.INVOKE; + action.procedure = packet.event; + if (packet.data !== undefined) { + action.data = packet.data; + } + } else { + action.type = AGAction.TRANSMIT; + action.receiver = packet.event; + if (packet.data !== undefined) { + action.data = packet.data; + } + } + } + + let newData; + + if (isRPC) { + let request = this.createRequest(this, packet.cid, eventName, packet.data); + try { + let {data} = await this.server._processMiddlewareAction(this.middlewareInboundStream, action, this); + newData = data; + } catch (error) { + request.error(error); + return; + } + + if (isSubscribe) { + request.data.data = newData; + await this._processSubscribeRequest(request); + } else if (isPublish) { + request.data.data = newData; + await this._processInboundPublishRequest(request); + } else { + request.data = newData; + } + + this._procedureDemux.write(eventName, request); + return; + } + + try { + let {data} = await this.server._processMiddlewareAction(this.middlewareInboundStream, action, this); + newData = data; + } catch (error) { + return; + } + + if (isPublish) { + packet.data.data = newData; + await this._processInboundPublishPacket(packet); + } + + this._receiverDemux.write(eventName, newData); + return; + } + + if (this.server.strictHandshake && this.state === this.CONNECTING) { + this._destroy(4009); + this.socket.close(4009); + return; + } + + if (packet && typeof packet.rid === 'number') { + // If incoming message is a response to a previously sent message + let ret = this._callbackMap[packet.rid]; + if (ret) { + clearTimeout(ret.timeout); + delete this._callbackMap[packet.rid]; + let rehydratedError = scErrors.hydrateError(packet.error); + ret.callback(rehydratedError, packet.data); + } + return; + } + // The last remaining case is to treat the message as raw + this.emit('raw', {message}); +}; + +AGServerSocket.prototype._resetPongTimeout = function () { + if (this.server.pingTimeoutDisabled) { + return; + } + clearTimeout(this._pingTimeoutTicker); + this._pingTimeoutTicker = setTimeout(() => { + this._destroy(4001); + this.socket.close(4001); + }, this.server.pingTimeout); +}; + +AGServerSocket.prototype._nextCallId = function () { + return this._cid++; +}; + +AGServerSocket.prototype.getState = function () { + return this.state; +}; + +AGServerSocket.prototype.getBytesReceived = function () { + return this.socket.bytesReceived; +}; + +AGServerSocket.prototype.emitError = function (error) { + this.emit('error', {error}); + this.server.emitWarning(error); +}; + +AGServerSocket.prototype._abortAllPendingEventsDueToBadConnection = function (failureType, code, reason) { + Object.keys(this._callbackMap || {}).forEach((i) => { + let eventObject = this._callbackMap[i]; + delete this._callbackMap[i]; + + clearTimeout(eventObject.timeout); + delete eventObject.timeout; + + let errorMessage = `Event ${eventObject.event} was aborted due to a bad connection`; + let badConnectionError = new BadConnectionError(errorMessage, failureType, code, reason); + + let callback = eventObject.callback; + delete eventObject.callback; + + callback.call(eventObject, badConnectionError, eventObject); + }); +}; + +AGServerSocket.prototype.closeAllMiddlewares = function () { + this.middlewareHandshakeStream.close(); + this.middlewareInboundRawStream.close(); + this.middlewareInboundStream.close(); + this.middlewareOutboundStream.close(); +}; + +AGServerSocket.prototype.closeInput = function () { + this.inboundMessageStream.close(); +}; + +AGServerSocket.prototype.closeOutput = function () { + this.outboundPacketStream.close(); +}; + +AGServerSocket.prototype.closeIO = function () { + this.closeInput(); + this.closeOutput(); +}; + +AGServerSocket.prototype.closeAllStreams = function () { + this.closeAllMiddlewares(); + this.closeIO(); + + this.closeAllReceivers(); + this.closeAllProcedures(); + this.closeAllListeners(); +}; + +AGServerSocket.prototype.killAllMiddlewares = function () { + this.middlewareHandshakeStream.kill(); + this.middlewareInboundRawStream.kill(); + this.middlewareInboundStream.kill(); + this.middlewareOutboundStream.kill(); +}; + +AGServerSocket.prototype.killInput = function () { + this.inboundMessageStream.kill(); +}; + +AGServerSocket.prototype.killOutput = function () { + this.outboundPacketStream.kill(); +}; + +AGServerSocket.prototype.killIO = function () { + this.killInput(); + this.killOutput(); +}; + +AGServerSocket.prototype.killAllStreams = function () { + this.killAllMiddlewares(); + this.killIO(); + + this.killAllReceivers(); + this.killAllProcedures(); + this.killAllListeners(); +}; + +AGServerSocket.prototype._destroy = async function (code, reason) { + clearInterval(this._pingIntervalTicker); + clearTimeout(this._pingTimeoutTicker); + + this._cancelBatching(); + + if (this.state === this.CLOSED) { + this._abortAllPendingEventsDueToBadConnection('connectAbort', code, reason); + } else { + if (!reason && AGServerSocket.errorStatuses[code]) { + reason = AGServerSocket.errorStatuses[code]; + } + let prevState = this.state; + this.state = this.CLOSED; + if (prevState === this.CONNECTING) { + this._abortAllPendingEventsDueToBadConnection('connectAbort', code, reason); + this.emit('connectAbort', {code, reason}); + this.server.emit('connectionAbort', { + socket: this, + code, + reason + }); + } else { + this._abortAllPendingEventsDueToBadConnection('disconnect', code, reason); + this.emit('disconnect', {code, reason}); + this.server.emit('disconnection', { + socket: this, + code, + reason + }); + } + + this.emit('close', {code, reason}); + this.server.emit('closure', { + socket: this, + code, + reason + }); + + clearTimeout(this._handshakeTimeoutRef); + let isClientFullyConnected = !!this.server.clients[this.id]; + + if (isClientFullyConnected) { + delete this.server.clients[this.id]; + this.server.clientsCount--; + } + + let isClientPending = !!this.server.pendingClients[this.id]; + if (isClientPending) { + delete this.server.pendingClients[this.id]; + this.server.pendingClientsCount--; + } + + if (!AGServerSocket.ignoreStatuses[code]) { + let closeMessage; + if (typeof reason === 'string') { + closeMessage = `Socket connection closed with status code ${code} and reason: ${reason}`; + } else { + closeMessage = `Socket connection closed with status code ${code}`; + } + let err = new SocketProtocolError(AGServerSocket.errorStatuses[code] || closeMessage, code); + this.emitError(err); + } + + await this._unsubscribeFromAllChannels(); + + let cleanupMode = this.server.options.socketStreamCleanupMode; + if (cleanupMode === 'kill') { + (async () => { + await this.listener('end').once(); + this.killAllStreams(); + })(); + } else if (cleanupMode === 'close') { + (async () => { + await this.listener('end').once(); + this.closeAllStreams(); + })(); + } + + this.emit('end'); + } +}; + +AGServerSocket.prototype.disconnect = async function (code, reason) { + code = code || 1000; + + if (typeof code !== 'number') { + let err = new InvalidArgumentsError('If specified, the code argument must be a number'); + this.emitError(err); + } + + if (this.state !== this.CLOSED) { + this._destroy(code, reason); + this.socket.close(code, reason); + } +}; + +AGServerSocket.prototype.terminate = function () { + this.socket.terminate(); +}; + +AGServerSocket.prototype.send = function (data, options) { + this.socket.send(data, options, (error) => { + if (error) { + this.emitError(error); + this._destroy(1006, error.toString()); + } + }); +}; + +AGServerSocket.prototype.decode = function (message) { + return this.server.codec.decode(message); +}; + +AGServerSocket.prototype.encode = function (object) { + return this.server.codec.encode(object); +}; + +AGServerSocket.prototype.startBatch = function () { + this.isBufferingBatch = true; + this._batchBuffer = []; +}; + +AGServerSocket.prototype.flushBatch = function () { + this.isBufferingBatch = false; + if (!this._batchBuffer.length) { + return; + } + let serializedBatch = this.serializeObject(this._batchBuffer); + this._batchBuffer = []; + this.send(serializedBatch); +}; + +AGServerSocket.prototype.cancelBatch = function () { + this.isBufferingBatch = false; + this._batchBuffer = []; +}; + +AGServerSocket.prototype._startBatching = function () { + if (this._batchingIntervalId != null) { + return; + } + this.startBatch(); + this._batchingIntervalId = setInterval(() => { + this.flushBatch(); + this.startBatch(); + }, this.batchInterval); +}; + +AGServerSocket.prototype.startBatching = function () { + this.isBatching = true; + this._startBatching(); +}; + +AGServerSocket.prototype._stopBatching = function () { + if (this._batchingIntervalId != null) { + clearInterval(this._batchingIntervalId); + } + this._batchingIntervalId = null; + this.flushBatch(); +}; + +AGServerSocket.prototype.stopBatching = function () { + this.isBatching = false; + this._stopBatching(); +}; + +AGServerSocket.prototype._cancelBatching = function () { + if (this._batchingIntervalId != null) { + clearInterval(this._batchingIntervalId); + } + this._batchingIntervalId = null; + this.cancelBatch(); +}; + +AGServerSocket.prototype.cancelBatching = function () { + this.isBatching = false; + this._cancelBatching(); +}; + +AGServerSocket.prototype.serializeObject = function (object) { + let str; + try { + str = this.encode(object); + } catch (error) { + this.emitError(error); + return null; + } + return str; +}; + +AGServerSocket.prototype.sendObject = function (object) { + if (this.isBufferingBatch) { + this._batchBuffer.push(object); + return; + } + let str = this.serializeObject(object); + if (str != null) { + this.send(str); + } +}; + +AGServerSocket.prototype._handleOutboundPacketStream = async function () { + for await (let packet of this.outboundPacketStream) { + if (packet.resolve) { + // Invoke has no middleware, so there is no need to await here. + (async () => { + let result; + try { + result = await this._invoke(packet.event, packet.data, packet.options); + } catch (error) { + packet.reject(error); + return; + } + packet.resolve(result); + })(); + + this.outboundSentMessageCount++; + continue; + } + await this._processTransmit(packet.event, packet.data, packet.options); + this.outboundSentMessageCount++; + } +}; + +AGServerSocket.prototype._transmit = async function (event, data, options) { + if (this.cloneData) { + data = cloneDeep(data); + } + this.outboundPreparedMessageCount++; + this.outboundPacketStream.write({ + event, + data, + options + }); +}; + +AGServerSocket.prototype.transmit = async function (event, data, options) { + if (this.state !== this.OPEN) { + let error = new BadConnectionError( + `Socket transmit ${event} event was aborted due to a bad connection`, + 'connectAbort' + ); + this.emitError(error); + return; + } + this._transmit(event, data, options); +}; + +AGServerSocket.prototype.invoke = async function (event, data, options) { + if (this.state !== this.OPEN) { + let error = new BadConnectionError( + `Socket invoke ${event} event was aborted due to a bad connection`, + 'connectAbort' + ); + this.emitError(error); + throw error; + } + if (this.cloneData) { + data = cloneDeep(data); + } + this.outboundPreparedMessageCount++; + return new Promise((resolve, reject) => { + this.outboundPacketStream.write({ + event, + data, + options, + resolve, + reject + }); + }); +}; + +AGServerSocket.prototype._processTransmit = async function (event, data, options) { + let newData; + let useCache = options ? options.useCache : false; + let packet = {event, data}; + let isPublish = event === '#publish'; + if (isPublish) { + let action = new AGAction(); + action.socket = this; + action.type = AGAction.PUBLISH_OUT; + + if (data !== undefined) { + action.channel = data.channel; + action.data = data.data; + } + useCache = !this.server.hasMiddleware(this.middlewareOutboundStream.type); + + try { + let {data, options} = await this.server._processMiddlewareAction(this.middlewareOutboundStream, action, this); + newData = data; + useCache = options == null ? useCache : options.useCache; + } catch (error) { + return; + } + } else { + newData = packet.data; + } + + if (options && useCache && options.stringifiedData != null && !this.isBufferingBatch) { + // Optimized + this.send(options.stringifiedData); + } else { + let eventObject = { + event + }; + if (isPublish) { + eventObject.data = data || {}; + eventObject.data.data = newData; + } else { + eventObject.data = newData; + } + + this.sendObject(eventObject); + } +}; + +AGServerSocket.prototype._invoke = async function (event, data, options) { + options = options || {}; + + return new Promise((resolve, reject) => { + let eventObject = { + event, + cid: this._nextCallId() + }; + if (data !== undefined) { + eventObject.data = data; + } + + let ackTimeout = options.ackTimeout == null ? this.server.ackTimeout : options.ackTimeout; + + let timeout = setTimeout(() => { + let error = new TimeoutError(`Event response for ${event} event timed out`); + delete this._callbackMap[eventObject.cid]; + reject(error); + }, ackTimeout); + + this._callbackMap[eventObject.cid] = { + event, + callback: (err, result) => { + if (err) { + reject(err); + return; + } + resolve(result); + }, + timeout + }; + + if (options.useCache && options.stringifiedData != null && !this.isBufferingBatch) { + // Optimized + this.send(options.stringifiedData); + } else { + this.sendObject(eventObject); + } + }); +}; + +AGServerSocket.prototype.triggerAuthenticationEvents = function (oldAuthState) { + if (oldAuthState !== this.AUTHENTICATED) { + let stateChangeData = { + oldAuthState, + newAuthState: this.authState, + authToken: this.authToken + }; + this.emit('authStateChange', stateChangeData); + this.server.emit('authenticationStateChange', { + socket: this, + ...stateChangeData + }); + } + this.emit('authenticate', {authToken: this.authToken}); + this.server.emit('authentication', { + socket: this, + authToken: this.authToken + }); +}; + +AGServerSocket.prototype.setAuthToken = async function (data, options) { + if (this.state === this.CONNECTING) { + let err = new InvalidActionError( + 'Cannot call setAuthToken before completing the handshake' + ); + this.emitError(err); + throw err; + } + + let authToken = cloneDeep(data); + let oldAuthState = this.authState; + this.authState = this.AUTHENTICATED; + + if (options == null) { + options = {}; + } else { + options = {...options}; + if (options.algorithm != null) { + delete options.algorithm; + let err = new InvalidArgumentsError( + 'Cannot change auth token algorithm at runtime - It must be specified as a config option on launch' + ); + this.emitError(err); + } + } + + options.mutatePayload = true; + let rejectOnFailedDelivery = options.rejectOnFailedDelivery; + delete options.rejectOnFailedDelivery; + let defaultSignatureOptions = this.server.defaultSignatureOptions; + + // We cannot have the exp claim on the token and the expiresIn option + // set at the same time or else auth.signToken will throw an error. + let expiresIn; + if (options.expiresIn == null) { + expiresIn = defaultSignatureOptions.expiresIn; + } else { + expiresIn = options.expiresIn; + } + if (authToken) { + if (authToken.exp == null) { + options.expiresIn = expiresIn; + } else { + delete options.expiresIn; + } + } else { + options.expiresIn = expiresIn; + } + + // Always use the default algorithm since it cannot be changed at runtime. + if (defaultSignatureOptions.algorithm != null) { + options.algorithm = defaultSignatureOptions.algorithm; + } + + this.authToken = authToken; + + let signedAuthToken; + + try { + signedAuthToken = await this.server.auth.signToken(authToken, this.server.signatureKey, options); + } catch (error) { + this.emitError(error); + this._destroy(4002, error.toString()); + this.socket.close(4002); + throw error; + } + + if (this.authToken === authToken) { + this.signedAuthToken = signedAuthToken; + this.emit('authTokenSigned', {signedAuthToken}); + } + + this.triggerAuthenticationEvents(oldAuthState); + + let tokenData = { + token: signedAuthToken + }; + + if (rejectOnFailedDelivery) { + try { + await this.invoke('#setAuthToken', tokenData); + } catch (err) { + let error; + if (err && typeof err.message === 'string') { + error = new AuthError(`Failed to deliver auth token to client - ${err.message}`); + } else { + error = new AuthError( + 'Failed to confirm delivery of auth token to client due to malformatted error response' + ); + } + this.emitError(error); + throw error; + } + return; + } + this.transmit('#setAuthToken', tokenData); +}; + +AGServerSocket.prototype.getAuthToken = function () { + return this.authToken; +}; + +AGServerSocket.prototype.deauthenticateSelf = function () { + let oldAuthState = this.authState; + let oldAuthToken = this.authToken; + this.signedAuthToken = null; + this.authToken = null; + this.authState = this.UNAUTHENTICATED; + if (oldAuthState !== this.UNAUTHENTICATED) { + let stateChangeData = { + oldAuthState, + newAuthState: this.authState + }; + this.emit('authStateChange', stateChangeData); + this.server.emit('authenticationStateChange', { + socket: this, + ...stateChangeData + }); + } + this.emit('deauthenticate', {oldAuthToken}); + this.server.emit('deauthentication', { + socket: this, + oldAuthToken + }); +}; + +AGServerSocket.prototype.deauthenticate = async function (options) { + this.deauthenticateSelf(); + if (options && options.rejectOnFailedDelivery) { + try { + await this.invoke('#removeAuthToken'); + } catch (error) { + this.emitError(error); + if (options && options.rejectOnFailedDelivery) { + throw error; + } + } + return; + } + this._transmit('#removeAuthToken'); +}; + +AGServerSocket.prototype.kickOut = function (channel, message) { + let channels = channel; + if (!channels) { + channels = Object.keys(this.channelSubscriptions); + } + if (!Array.isArray(channels)) { + channels = [channel]; + } + return Promise.all(channels.map((channelName) => { + this.transmit('#kickOut', {channel: channelName, message}); + return this._unsubscribe(channelName); + })); +}; + +AGServerSocket.prototype.subscriptions = function () { + return Object.keys(this.channelSubscriptions); +}; + +AGServerSocket.prototype.isSubscribed = function (channel) { + return !!this.channelSubscriptions[channel]; +}; + +AGServerSocket.prototype._processAuthTokenExpiry = function () { + let token = this.getAuthToken(); + if (this.isAuthTokenExpired(token)) { + this.deauthenticate(); + + return new AuthTokenExpiredError( + 'The socket auth token has expired', + token.exp + ); + } + return null; +}; + +AGServerSocket.prototype.isAuthTokenExpired = function (token) { + if (token && token.exp != null) { + let currentTime = Date.now(); + let expiryMilliseconds = token.exp * 1000; + return currentTime > expiryMilliseconds; + } + return false; +}; + +AGServerSocket.prototype._processTokenError = function (err) { + if (err) { + if (err.name === 'TokenExpiredError') { + let authError = new AuthTokenExpiredError(err.message, err.expiredAt); + authError.isBadToken = true; + return authError; + } + if (err.name === 'JsonWebTokenError') { + let authError = new AuthTokenInvalidError(err.message); + authError.isBadToken = true; + return authError; + } + if (err.name === 'NotBeforeError') { + let authError = new AuthTokenNotBeforeError(err.message, err.date); + // In this case, the token is good; it's just not active yet. + authError.isBadToken = false; + return authError; + } + let authError = new AuthTokenError(err.message); + authError.isBadToken = true; + return authError; + } + return null; +}; + +AGServerSocket.prototype._emitBadAuthTokenError = function (error, signedAuthToken) { + this.emit('badAuthToken', { + authError: error, + signedAuthToken + }); + this.server.emit('badSocketAuthToken', { + socket: this, + authError: error, + signedAuthToken + }); +}; + +AGServerSocket.prototype._validateAuthToken = async function (signedAuthToken) { + let verificationOptions = Object.assign({}, this.server.defaultVerificationOptions, { + socket: this + }); + + let authToken; + try { + authToken = await this.server.auth.verifyToken(signedAuthToken, this.server.verificationKey, verificationOptions); + } catch (error) { + let authTokenError = this._processTokenError(error); + return { + signedAuthToken, + authTokenError, + authToken: null, + authState: this.UNAUTHENTICATED + }; + } + + return { + signedAuthToken, + authTokenError: null, + authToken, + authState: this.AUTHENTICATED + }; +}; + +AGServerSocket.prototype._processAuthentication = async function ({signedAuthToken, authTokenError, authToken, authState}) { + if (authTokenError) { + this.signedAuthToken = null; + this.authToken = null; + this.authState = this.UNAUTHENTICATED; + + // If the error is related to the JWT being badly formatted, then we will + // treat the error as a socket error. + if (signedAuthToken != null) { + this.emitError(authTokenError); + if (authTokenError.isBadToken) { + this._emitBadAuthTokenError(authTokenError, signedAuthToken); + } + } + throw authTokenError; + } + + this.signedAuthToken = signedAuthToken; + this.authToken = authToken; + this.authState = this.AUTHENTICATED; + + let action = new AGAction(); + action.socket = this; + action.type = AGAction.AUTHENTICATE; + action.signedAuthToken = this.signedAuthToken; + action.authToken = this.authToken; + + try { + await this.server._processMiddlewareAction(this.middlewareInboundStream, action, this); + } catch (error) { + this.authToken = null; + this.authState = this.UNAUTHENTICATED; + + if (error.isBadToken) { + this._emitBadAuthTokenError(error, signedAuthToken); + } + throw error; + } +}; + +module.exports = AGServerSocket; diff --git a/test/integration.js b/test/integration.js index 6fef418..f5a671d 100644 --- a/test/integration.js +++ b/test/integration.js @@ -1,14 +1,13 @@ const assert = require('assert'); const socketClusterServer = require('../'); +const AGAction = require('../action'); const socketClusterClient = require('socketcluster-client'); const localStorage = require('localStorage'); -const SCSimpleBroker = require('sc-simple-broker').SCSimpleBroker; +const AGSimpleBroker = require('ag-simple-broker'); // Add to the global scope like in browser. global.localStorage = localStorage; -let portNumber = 8008; - let clientOptions; let serverOptions; @@ -17,8 +16,12 @@ let allowedUsers = { alice: true }; -const TEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 10; +const PORT_NUMBER = 8008; const WS_ENGINE = 'ws'; +const LOG_WARNINGS = false; +const LOG_ERRORS = false; + +const TEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 10; let validSignedAuthTokenBob = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImJvYiIsImV4cCI6MzE2Mzc1ODk3OTA4MDMxMCwiaWF0IjoxNTAyNzQ3NzQ2fQ.dSZOfsImq4AvCu-Or3Fcmo7JNv1hrV3WqxaiSKkTtAo'; let validSignedAuthTokenAlice = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFsaWNlIiwiaWF0IjoxNTE4NzI4MjU5LCJleHAiOjMxNjM3NTg5NzkwODAzMTB9.XxbzPPnnXrJfZrS0FJwb_EAhIu2VY5i7rGyUThtNLh4'; @@ -42,7 +45,7 @@ async function resolveAfterTimeout(duration, value) { function connectionHandler(socket) { (async () => { for await (let rpc of socket.procedure('login')) { - if (allowedUsers[rpc.data.username]) { + if (rpc.data && allowedUsers[rpc.data.username]) { socket.setAuthToken(rpc.data); rpc.end(); } else { @@ -123,55 +126,86 @@ function connectionHandler(socket) { rpc.end(); } })(); -}; -function destroyTestCase() { - if (client) { - if (client.state !== client.CLOSED) { - client.closeAllListeners(); - client.disconnect(); + (async () => { + for await (let rpc of socket.procedure('proc')) { + rpc.end('success ' + rpc.data); } - } + })(); }; +function bindFailureHandlers(server) { + if (LOG_ERRORS) { + (async () => { + for await (let {error} of server.listener('error')) { + console.error('ERROR', error); + } + })(); + } + if (LOG_WARNINGS) { + (async () => { + for await (let {warning} of server.listener('warning')) { + console.warn('WARNING', warning); + } + })(); + } +} + describe('Integration tests', function () { - beforeEach('Run the server before start', async function () { + beforeEach('Prepare options', async function () { clientOptions = { hostname: '127.0.0.1', - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }; serverOptions = { authKey: 'testkey', wsEngine: WS_ENGINE }; + }); - server = socketClusterServer.listen(portNumber, serverOptions); - - (async () => { - for await (let {socket} of server.listener('connection')) { - connectionHandler(socket); - } - })(); + afterEach('Close server and client after each test', async function () { + if (client) { + client.closeAllListeners(); + client.disconnect(); + } + if (server) { + server.closeAllListeners(); + server.httpServer.close(); + await server.close(); + } + global.localStorage.removeItem('socketcluster.authToken'); + }); - server.addMiddleware(server.MIDDLEWARE_AUTHENTICATE, async function (req) { - if (req.authToken.username === 'alice') { - let err = new Error('Blocked by MIDDLEWARE_AUTHENTICATE'); - err.name = 'AuthenticateMiddlewareError'; - throw err; - } - }); + describe('Client authentication', function () { + beforeEach('Run the server before start', async function () { + server = socketClusterServer.listen(PORT_NUMBER, serverOptions); + bindFailureHandlers(server); + + server.setMiddleware(server.MIDDLEWARE_INBOUND, async (middlewareStream) => { + for await (let action of middlewareStream) { + if ( + action.type === AGAction.AUTHENTICATE && + (!action.authToken || action.authToken.username === 'alice') + ) { + let err = new Error('Blocked by MIDDLEWARE_INBOUND'); + err.name = 'AuthenticateMiddlewareError'; + action.block(err); + continue; + } + action.allow(); + } + }); - await server.listener('ready').once(); - }); + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); - afterEach('Close server after each test', async function () { - portNumber++; - destroyTestCase(); - server.close(); - global.localStorage.removeItem('socketCluster.authToken'); - }); + await server.listener('ready').once(); + }); - describe('Socket authentication', function () { it('Should not send back error if JWT is not provided in handshake', async function () { client = socketClusterClient.create(clientOptions); let event = await client.listener('connect').once(); @@ -192,7 +226,7 @@ describe('Integration tests', function () { }); it('Should send back error if JWT is invalid during handshake', async function () { - global.localStorage.setItem('socketCluster.authToken', validSignedAuthTokenBob); + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); client = socketClusterClient.create(clientOptions); @@ -208,7 +242,7 @@ describe('Integration tests', function () { }); it('Should allow switching between users', async function () { - global.localStorage.setItem('socketCluster.authToken', validSignedAuthTokenBob); + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); let authenticateEvents = []; let deauthenticateEvents = []; @@ -270,7 +304,7 @@ describe('Integration tests', function () { }); it('Should emit correct events/data when socket is deauthenticated', async function () { - global.localStorage.setItem('socketCluster.authToken', validSignedAuthTokenBob); + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); let authenticationStateChangeEvents = []; let authStateChangeEvents = []; @@ -322,28 +356,57 @@ describe('Integration tests', function () { assert.equal(authenticationStateChangeEvents[1].authToken, null); }); - it('Should not authenticate the client if MIDDLEWARE_AUTHENTICATE blocks the authentication', async function () { - global.localStorage.setItem('socketCluster.authToken', validSignedAuthTokenAlice); + it('Should throw error if server socket deauthenticate is called after client disconnected and rejectOnFailedDelivery is true', async function () { + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); + + client = socketClusterClient.create(clientOptions); + + let {socket} = await server.listener('connection').once(); + + client.disconnect(); + let error; + try { + await socket.deauthenticate({rejectOnFailedDelivery: true}); + } catch (err) { + error = err; + } + assert.notEqual(error, null); + assert.equal(error.name, 'BadConnectionError'); + }); + + it('Should not throw error if server socket deauthenticate is called after client disconnected and rejectOnFailedDelivery is not true', async function () { + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); + + client = socketClusterClient.create(clientOptions); + + let {socket} = await server.listener('connection').once(); + + client.disconnect(); + socket.deauthenticate(); + }); + + it('Should not authenticate the client if MIDDLEWARE_INBOUND blocks the authentication', async function () { + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenAlice); client = socketClusterClient.create(clientOptions); // The previous test authenticated us as 'alice', so that token will be passed to the server as // part of the handshake. - let event = await client.listener('connect').once(); - // Any token containing the username 'alice' should be blocked by the MIDDLEWARE_AUTHENTICATE middleware. + // Any token containing the username 'alice' should be blocked by the MIDDLEWARE_INBOUND middleware. // This will only affects token-based authentication, not the credentials-based login event. assert.equal(event.isAuthenticated, false); assert.notEqual(event.authError, null); assert.equal(event.authError.name, 'AuthenticateMiddlewareError'); }); + }); - it('Token should be available after Promise resolves if token engine signing is synchronous', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + describe('Server authentication', function () { + it('Token should be available after the authenticate listener resolves', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - authSignAsync: false + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { @@ -355,7 +418,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); await client.listener('connect').once(); @@ -368,13 +432,13 @@ describe('Integration tests', function () { assert.equal(client.authToken.username, 'bob'); }); - it('If token engine signing is asynchronous, authentication can be captured using the authenticate event', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + it('Authentication can be captured using the authenticate listener', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE, - authSignAsync: true + authTokenName: 'socketcluster.authToken' }); + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { @@ -386,7 +450,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); await client.listener('connect').once(); @@ -399,13 +464,12 @@ describe('Integration tests', function () { assert.equal(client.authToken.username, 'bob'); }); - it('Should still work if token verification is asynchronous', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + it('Previously authenticated client should still be authenticated after reconnecting', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - authVerifyAsync: false + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { @@ -417,7 +481,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); await client.listener('connect').once(); @@ -437,12 +502,11 @@ describe('Integration tests', function () { }); it('Should set the correct expiry when using expiresIn option when creating a JWT with socket.setAuthToken', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - authVerifyAsync: false + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { @@ -454,7 +518,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); await client.listener('connect').once(); @@ -470,12 +535,11 @@ describe('Integration tests', function () { }); it('Should set the correct expiry when adding exp claim when creating a JWT with socket.setAuthToken', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - authVerifyAsync: false + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { @@ -487,7 +551,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); await client.listener('connect').once(); @@ -503,12 +568,11 @@ describe('Integration tests', function () { }); it('The exp claim should have priority over expiresIn option when using socket.setAuthToken', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - authVerifyAsync: false + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { @@ -520,7 +584,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); await client.listener('connect').once(); @@ -536,12 +601,12 @@ describe('Integration tests', function () { }); it('Should send back error if socket.setAuthToken tries to set both iss claim and issuer option', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - authVerifyAsync: false + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); + let warningMap = {}; (async () => { @@ -554,7 +619,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); await client.listener('connect').once(); @@ -604,12 +670,11 @@ describe('Integration tests', function () { }); it('Should trigger an authTokenSigned event and socket.signedAuthToken should be set after calling the socket.setAuthToken method', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - authSignAsync: true + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); let authTokenSignedEventEmitted = false; @@ -626,7 +691,7 @@ describe('Integration tests', function () { (async () => { for await (let req of socket.procedure('login')) { if (allowedUsers[req.data.username]) { - socket.setAuthToken(req.data, {async: true}); + socket.setAuthToken(req.data); req.end(); } else { let err = new Error('Failed to login'); @@ -642,32 +707,36 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); await client.listener('connect').once(); - await client.invoke('login', {username: 'bob'}); - await client.listener('authenticate').once(); + + await Promise.all([ + client.invoke('login', {username: 'bob'}), + client.listener('authenticate').once() + ]); assert.equal(authTokenSignedEventEmitted, true); }); - it('Should reject Promise returned by socket.setAuthToken if token delivery fails and rejectOnFailedDelivery option is true', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + it('The socket.setAuthToken call should reject if token delivery fails and rejectOnFailedDelivery option is true', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE, - authSignAsync: true, ackTimeout: 1000 }); + bindFailureHandlers(server); - let socketErrors = []; + let serverWarnings = []; (async () => { await server.listener('ready').once(); client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); await client.listener('connect').once(); client.invoke('login', {username: 'bob'}); @@ -676,8 +745,8 @@ describe('Integration tests', function () { let {socket} = await server.listener('connection').once(); (async () => { - for await (let {error} of socket.listener('error')) { - socketErrors.push(error); + for await (let {warning} of server.listener('warning')) { + serverWarnings.push(warning); } })(); @@ -694,8 +763,10 @@ describe('Integration tests', function () { assert.notEqual(error, null); assert.equal(error.name, 'AuthError'); await wait(0); - assert.notEqual(socketErrors[0], null); - assert.equal(socketErrors[0].name, 'AuthError'); + assert.notEqual(serverWarnings[0], null); + assert.equal(serverWarnings[0].name, 'BadConnectionError'); + assert.notEqual(serverWarnings[1], null); + assert.equal(serverWarnings[1].name, 'AuthError'); } else { let err = new Error('Failed to login'); err.name = 'FailedLoginError'; @@ -703,22 +774,22 @@ describe('Integration tests', function () { } }); - it('Should not reject Promise returned by socket.setAuthToken if token delivery fails and rejectOnFailedDelivery option is not true', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + it('The socket.setAuthToken call should not reject if token delivery fails and rejectOnFailedDelivery option is not true', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE, - authSignAsync: true, ackTimeout: 1000 }); + bindFailureHandlers(server); - let socketErrors = []; + let serverWarnings = []; (async () => { await server.listener('ready').once(); client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); await client.listener('connect').once(); client.invoke('login', {username: 'bob'}); @@ -727,8 +798,8 @@ describe('Integration tests', function () { let {socket} = await server.listener('connection').once(); (async () => { - for await (let {error} of socket.listener('error')) { - socketErrors.push(error); + for await (let {warning} of server.listener('warning')) { + serverWarnings.push(warning); } })(); @@ -744,8 +815,8 @@ describe('Integration tests', function () { } assert.equal(error, null); await wait(0); - assert.notEqual(socketErrors[0], null); - assert.equal(socketErrors[0].name, 'AuthError'); + assert.notEqual(serverWarnings[0], null); + assert.equal(serverWarnings[0].name, 'BadConnectionError'); } else { let err = new Error('Failed to login'); err.name = 'FailedLoginError'; @@ -754,13 +825,13 @@ describe('Integration tests', function () { }); it('The verifyToken method of the authEngine receives correct params', async function () { - global.localStorage.setItem('socketCluster.authToken', validSignedAuthTokenBob); + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { @@ -772,7 +843,8 @@ describe('Integration tests', function () { await server.listener('ready').once(); client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); })(); @@ -792,11 +864,12 @@ describe('Integration tests', function () { }); it('Should remove client data from the server when client disconnects before authentication process finished', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); + server.setAuthEngine({ verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { return resolveAfterTimeout(500, {}); @@ -812,7 +885,8 @@ describe('Integration tests', function () { await server.listener('ready').once(); client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); let serverSocket; @@ -835,15 +909,56 @@ describe('Integration tests', function () { assert.equal(server.pendingClientsCount, 0); assert.equal(JSON.stringify(server.pendingClients), '{}'); }); + + it('Should close the connection if the client tries to send a malformatted authenticate packet', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let originalInvoke = client.invoke; + client.invoke = async function (...args) { + if (args[0] === '#authenticate') { + client.transmit(args[0], args[1]); + return; + } + return originalInvoke.apply(this, args); + }; + + client.authenticate(validSignedAuthTokenBob) + + let results = await Promise.all([ + server.listener('closure').once(500), + client.listener('close').once(100) + ]); + assert.equal(results[0].code, 4008); + assert.equal(results[0].reason, 'Server rejected handshake from client'); + assert.equal(results[1].code, 4008); + assert.equal(results[1].reason, 'Server rejected handshake from client'); + }); }); describe('Socket handshake', function () { it('Exchange is attached to socket before the handshake event is triggered', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { @@ -855,21 +970,153 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); let {socket} = await server.listener('handshake').once(); assert.notEqual(socket.exchange, null); }); + + it('Should close the connection if the client tries to send a message before the handshake', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + client.transport.socket.onopen = function () { + client.transport.socket.send(Buffer.alloc(0)); + }; + + let results = await Promise.all([ + server.listener('closure').once(200), + client.listener('close').once(200) + ]); + assert.equal(results[0].code, 4009); + assert.equal(results[0].reason, 'Server received a message before the client handshake'); + assert.equal(results[1].code, 4009); + assert.equal(results[1].reason, 'Server received a message before the client handshake'); + }); + + it('Should close the connection if the client tries to send a ping before the handshake', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + client.transport.socket.onopen = function () { + client.transport.socket.send(''); + }; + + let {code: closeCode} = await client.listener('close').once(200); + + assert.equal(closeCode, 4009); + }); + + it('Should close the connection if the client tries to send a malformatted handshake', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + client.transport._handshake = async function () { + this.transmit('#handshake', {}, {force: true}); + }; + + let results = await Promise.all([ + server.listener('closure').once(200), + client.listener('close').once(200) + ]); + assert.equal(results[0].code, 4008); + assert.equal(results[0].reason, 'Server rejected handshake from client'); + assert.equal(results[1].code, 4008); + assert.equal(results[1].reason, 'Server rejected handshake from client'); + }); + + it('Should not close the connection if the client tries to send a message before the handshake and strictHandshake is false', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE, + strictHandshake: false + }); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let realOnOpenFunction = client.transport.socket.onopen; + + client.transport.socket.onopen = function () { + client.transport.socket.send(Buffer.alloc(0)); + return realOnOpenFunction.apply(this, arguments); + }; + + let packet = await client.listener('connect').once(200); + + assert.notEqual(packet, null); + assert.notEqual(packet.id, null); + }); }); describe('Socket connection', function () { it('Server-side socket connect event and server connection event should trigger', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); let connectionEmitted = false; let connectionEvent; @@ -886,7 +1133,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); let connectEmitted = false; @@ -946,15 +1194,102 @@ describe('Integration tests', function () { // a reference to the same object. assert.notEqual(clientConnectStatus.foo, connectStatus.foo); }); + + it('Server-side connection event should trigger with large number of concurrent connections', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + let connectionList = []; + + (async () => { + for await (let event of server.listener('connection')) { + connectionList.push(event); + } + })(); + + await server.listener('ready').once(); + + let clientList = []; + + for (let i = 0; i < 100; i++) { + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + clientList.push(client); + } + + await wait(2000); + + assert.equal(connectionList.length, 100); + + for (let client of clientList) { + client.disconnect(); + } + await wait(1000); + }); + + it('Server should support large a number of connections invoking procedures concurrently immediately upon connect', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + let connectionCount = 0; + let requestCount = 0; + + (async () => { + for await (let { socket } of server.listener('connection')) { + connectionCount++; + (async () => { + for await (let request of socket.procedure('greeting')) { + requestCount++; + await wait(20); + request.end('hello'); + } + })(); + } + })(); + + await server.listener('ready').once(); + + let clientList = []; + for (let i = 0; i < 100; i++) { + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + autoConnect: true, + authTokenName: 'socketcluster.authToken' + }); + clientList.push(client); + await client.invoke('greeting'); + } + + await wait(2500); + + assert.equal(requestCount, 100); + assert.equal(connectionCount, 100); + + for (let client of clientList) { + client.disconnect(); + } + await wait(1000); + }); }); describe('Socket disconnection', function () { it('Server-side socket disconnect event should not trigger if the socket did not complete the handshake; instead, it should trigger connectAbort', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); + server.setAuthEngine({ verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { return resolveAfterTimeout(500, {}); @@ -974,7 +1309,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); let socketDisconnected = false; @@ -1027,11 +1363,12 @@ describe('Integration tests', function () { }); it('Server-side socket disconnect event should trigger if the socket completed the handshake (not connectAbort)', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); + server.setAuthEngine({ verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { return resolveAfterTimeout(10, {}); @@ -1051,7 +1388,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); let socketDisconnected = false; @@ -1105,11 +1443,12 @@ describe('Integration tests', function () { }); it('The close event should trigger when the socket loses the connection before the handshake', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); + server.setAuthEngine({ verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { return resolveAfterTimeout(500, {}); @@ -1127,7 +1466,8 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); let serverSocketClosed = false; @@ -1166,11 +1506,12 @@ describe('Integration tests', function () { }); it('The close event should trigger when the socket loses the connection after the handshake', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); + server.setAuthEngine({ verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { return resolveAfterTimeout(0, {}); @@ -1188,11 +1529,12 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); let serverSocketClosed = false; - let serverSocketDisconnected = false; + let serverDisconnection = false; let serverClosure = false; (async () => { @@ -1206,7 +1548,7 @@ describe('Integration tests', function () { (async () => { for await (let event of server.listener('disconnection')) { - serverSocketDisconnected = true; + serverDisconnection = true; } })(); @@ -1222,118 +1564,172 @@ describe('Integration tests', function () { await wait(1000); assert.equal(serverSocketClosed, true); - assert.equal(serverSocketDisconnected, true); + assert.equal(serverDisconnection, true); assert.equal(serverClosure, true); }); - }); - describe('Socket pub/sub', function () { - it('Should support subscription batching', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + it('Disconnection should support socket message backpressure', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); + + let serverWarnings = []; + (async () => { + for await (let {warning} of server.listener('warning')) { + serverWarnings.push(warning); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let currentRequestData = null; + let requestDataAtTimeOfDisconnect = null; (async () => { for await (let {socket} of server.listener('connection')) { + connectionOnServer = true; connectionHandler(socket); - let isFirstMessage = true; (async () => { - for await (let {message} of socket.listener('message')) { - if (isFirstMessage) { - let data = JSON.parse(message); - // All 20 subscriptions should arrive as a single message. - assert.equal(data.length, 20); - isFirstMessage = false; + await socket.listener('disconnect').once(); + requestDataAtTimeOfDisconnect = currentRequestData; + })(); + + (async () => { + for await (let request of socket.procedure('foo')) { + currentRequestData = request.data; + await wait(10); + (async () => { + try { + await socket.invoke('bla', request.data); + } catch (err) {} + })(); + socket.transmit('hi', request.data); + request.end('bar'); + if (request.data === 10) { + client.disconnect(); } } })(); } })(); - let subscribeMiddlewareCounter = 0; + for (let i = 0; i < 30; i++) { + (async () => { + let result; + try { + result = await client.invoke('foo', i); + } catch (error) { + return; + } + })(); + } - // Each subscription should pass through the middleware individually, even - // though they were sent as a batch/array. - server.addMiddleware(server.MIDDLEWARE_SUBSCRIBE, function (req, next) { - subscribeMiddlewareCounter++; - assert.equal(req.channel.indexOf('my-channel-'), 0); - if (req.channel === 'my-channel-10') { - assert.equal(JSON.stringify(req.data), JSON.stringify({foo: 123})); - } else if (req.channel === 'my-channel-12') { - // Block my-channel-12 - let err = new Error('You cannot subscribe to channel 12'); - err.name = 'UnauthorizedSubscribeError'; - next(err); - return; - } - next(); - }); + await wait(200); - await server.listener('ready').once(); + // Expect a server warning (socket error) if a response was sent on a disconnected socket. + assert.equal( + serverWarnings.some((warning) => { + return warning.message.match(/WebSocket is not open/g); + }), + true + ); + + // Expect a server warning (socket error) if transmit was called on a disconnected socket. + assert.equal( + serverWarnings.some((warning) => { + return warning.name === 'BadConnectionError' && warning.message.match(/Socket transmit hi event was aborted/g); + }), + true + ); + + // Expect a server warning (socket error) if invoke was called on a disconnected socket. + assert.equal( + serverWarnings.some((warning) => { + return warning.name === 'BadConnectionError' && warning.message.match(/Socket invoke bla event was aborted/g); + }), + true + ); + + // Check that the disconnect event on the back end socket triggers as soon as possible (out-of-band) and not at the end of the stream. + // Any value less than 30 indicates that the 'disconnect' event was triggerred out-of-band. + // Since the client disconnect() call is executed on the 11th message, we can assume that the 'disconnect' event will trigger sooner. + assert.equal(requestDataAtTimeOfDisconnect < 15, true); + }); - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber + it('Socket streams should be killed immediately if socket disconnects (default/kill mode)', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); - let channelList = []; - for (let i = 0; i < 20; i++) { - let subscribeOptions = { - batch: true - }; - if (i === 10) { - subscribeOptions.data = {foo: 123}; - } - channelList.push( - client.subscribe('my-channel-' + i, subscribeOptions) - ); - } + let handledPackets = []; + let closedReceiver = false; (async () => { - for await (let event of channelList[12].listener('subscribe')) { - throw new Error('The my-channel-12 channel should have been blocked by MIDDLEWARE_SUBSCRIBE'); + for await (let {socket} of server.listener('connection')) { + (async () => { + for await (let packet of socket.receiver('foo')) { + await wait(30); + handledPackets.push(packet); + } + closedReceiver = true; + })(); } })(); - (async () => { - for await (let event of channelList[12].listener('subscribeFail')) { - assert.notEqual(event.error, null); - assert.equal(event.error.name, 'UnauthorizedSubscribeError'); - } - })(); + await server.listener('ready').once(); - (async () => { - for await (let event of channelList[0].listener('subscribe')) { - client.publish('my-channel-19', 'Hello!'); - } - })(); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - for await (let data of channelList[19]) { - assert.equal(data, 'Hello!'); - assert.equal(subscribeMiddlewareCounter, 20); - break; + await wait(100); + + for (let i = 0; i < 15; i++) { + client.transmit('foo', i); } + + await wait(110); + + client.disconnect(4445, 'Disconnect'); + + await wait(500); + assert.equal(handledPackets.length, 4); + assert.equal(closedReceiver, true); }); - it('Client should not be able to subscribe to a channel before the handshake has completed', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + it('Socket streams should be closed eventually if socket disconnects (close mode)', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE + wsEngine: WS_ENGINE, + socketStreamCleanupMode: 'close' }); + bindFailureHandlers(server); - server.setAuthEngine({ - verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { - return resolveAfterTimeout(500, {}); - } - }); + let handledPackets = []; + let closedReceiver = false; (async () => { for await (let {socket} of server.listener('connection')) { - connectionHandler(socket); + (async () => { + for await (let packet of socket.receiver('foo')) { + await wait(30); + handledPackets.push(packet); + } + closedReceiver = true; + })(); } })(); @@ -1341,48 +1737,45 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - let isSubscribed = false; - let error; + await wait(100); - (async () => { - for await (let event of server.listener('subscription')) { - isSubscribed = true; - } - })(); + for (let i = 0; i < 15; i++) { + client.transmit('foo', i); + } - // Hack to capture the error without relying on the standard client flow. - client.transport._callbackMap[2] = { - event: '#subscribe', - data: {"channel":"someChannel"}, - callback: function (err) { - error = err; - } - }; + await wait(110); - // Trick the server by sending a fake subscribe before the handshake is done. - client.transport.socket.on('open', function () { - client.send('{"event":"#subscribe","data":{"channel":"someChannel"},"cid":2}'); - }); + client.disconnect(4445, 'Disconnect'); - await wait(1000); - assert.equal(isSubscribed, false); - assert.notEqual(error, null); - assert.equal(error.name, 'InvalidActionError'); + await wait(500); + assert.equal(handledPackets.length, 15); + assert.equal(closedReceiver, true); }); - it('Server should be able to handle invalid #subscribe and #unsubscribe and #publish events without crashing', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + it('Socket streams should be closed eventually if socket disconnects (none mode)', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE + wsEngine: WS_ENGINE, + socketStreamCleanupMode: 'none' }); + bindFailureHandlers(server); + + let handledPackets = []; + let closedReceiver = false; (async () => { for await (let {socket} of server.listener('connection')) { - connectionHandler(socket); + (async () => { + for await (let packet of socket.receiver('foo')) { + await wait(30); + handledPackets.push(packet); + } + closedReceiver = false; + })(); } })(); @@ -1390,306 +1783,263 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - let nullInChannelArrayError; - let objectAsChannelNameError; - let nullChannelNameError; - let nullUnsubscribeError; + await wait(100); - let undefinedPublishError; - let objectAsChannelNamePublishError; - let nullPublishError; + for (let i = 0; i < 15; i++) { + client.transmit('foo', i); + } - // Hacks to capture the errors without relying on the standard client flow. - client.transport._callbackMap[2] = { - event: '#subscribe', - data: [null], - callback: function (err) { - nullInChannelArrayError = err; - } - }; - client.transport._callbackMap[3] = { - event: '#subscribe', - data: {"channel": {"hello": 123}}, - callback: function (err) { - objectAsChannelNameError = err; - } - }; - client.transport._callbackMap[4] = { - event: '#subscribe', - data: null, - callback: function (err) { - nullChannelNameError = err; - } - }; - client.transport._callbackMap[5] = { - event: '#unsubscribe', - data: [null], - callback: function (err) { - nullUnsubscribeError = err; - } - }; - client.transport._callbackMap[6] = { - event: '#publish', - data: null, - callback: function (err) { - undefinedPublishError = err; - } - }; - client.transport._callbackMap[7] = { - event: '#publish', - data: {"channel": {"hello": 123}}, - callback: function (err) { - objectAsChannelNamePublishError = err; - } - }; - client.transport._callbackMap[8] = { - event: '#publish', - data: {"channel": null}, - callback: function (err) { - nullPublishError = err; - } - }; + await wait(110); + + client.disconnect(4445, 'Disconnect'); + + await wait(500); + assert.equal(handledPackets.length, 15); + assert.equal(closedReceiver, false); + }); + }); + + describe('Socket RPC invoke', function () { + it ('Should support invoking a remote procedure on the server', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); (async () => { - for await (let event of client.listener('connect')) { - // Trick the server by sending a fake subscribe before the handshake is done. - client.send('{"event":"#subscribe","data":[null],"cid":2}'); - client.send('{"event":"#subscribe","data":{"channel":{"hello":123}},"cid":3}'); - client.send('{"event":"#subscribe","data":null,"cid":4}'); - client.send('{"event":"#unsubscribe","data":[null],"cid":5}'); - client.send('{"event":"#publish","data":null,"cid":6}'); - client.send('{"event":"#publish","data":{"channel":{"hello":123}},"cid":7}'); - client.send('{"event":"#publish","data":{"channel":null},"cid":8}'); + for await (let {socket} of server.listener('connection')) { + (async () => { + for await (let req of socket.procedure('customProc')) { + if (req.data.bad) { + let error = new Error('Server failed to execute the procedure'); + error.name = 'BadCustomError'; + req.error(error); + } else { + req.end('Success'); + } + } + })(); } })(); - await wait(300); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - assert.notEqual(nullInChannelArrayError, null); - assert.notEqual(objectAsChannelNameError, null); - assert.notEqual(nullChannelNameError, null); - assert.notEqual(nullUnsubscribeError, null); - assert.notEqual(undefinedPublishError, null); - assert.notEqual(objectAsChannelNamePublishError, null); - assert.notEqual(nullPublishError, null); + let result = await client.invoke('customProc', {good: true}); + assert.equal(result, 'Success'); + + let error; + try { + result = await client.invoke('customProc', {bad: true}); + } catch (err) { + error = err; + } + assert.notEqual(error, null); + assert.equal(error.name, 'BadCustomError'); }); + }); - it('When default SCSimpleBroker broker engine is used, disconnect event should trigger before unsubscribe event', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + describe('Socket transmit', function () { + it ('Should support receiving remote transmitted data on the server', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); - - let eventList = []; + bindFailureHandlers(server); (async () => { - await server.listener('ready').once(); + await wait(10); client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - await client.subscribe('foo').listener('subscribe').once(); - await wait(200); - client.disconnect(); + client.transmit('customRemoteEvent', 'This is data'); })(); - let {socket} = await server.listener('connection').once(); - - (async () => { - for await (let event of socket.listener('unsubscribe')) { - eventList.push({ - type: 'unsubscribe', - channel: event.channel - }); + for await (let {socket} of server.listener('connection')) { + for await (let data of socket.receiver('customRemoteEvent')) { + assert.equal(data, 'This is data'); + break; } - })(); - - let disconnectPacket = await socket.listener('disconnect').once(); - eventList.push({ - type: 'disconnect', - code: disconnectPacket.code, - reason: disconnectPacket.data - }); - - await wait(0); - assert.equal(eventList[0].type, 'disconnect'); - assert.equal(eventList[1].type, 'unsubscribe'); - assert.equal(eventList[1].channel, 'foo'); + break; + } }); + }); - it('When default SCSimpleBroker broker engine is used, scServer.exchange should support consuming data from a channel', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + describe('Socket backpressure', function () { + it('Should be able to getInboundBackpressure() on a socket object', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); + + let backpressureHistory = []; + + server.setMiddleware(server.MIDDLEWARE_INBOUND_RAW, async (middlewareStream) => { + for await (let action of middlewareStream) { + backpressureHistory.push(action.socket.getInboundBackpressure()); + action.allow(); + } + }); + + server.setMiddleware(server.MIDDLEWARE_INBOUND, async (middlewareStream) => { + for await (let action of middlewareStream) { + if (action.data === 5) { + await wait(100); + } + action.allow(); + } + }); await server.listener('ready').once(); client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - (async () => { - await client.listener('connect').once(); - - client.publish('foo', 'hi1'); + await client.listener('connect').once(); + for (let i = 0; i < 20; i++) { await wait(10); - client.publish('foo', 'hi2'); - })(); - - let receivedSubscribedData = []; - let receivedChannelData = []; - - (async () => { - let subscription = server.exchange.subscribe('foo'); - for await (let data of subscription) { - receivedSubscribedData.push(data); - } - })(); - - let channel = server.exchange.channel('foo'); - for await (let data of channel) { - receivedChannelData.push(data); - if (receivedChannelData.length > 1) { - break; - } + client.transmitPublish('foo', i); } - assert.equal(server.exchange.isSubscribed('foo'), true); - assert.equal(server.exchange.subscriptions().join(','), 'foo'); + await wait(400); - assert.equal(receivedSubscribedData[0], 'hi1'); - assert.equal(receivedSubscribedData[1], 'hi2'); - assert.equal(receivedChannelData[0], 'hi1'); - assert.equal(receivedChannelData[1], 'hi2'); + // Backpressure should go up and come back down. + assert.equal(backpressureHistory.length, 21); + assert.equal(backpressureHistory[0], 1); + assert.equal(backpressureHistory[12] > 4, true); + assert.equal(backpressureHistory[14] > 6, true); + assert.equal(backpressureHistory[19], 1); }); - it('When default SCSimpleBroker broker engine is used, scServer.exchange should support publishing data to a channel', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + it('Should be able to getOutboundBackpressure() on a socket object', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); - await server.listener('ready').once(); - - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber - }); + let backpressureHistory = []; (async () => { - await client.listener('subscribe').once(); - server.exchange.publish('bar', 'hello1'); - await wait(10); - server.exchange.publish('bar', 'hello2'); - })(); - - let receivedSubscribedData = []; - let receivedChannelData = []; + for await (let {socket} of server.listener('connection')) { + (async () => { + await socket.listener('subscribe').once(); - (async () => { - let subscription = client.subscribe('bar'); - for await (let data of subscription) { - receivedSubscribedData.push(data); + for (let i = 0; i < 20; i++) { + await wait(10); + server.exchange.transmitPublish('foo', i); + backpressureHistory.push(socket.getOutboundBackpressure()); + } + })(); } })(); - let channel = client.channel('bar'); - for await (let data of channel) { - receivedChannelData.push(data); - if (receivedChannelData.length > 1) { - break; + server.setMiddleware(server.MIDDLEWARE_OUTBOUND, async (middlewareStream) => { + for await (let action of middlewareStream) { + if (action.data === 5) { + await wait(100); + } + action.allow(); } - } + }); - assert.equal(receivedSubscribedData[0], 'hello1'); - assert.equal(receivedSubscribedData[1], 'hello2'); - assert.equal(receivedChannelData[0], 'hello1'); - assert.equal(receivedChannelData[1], 'hello2'); - }); + await server.listener('ready').once(); - it('When disconnecting a socket, the unsubscribe event should trigger after the disconnect event', async function () { - portNumber++; - let customBrokerEngine = new SCSimpleBroker(); - let defaultUnsubscribeSocket = customBrokerEngine.unsubscribeSocket; - customBrokerEngine.unsubscribeSocket = function (socket, channel) { - return resolveAfterTimeout(100, defaultUnsubscribeSocket.call(this, socket, channel)); - }; + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + await client.subscribe('foo').listener('subscribe').once(); - server = socketClusterServer.listen(portNumber, { + await wait(400); + + // Backpressure should go up and come back down. + assert.equal(backpressureHistory.length, 20); + assert.equal(backpressureHistory[0], 1); + assert.equal(backpressureHistory[13] > 7, true); + assert.equal(backpressureHistory[14] > 8, true); + assert.equal(backpressureHistory[19], 1); + }); + + it('Should be able to getBackpressure() on a socket object and it should be the highest backpressure', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - brokerEngine: customBrokerEngine + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); - let eventList = []; - - (async () => { - await server.listener('ready').once(); - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber - }); + let backpressureHistory = []; - for await (let event of client.subscribe('foo').listener('subscribe')) { - (async () => { - await wait(200); - client.disconnect(); - })(); + server.setMiddleware(server.MIDDLEWARE_INBOUND_RAW, async (middlewareStream) => { + for await (let action of middlewareStream) { + backpressureHistory.push(action.socket.getBackpressure()); + action.allow(); } - })(); - - let {socket} = await server.listener('connection').once(); + }); - (async () => { - for await (let event of socket.listener('unsubscribe')) { - eventList.push({ - type: 'unsubscribe', - channel: event.channel - }); + server.setMiddleware(server.MIDDLEWARE_INBOUND, async (middlewareStream) => { + for await (let action of middlewareStream) { + if (action.data === 5) { + await wait(100); + } + action.allow(); } - })(); + }); - let event = await socket.listener('disconnect').once(); + await server.listener('ready').once(); - eventList.push({ - type: 'disconnect', - code: event.code, - reason: event.reason + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - await wait(0); - assert.equal(eventList[0].type, 'disconnect'); - assert.equal(eventList[1].type, 'unsubscribe'); - assert.equal(eventList[1].channel, 'foo'); - }); + await client.listener('connect').once(); + for (let i = 0; i < 20; i++) { + await wait(10); + client.transmitPublish('foo', i); + } - it('Socket should emit an error when trying to unsubscribe to a channel which it is not subscribed to', async function () { - portNumber++; + await wait(400); + + // Backpressure should go up and come back down. + assert.equal(backpressureHistory.length, 21); + assert.equal(backpressureHistory[0], 1); + assert.equal(backpressureHistory[12] > 4, true); + assert.equal(backpressureHistory[14] > 6, true); + assert.equal(backpressureHistory[19], 1); + }); + }); - server = socketClusterServer.listen(portNumber, { + describe('Socket pub/sub', function () { + it('Should maintain order of publish and subscribe', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); - - let errorList = []; + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { - (async () => { - for await (let {error} of socket.listener('error')) { - errorList.push(error); - } - })(); + connectionHandler(socket); } })(); @@ -1697,46 +2047,37 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - let error; - try { - await client.invoke('#unsubscribe', 'bar'); - } catch (err) { - error = err; - } - assert.notEqual(error, null); - assert.equal(error.name, 'BrokerError'); + await client.listener('connect').once(); + + let receivedMessages = []; + (async () => { + for await (let data of client.subscribe('foo')) { + receivedMessages.push(data); + } + })(); + + await client.invokePublish('foo', 123); + + assert.equal(client.state, client.OPEN); await wait(100); - assert.equal(errorList.length, 1); - assert.equal(errorList[0].name, 'BrokerError'); + assert.equal(receivedMessages.length, 1); }); - it('Socket should not receive messages from a channel which it has only just unsubscribed from (accounting for delayed unsubscribe by brokerEngine)', async function () { - portNumber++; - let customBrokerEngine = new SCSimpleBroker(); - let defaultUnsubscribeSocket = customBrokerEngine.unsubscribeSocket; - customBrokerEngine.unsubscribeSocket = function (socket, channel) { - return resolveAfterTimeout(300, defaultUnsubscribeSocket.call(this, socket, channel)); - }; - - server = socketClusterServer.listen(portNumber, { + it('Should maintain order of publish and subscribe when client starts out as disconnected', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - brokerEngine: customBrokerEngine + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { - (async () => { - for await (let event of socket.listener('unsubscribe')) { - if (event.channel === 'foo') { - server.exchange.publish('foo', 'hello'); - } - } - })(); + connectionHandler(socket); } })(); @@ -1744,64 +2085,44 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + autoConnect: false, + authTokenName: 'socketcluster.authToken' }); - // Stub the isSubscribed method so that it always returns true. - // That way the client will always invoke watchers whenever - // it receives a #publish event. - client.isSubscribed = function () { return true; }; - let messageList = []; + assert.equal(client.state, client.CLOSED); - let fooChannel = client.subscribe('foo'); + let receivedMessages = []; (async () => { - for await (let data of fooChannel) { - messageList.push(data); + for await (let data of client.subscribe('foo')) { + receivedMessages.push(data); } })(); - (async () => { - for await (let event of fooChannel.listener('subscribe')) { - client.invoke('#unsubscribe', 'foo'); - } - })(); + client.invokePublish('foo', 123); - await wait(200); - assert.equal(messageList.length, 0); + await wait(100); + assert.equal(client.state, client.OPEN); + assert.equal(receivedMessages.length, 1); }); - it('Socket channelSubscriptions and channelSubscriptionsCount should update when socket.kickOut(channel) is called', async function () { - portNumber++; - - server = socketClusterServer.listen(portNumber, { + it('Client should not be able to subscribe to a channel before the handshake has completed', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); - let errorList = []; - let serverSocket; - let wasKickOutCalled = false; + server.setAuthEngine({ + verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { + return resolveAfterTimeout(500, {}); + } + }); (async () => { for await (let {socket} of server.listener('connection')) { - serverSocket = socket; - - (async () => { - for await (let {error} of socket.listener('error')) { - errorList.push(error); - } - })(); - - (async () => { - for await (let event of socket.listener('subscribe')) { - if (event.channel === 'foo') { - await wait(50); - wasKickOutCalled = true; - socket.kickOut('foo', 'Socket was kicked out of the channel'); - } - } - })(); + connectionHandler(socket); } })(); @@ -1809,49 +2130,49 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - client.subscribe('foo'); + let isSubscribed = false; + let error; - await wait(100); - assert.equal(errorList.length, 0); - assert.equal(wasKickOutCalled, true); - assert.equal(serverSocket.channelSubscriptionsCount, 0); - assert.equal(Object.keys(serverSocket.channelSubscriptions).length, 0); - }); + (async () => { + for await (let event of server.listener('subscription')) { + isSubscribed = true; + } + })(); + + // Hack to capture the error without relying on the standard client flow. + client.transport._callbackMap[2] = { + event: '#subscribe', + data: {"channel":"someChannel"}, + callback: function (err) { + error = err; + } + }; + + // Trick the server by sending a fake subscribe before the handshake is done. + client.transport.socket.on('open', function () { + client.send('{"event":"#subscribe","data":{"channel":"someChannel"},"cid":2}'); + }); - it('Socket channelSubscriptions and channelSubscriptionsCount should update when socket.kickOut() is called without arguments', async function () { - portNumber++; + await wait(1000); + assert.equal(isSubscribed, false); + assert.notEqual(error, null); + assert.equal(error.name, 'BadConnectionError'); + }); - server = socketClusterServer.listen(portNumber, { + it('Server should be able to handle invalid #subscribe and #unsubscribe and #publish events without crashing', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); - - let errorList = []; - let serverSocket; - let wasKickOutCalled = false; + bindFailureHandlers(server); (async () => { for await (let {socket} of server.listener('connection')) { - serverSocket = socket; - - (async () => { - for await (let {error} of socket.listener('error')) { - errorList.push(error); - } - })(); - - (async () => { - for await (let event of socket.listener('subscribe')) { - if (socket.channelSubscriptionsCount === 2) { - await wait(50); - wasKickOutCalled = true; - socket.kickOut(); - } - } - })(); + connectionHandler(socket); } })(); @@ -1859,409 +2180,1585 @@ describe('Integration tests', function () { client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - client.subscribe('foo'); - client.subscribe('bar'); + let nullInChannelArrayError; + let objectAsChannelNameError; + let nullChannelNameError; + let nullUnsubscribeError; - await wait(200); - assert.equal(errorList.length, 0); - assert.equal(wasKickOutCalled, true); - assert.equal(serverSocket.channelSubscriptionsCount, 0); - assert.equal(Object.keys(serverSocket.channelSubscriptions).length, 0); + let undefinedPublishError; + let objectAsChannelNamePublishError; + let nullPublishError; + + // Hacks to capture the errors without relying on the standard client flow. + client.transport._callbackMap[2] = { + event: '#subscribe', + data: [null], + callback: function (err) { + nullInChannelArrayError = err; + } + }; + client.transport._callbackMap[3] = { + event: '#subscribe', + data: {"channel": {"hello": 123}}, + callback: function (err) { + objectAsChannelNameError = err; + } + }; + client.transport._callbackMap[4] = { + event: '#subscribe', + data: null, + callback: function (err) { + nullChannelNameError = err; + } + }; + client.transport._callbackMap[5] = { + event: '#unsubscribe', + data: [null], + callback: function (err) { + nullUnsubscribeError = err; + } + }; + client.transport._callbackMap[6] = { + event: '#publish', + data: null, + callback: function (err) { + undefinedPublishError = err; + } + }; + client.transport._callbackMap[7] = { + event: '#publish', + data: {"channel": {"hello": 123}}, + callback: function (err) { + objectAsChannelNamePublishError = err; + } + }; + client.transport._callbackMap[8] = { + event: '#publish', + data: {"channel": null}, + callback: function (err) { + nullPublishError = err; + } + }; + + (async () => { + for await (let event of client.listener('connect')) { + // Trick the server by sending a fake subscribe before the handshake is done. + client.send('{"event":"#subscribe","data":[null],"cid":2}'); + client.send('{"event":"#subscribe","data":{"channel":{"hello":123}},"cid":3}'); + client.send('{"event":"#subscribe","data":null,"cid":4}'); + client.send('{"event":"#unsubscribe","data":[null],"cid":5}'); + client.send('{"event":"#publish","data":null,"cid":6}'); + client.send('{"event":"#publish","data":{"channel":{"hello":123}},"cid":7}'); + client.send('{"event":"#publish","data":{"channel":null},"cid":8}'); + } + })(); + + await wait(300); + + assert.notEqual(nullInChannelArrayError, null); + assert.notEqual(objectAsChannelNameError, null); + assert.notEqual(nullChannelNameError, null); + assert.notEqual(nullUnsubscribeError, null); + assert.notEqual(undefinedPublishError, null); + assert.notEqual(objectAsChannelNamePublishError, null); + assert.notEqual(nullPublishError, null); }); - }); - describe('Socket destruction', function () { - it('Server socket destroy should disconnect the socket', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { + it('When default AGSimpleBroker broker engine is used, disconnect event should trigger before unsubscribe event', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, wsEngine: WS_ENGINE }); + bindFailureHandlers(server); + + let eventList = []; (async () => { - for await (let {socket} of server.listener('connection')) { - await wait(100); - socket.destroy(1000, 'Custom reason'); + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + await client.subscribe('foo').listener('subscribe').once(); + await wait(200); + client.disconnect(); + })(); + + let {socket} = await server.listener('connection').once(); + + (async () => { + for await (let event of socket.listener('unsubscribe')) { + eventList.push({ + type: 'unsubscribe', + channel: event.channel + }); + } + })(); + + (async () => { + for await (let disconnectPacket of socket.listener('disconnect')) { + eventList.push({ + type: 'disconnect', + code: disconnectPacket.code, + reason: disconnectPacket.data + }); } })(); + await wait(300); + assert.equal(eventList[0].type, 'disconnect'); + assert.equal(eventList[1].type, 'unsubscribe'); + assert.equal(eventList[1].channel, 'foo'); + }); + + it('When default AGSimpleBroker broker engine is used, agServer.exchange should support consuming data from a channel', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + await server.listener('ready').once(); client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: portNumber + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - let {code, reason} = await client.listener('disconnect').once(); - assert.equal(code, 1000); - assert.equal(reason, 'Custom reason'); - assert.equal(server.clientsCount, 0); - assert.equal(server.pendingClientsCount, 0); + (async () => { + await client.listener('connect').once(); + + client.transmitPublish('foo', 'hi1'); + await wait(10); + client.transmitPublish('foo', 'hi2'); + })(); + + let receivedSubscribedData = []; + let receivedChannelData = []; + + (async () => { + let subscription = server.exchange.subscribe('foo'); + for await (let data of subscription) { + receivedSubscribedData.push(data); + } + })(); + + let channel = server.exchange.channel('foo'); + for await (let data of channel) { + receivedChannelData.push(data); + if (receivedChannelData.length > 1) { + break; + } + } + + assert.equal(server.exchange.isSubscribed('foo'), true); + assert.equal(server.exchange.subscriptions().join(','), 'foo'); + + assert.equal(receivedSubscribedData[0], 'hi1'); + assert.equal(receivedSubscribedData[1], 'hi2'); + assert.equal(receivedChannelData[0], 'hi1'); + assert.equal(receivedChannelData[1], 'hi2'); + }); + + it('When default AGSimpleBroker broker engine is used, agServer.exchange should support publishing data to a channel', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + (async () => { + await client.listener('subscribe').once(); + server.exchange.transmitPublish('bar', 'hello1'); + await wait(10); + server.exchange.transmitPublish('bar', 'hello2'); + })(); + + let receivedSubscribedData = []; + let receivedChannelData = []; + + (async () => { + let subscription = client.subscribe('bar'); + for await (let data of subscription) { + receivedSubscribedData.push(data); + } + })(); + + let channel = client.channel('bar'); + for await (let data of channel) { + receivedChannelData.push(data); + if (receivedChannelData.length > 1) { + break; + } + } + + assert.equal(receivedSubscribedData[0], 'hello1'); + assert.equal(receivedSubscribedData[1], 'hello2'); + assert.equal(receivedChannelData[0], 'hello1'); + assert.equal(receivedChannelData[1], 'hello2'); + }); + + it('When disconnecting a socket, the unsubscribe event should trigger after the disconnect and close events', async function () { + let customBrokerEngine = new AGSimpleBroker(); + let defaultUnsubscribeSocket = customBrokerEngine.unsubscribeSocket; + customBrokerEngine.unsubscribeSocket = function (socket, channel) { + return resolveAfterTimeout(100, defaultUnsubscribeSocket.call(this, socket, channel)); + }; + + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE, + brokerEngine: customBrokerEngine + }); + bindFailureHandlers(server); + + let eventList = []; + + (async () => { + await server.listener('ready').once(); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + for await (let event of client.subscribe('foo').listener('subscribe')) { + (async () => { + await wait(200); + client.disconnect(); + })(); + } + })(); + + let {socket} = await server.listener('connection').once(); + + (async () => { + for await (let event of socket.listener('unsubscribe')) { + eventList.push({ + type: 'unsubscribe', + channel: event.channel + }); + } + })(); + + (async () => { + for await (let event of socket.listener('disconnect')) { + eventList.push({ + type: 'disconnect', + code: event.code, + reason: event.reason + }); + } + })(); + + (async () => { + for await (let event of socket.listener('close')) { + eventList.push({ + type: 'close', + code: event.code, + reason: event.reason + }); + } + })(); + + await wait(700); + assert.equal(eventList[0].type, 'disconnect'); + assert.equal(eventList[1].type, 'close'); + assert.equal(eventList[2].type, 'unsubscribe'); + assert.equal(eventList[2].channel, 'foo'); }); - it('Server socket destroy should set the active property on the socket to false', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { - authKey: serverOptions.authKey, - wsEngine: WS_ENGINE - }); + it('Socket should emit an error when trying to unsubscribe from a channel which it is not subscribed to', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + let errorList = []; + + (async () => { + for await (let {socket} of server.listener('connection')) { + (async () => { + for await (let {error} of socket.listener('error')) { + errorList.push(error); + } + })(); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let error; + try { + await client.invoke('#unsubscribe', 'bar'); + } catch (err) { + error = err; + } + assert.notEqual(error, null); + assert.equal(error.name, 'BrokerError'); + + await wait(100); + assert.equal(errorList.length, 1); + assert.equal(errorList[0].name, 'BrokerError'); + }); + + it('Socket should not receive messages from a channel which it has only just unsubscribed from (accounting for delayed unsubscribe by brokerEngine)', async function () { + let customBrokerEngine = new AGSimpleBroker(); + let defaultUnsubscribeSocket = customBrokerEngine.unsubscribeSocket; + customBrokerEngine.unsubscribeSocket = function (socket, channel) { + return resolveAfterTimeout(300, defaultUnsubscribeSocket.call(this, socket, channel)); + }; + + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE, + brokerEngine: customBrokerEngine + }); + bindFailureHandlers(server); + + (async () => { + for await (let {socket} of server.listener('connection')) { + (async () => { + for await (let event of socket.listener('unsubscribe')) { + if (event.channel === 'foo') { + server.exchange.transmitPublish('foo', 'hello'); + } + } + })(); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + // Stub the isSubscribed method so that it always returns true. + // That way the client will always invoke watchers whenever + // it receives a #publish event. + client.isSubscribed = function () { return true; }; + + let messageList = []; + + let fooChannel = client.subscribe('foo'); + + (async () => { + for await (let data of fooChannel) { + messageList.push(data); + } + })(); + + (async () => { + for await (let event of fooChannel.listener('subscribe')) { + client.invoke('#unsubscribe', 'foo'); + } + })(); + + await wait(200); + assert.equal(messageList.length, 0); + }); + + it('Socket channelSubscriptions and channelSubscriptionsCount should update when socket.kickOut(channel) is called', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + let errorList = []; + let serverSocket; + let wasKickOutCalled = false; + + (async () => { + for await (let {socket} of server.listener('connection')) { + serverSocket = socket; + + (async () => { + for await (let {error} of socket.listener('error')) { + errorList.push(error); + } + })(); + + (async () => { + for await (let event of socket.listener('subscribe')) { + if (event.channel === 'foo') { + await wait(50); + wasKickOutCalled = true; + socket.kickOut('foo', 'Socket was kicked out of the channel'); + } + } + })(); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + client.subscribe('foo'); + + await wait(100); + assert.equal(errorList.length, 0); + assert.equal(wasKickOutCalled, true); + assert.equal(serverSocket.channelSubscriptionsCount, 0); + assert.equal(Object.keys(serverSocket.channelSubscriptions).length, 0); + }); + }); + + describe('Batching', function () { + it('Should batch messages sent through sockets after the handshake when the batchOnHandshake option is true', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE, + batchOnHandshake: true, + batchOnHandshakeDuration: 400, + batchInterval: 50 + }); + bindFailureHandlers(server); + + let receivedServerMessages = []; + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + + (async () => { + for await (let {message} of socket.listener('message')) { + receivedServerMessages.push(message); + } + })(); + } + })(); + + let subscribeMiddlewareCounter = 0; + + // Each subscription should pass through the middleware individually, even + // though they were sent as a batch/array. + server.setMiddleware(server.MIDDLEWARE_INBOUND, async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.SUBSCRIBE) { + subscribeMiddlewareCounter++; + assert.equal(action.channel.indexOf('my-channel-'), 0); + if (action.channel === 'my-channel-10') { + assert.equal(JSON.stringify(action.data), JSON.stringify({foo: 123})); + } else if (action.channel === 'my-channel-12') { + // Block my-channel-12 + let err = new Error('You cannot subscribe to channel 12'); + err.name = 'UnauthorizedSubscribeError'; + action.block(err); + continue; + } + } + action.allow(); + } + }); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + batchOnHandshake: true, + batchOnHandshakeDuration: 100, + batchInterval: 50, + authTokenName: 'socketcluster.authToken' + }); + + let receivedClientMessages = []; + (async () => { + for await (let {message} of client.listener('message')) { + receivedClientMessages.push(message); + } + })(); + + let channelList = []; + for (let i = 0; i < 20; i++) { + let subscriptionOptions = {}; + if (i === 10) { + subscriptionOptions.data = {foo: 123}; + } + channelList.push( + client.subscribe('my-channel-' + i, subscriptionOptions) + ); + } + + (async () => { + for await (let event of channelList[12].listener('subscribe')) { + throw new Error('The my-channel-12 channel should have been blocked by MIDDLEWARE_SUBSCRIBE'); + } + })(); + + (async () => { + for await (let event of channelList[12].listener('subscribeFail')) { + assert.notEqual(event.error, null); + assert.equal(event.error.name, 'UnauthorizedSubscribeError'); + } + })(); + + (async () => { + for await (let event of channelList[19].listener('subscribe')) { + client.transmitPublish('my-channel-19', 'Hello!'); + } + })(); + + for await (let data of channelList[19]) { + assert.equal(data, 'Hello!'); + assert.equal(subscribeMiddlewareCounter, 20); + break; + } + + assert.notEqual(receivedServerMessages[0], null); + // All 20 subscriptions should arrive as a single message. + assert.equal(JSON.parse(receivedServerMessages[0]).length, 20); + + assert.equal(Array.isArray(JSON.parse(receivedClientMessages[0])), false); + assert.equal(JSON.parse(receivedClientMessages[1]).length, 20); + }); + + it('The batchOnHandshake option should not break the order of subscribe and publish', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE, + batchOnHandshake: true, + batchOnHandshakeDuration: 400, + batchInterval: 50 + }); + bindFailureHandlers(server); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + autoConnect: false, + batchOnHandshake: true, + batchOnHandshakeDuration: 100, + batchInterval: 50, + authTokenName: 'socketcluster.authToken' + }); + + let receivedMessage; + + let fooChannel = client.subscribe('foo'); + client.transmitPublish('foo', 'bar'); + + for await (let data of fooChannel) { + receivedMessage = data; + break; + } + }); + }); + + describe('Socket Ping/pong', function () { + describe('When pingTimeoutDisabled is not set', function () { + beforeEach('Launch server with ping options before start', async function () { + // Intentionally make pingInterval higher than pingTimeout, that + // way the client will never receive a ping or send back a pong. + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE, + pingInterval: 5000, + pingTimeout: 500 + }); + bindFailureHandlers(server); + + await server.listener('ready').once(); + }); + + it('Should disconnect socket if server does not receive a pong from client before timeout', async function () { + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let serverWarning = null; + (async () => { + for await (let {warning} of server.listener('warning')) { + serverWarning = warning; + } + })(); + + let serverDisconnectionCode = null; + (async () => { + for await (let event of server.listener('disconnection')) { + serverDisconnectionCode = event.code; + } + })(); + + let clientError = null; + (async () => { + for await (let {error} of client.listener('error')) { + clientError = error; + } + })(); + + let clientDisconnectCode = null; + (async () => { + for await (let event of client.listener('disconnect')) { + clientDisconnectCode = event.code; + } + })(); + + await wait(1000); + assert.notEqual(clientError, null); + assert.equal(clientError.name, 'SocketProtocolError'); + assert.equal(clientDisconnectCode === 4000 || clientDisconnectCode === 4001, true); + + assert.notEqual(serverWarning, null); + assert.equal(serverWarning.name, 'SocketProtocolError'); + assert.equal(clientDisconnectCode === 4000 || clientDisconnectCode === 4001, true); + }); + }); + + describe('When pingTimeoutDisabled is true', function () { + beforeEach('Launch server with ping options before start', async function () { + // Intentionally make pingInterval higher than pingTimeout, that + // way the client will never receive a ping or send back a pong. + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE, + pingInterval: 1000, + pingTimeout: 500, + pingTimeoutDisabled: true + }); + bindFailureHandlers(server); + + await server.listener('ready').once(); + }); + + it('Should not disconnect socket if server does not receive a pong from client before timeout', async function () { + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + pingTimeoutDisabled: true, + authTokenName: 'socketcluster.authToken' + }); + + let serverWarning = null; + (async () => { + for await (let {warning} of server.listener('warning')) { + serverWarning = warning; + } + })(); + + let serverDisconnectionCode = null; + (async () => { + for await (let event of server.listener('disconnection')) { + serverDisconnectionCode = event.code; + } + })(); + + let clientError = null; + (async () => { + for await (let {error} of client.listener('error')) { + clientError = error; + } + })(); + + let clientDisconnectCode = null; + (async () => { + for await (let event of client.listener('disconnect')) { + clientDisconnectCode = event.code; + } + })(); + + await wait(1000); + assert.equal(clientError, null); + assert.equal(clientDisconnectCode, null); + + assert.equal(serverWarning, null); + assert.equal(serverDisconnectionCode, null); + }); + }); + + describe('When pingTimeout is greater than pingInterval', function () { + beforeEach('Launch server with ping options before start', async function () { + // Intentionally make pingInterval higher than pingTimeout, that + // way the client will never receive a ping or send back a pong. + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE, + pingInterval: 400, + pingTimeout: 1000 + }); + bindFailureHandlers(server); + + await server.listener('ready').once(); + }); + + it('Should not disconnect socket if server receives a pong from client before timeout', async function () { + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let serverWarning = null; + (async () => { + for await (let {warning} of server.listener('warning')) { + serverWarning = warning; + } + })(); + + let serverDisconnectionCode = null; + (async () => { + for await (let event of server.listener('disconnection')) { + serverDisconnectionCode = event.code; + } + })(); + + let clientError = null; + (async () => { + for await (let {error} of client.listener('error')) { + clientError = error; + } + })(); + + let clientDisconnectCode = null; + (async () => { + for await (let event of client.listener('disconnect')) { + clientDisconnectCode = event.code; + } + })(); + + await wait(2000); + assert.equal(clientError, null); + assert.equal(clientDisconnectCode, null); + + assert.equal(serverWarning, null); + assert.equal(serverDisconnectionCode, null); + }); + }); + }); + + describe('Middleware', function () { + beforeEach('Launch server without middleware before start', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + }); + + describe('MIDDLEWARE_HANDSHAKE', function () { + describe('HANDSHAKE_WS action', function () { + it('Delaying handshake for one client should not affect other clients', async function () { + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.HANDSHAKE_WS) { + if (action.request.url.indexOf('?delayMe=true') !== -1) { + // Long delay. + await wait(5000); + action.allow(); + continue; + } + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_HANDSHAKE, middlewareFunction); + + let clientA = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let clientB = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + query: { + delayMe: true + }, + authTokenName: 'socketcluster.authToken' + }); + + let clientAIsConnected = false; + let clientBIsConnected = false; + + (async () => { + await clientA.listener('connect').once(); + clientAIsConnected = true; + })(); + + (async () => { + await clientB.listener('connect').once(); + clientBIsConnected = true; + })(); + + await wait(100); + + assert.equal(clientAIsConnected, true); + assert.equal(clientBIsConnected, false); + + clientA.disconnect(); + clientB.disconnect(); + }); + }); + + describe('HANDSHAKE_SC action', function () { + it('Should trigger correct events if MIDDLEWARE_HANDSHAKE blocks with an error', async function () { + let middlewareWasExecuted = false; + let serverWarnings = []; + let clientErrors = []; + let abortStatus; + + let middlewareFunction = async function (middlewareStream) { + for await (let {type, allow, block} of middlewareStream) { + if (type === AGAction.HANDSHAKE_SC) { + await wait(100); + middlewareWasExecuted = true; + let err = new Error('AG handshake failed because the server was too lazy'); + err.name = 'TooLazyHandshakeError'; + block(err); + continue; + } + allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_HANDSHAKE, middlewareFunction); + + (async () => { + for await (let {warning} of server.listener('warning')) { + serverWarnings.push(warning); + } + })(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + (async () => { + for await (let {error} of client.listener('error')) { + clientErrors.push(error); + } + })(); + + (async () => { + let event = await client.listener('connectAbort').once(); + abortStatus = event.code; + })(); + + await wait(200); + assert.equal(middlewareWasExecuted, true); + assert.notEqual(clientErrors[0], null); + assert.equal(clientErrors[0].name, 'TooLazyHandshakeError'); + assert.notEqual(clientErrors[1], null); + assert.equal(clientErrors[1].name, 'SocketProtocolError'); + assert.notEqual(serverWarnings[0], null); + assert.equal(serverWarnings[0].name, 'TooLazyHandshakeError'); + assert.notEqual(abortStatus, null); + }); + + it('Should send back default 4008 status code if MIDDLEWARE_HANDSHAKE blocks without providing a status code', async function () { + let middlewareWasExecuted = false; + let abortStatus; + let abortReason; + + let middlewareFunction = async function (middlewareStream) { + for await (let {type, allow, block} of middlewareStream) { + if (type === AGAction.HANDSHAKE_SC) { + await wait(100); + middlewareWasExecuted = true; + let err = new Error('AG handshake failed because the server was too lazy'); + err.name = 'TooLazyHandshakeError'; + block(err); + continue; + } + allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_HANDSHAKE, middlewareFunction); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + (async () => { + let event = await client.listener('connectAbort').once(); + abortStatus = event.code; + abortReason = event.reason; + })(); + + await wait(200); + assert.equal(middlewareWasExecuted, true); + assert.equal(abortStatus, 4008); + assert.equal(abortReason, 'TooLazyHandshakeError: AG handshake failed because the server was too lazy'); + }); + + it('Should send back custom status code if MIDDLEWARE_HANDSHAKE blocks by providing a status code', async function () { + let middlewareWasExecuted = false; + let abortStatus; + let abortReason; + + let middlewareFunction = async function (middlewareStream) { + for await (let {type, allow, block} of middlewareStream) { + if (type === AGAction.HANDSHAKE_SC) { + await wait(100); + middlewareWasExecuted = true; + let err = new Error('AG handshake failed because of invalid query auth parameters'); + err.name = 'InvalidAuthQueryHandshakeError'; + // Set custom 4501 status code as a property of the error. + // We will treat this code as a fatal authentication failure on the front end. + // A status code of 4500 or higher means that the client shouldn't try to reconnect. + err.statusCode = 4501; + block(err); + continue; + } + allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_HANDSHAKE, middlewareFunction); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + (async () => { + let event = await client.listener('connectAbort').once(); + abortStatus = event.code; + abortReason = event.reason; + })(); + + await wait(200); + assert.equal(middlewareWasExecuted, true); + assert.equal(abortStatus, 4501); + assert.equal(abortReason, 'InvalidAuthQueryHandshakeError: AG handshake failed because of invalid query auth parameters'); + }); + + it('Should connect with a delay if allow() is called after a timeout inside the middleware function', async function () { + let createConnectionTime = null; + let connectEventTime = null; + let abortStatus; + let abortReason; + + let middlewareFunction = async function (middlewareStream) { + for await (let {type, allow} of middlewareStream) { + if (type === AGAction.HANDSHAKE_SC) { + await wait(500); + } + allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_HANDSHAKE, middlewareFunction); + + createConnectionTime = Date.now(); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + (async () => { + let event = await client.listener('connectAbort').once(); + abortStatus = event.code; + abortReason = event.reason; + })(); + + await client.listener('connect').once(); + connectEventTime = Date.now(); + assert.equal(connectEventTime - createConnectionTime > 400, true); + }); + + it('Should not be allowed to call req.socket.setAuthToken from inside middleware', async function () { + let didAuthenticationEventTrigger = false; + let setAuthTokenError; + + server.setMiddleware(server.MIDDLEWARE_HANDSHAKE, async function (middlewareStream) { + for await (let {socket, type, allow, block} of middlewareStream) { + if (type === AGAction.HANDSHAKE_SC) { + try { + await socket.setAuthToken({username: 'alice'}); + } catch (error) { + setAuthTokenError = error; + } + } + allow(); + } + }); + + (async () => { + let event = await server.listener('authentication').once(); + didAuthenticationEventTrigger = true; + })(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let event = await client.listener('connect').once(); + assert.equal(event.isAuthenticated, false); + assert.equal(client.authState, client.UNAUTHENTICATED); + assert.equal(client.authToken, null); + assert.equal(didAuthenticationEventTrigger, false); + assert.notEqual(setAuthTokenError, null); + assert.equal(setAuthTokenError.name, 'InvalidActionError'); + }); + + it('Delaying handshake for one client should not affect other clients', async function () { + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.HANDSHAKE_SC) { + if (action.socket.request.url.indexOf('?delayMe=true') !== -1) { + // Long delay. + await wait(5000); + action.allow(); + continue; + } + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_HANDSHAKE, middlewareFunction); + + let clientA = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let clientB = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + query: { + delayMe: true + }, + authTokenName: 'socketcluster.authToken' + }); + + let clientAIsConnected = false; + let clientBIsConnected = false; + + (async () => { + await clientA.listener('connect').once(); + clientAIsConnected = true; + })(); - let serverSocket; + (async () => { + await clientB.listener('connect').once(); + clientBIsConnected = true; + })(); - (async () => { - for await (let {socket} of server.listener('connection')) { - serverSocket = socket; - assert.equal(socket.active, true); await wait(100); - socket.destroy(); - } - })(); - await server.listener('ready').once(); - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber - }); + assert.equal(clientAIsConnected, true); + assert.equal(clientBIsConnected, false); - await client.listener('disconnect').once(); - assert.equal(serverSocket.active, false); + clientA.disconnect(); + clientB.disconnect(); + }); + }); }); - }); - describe('Socket Ping/pong', function () { - describe('When when pingTimeoutDisabled is not set', function () { - beforeEach('Launch server with ping options before start', async function () { - portNumber++; - // Intentionally make pingInterval higher than pingTimeout, that - // way the client will never receive a ping or send back a pong. - server = socketClusterServer.listen(portNumber, { - authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - pingInterval: 2000, - pingTimeout: 500 - }); + describe('MIDDLEWARE_INBOUND', function () { + describe('INVOKE action', function () { + it('Should run INVOKE action in middleware if client invokes an RPC', async function () { + let middlewareWasExecuted = false; + let middlewareAction = null; + + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.INVOKE) { + middlewareWasExecuted = true; + middlewareAction = action; + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); - await server.listener('ready').once(); - }); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - afterEach('Shut down server afterwards', async function () { - destroyTestCase(); - server.close(); - }); + let result = await client.invoke('proc', 123); - it('Should disconnect socket if server does not receive a pong from client before timeout', async function () { - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber + assert.equal(middlewareWasExecuted, true); + assert.notEqual(middlewareAction, null); + assert.equal(result, 'success 123'); }); - let serverWarning = null; - (async () => { - for await (let {warning} of server.listener('warning')) { - serverWarning = warning; - } - })(); + it('Should send back custom Error if INVOKE action in middleware blocks the client RPC', async function () { + let middlewareWasExecuted = false; + let middlewareAction = null; - let serverDisconnectionCode = null; - (async () => { - for await (let event of server.listener('disconnection')) { - serverDisconnectionCode = event.code; - } - })(); + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.INVOKE) { + middlewareWasExecuted = true; + middlewareAction = action; - let clientError = null; - (async () => { - for await (let {error} of client.listener('error')) { - clientError = error; - } - })(); + let customError = new Error('Invoke action was blocked'); + customError.name = 'BlockedInvokeError'; + action.block(customError); + continue; + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); - let clientDisconnectCode = null; - (async () => { - for await (let event of client.listener('disconnect')) { - clientDisconnectCode = event.code; - } - })(); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - await wait(1000); - assert.notEqual(clientError, null); - assert.equal(clientError.name, 'SocketProtocolError'); - assert.equal(clientDisconnectCode, 4001); + let result; + let error; + try { + result = await client.invoke('proc', 123); + } catch (err) { + error = err; + } - assert.notEqual(serverWarning, null); - assert.equal(serverWarning.name, 'SocketProtocolError'); - assert.equal(serverDisconnectionCode, 4001); + assert.equal(result, null); + assert.notEqual(error, null); + assert.equal(error.name, 'BlockedInvokeError'); + }); }); - }); - describe('When when pingTimeoutDisabled is true', function () { - beforeEach('Launch server with ping options before start', async function () { - portNumber++; - // Intentionally make pingInterval higher than pingTimeout, that - // way the client will never receive a ping or send back a pong. - server = socketClusterServer.listen(portNumber, { - authKey: serverOptions.authKey, - wsEngine: WS_ENGINE, - pingInterval: 2000, - pingTimeout: 500, - pingTimeoutDisabled: true + describe('AUTHENTICATE action', function () { + it('Should not run AUTHENTICATE action in middleware if JWT token does not exist', async function () { + let middlewareWasExecuted = false; + let middlewareFunction = async function (middlewareStream) { + for await (let {type, allow} of middlewareStream) { + if (type === AGAction.AUTHENTICATE) { + middlewareWasExecuted = true; + } + allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + await client.listener('connect').once(); + assert.notEqual(middlewareWasExecuted, true); }); - await server.listener('ready').once(); - }); + it('Should run AUTHENTICATE action in middleware if JWT token exists', async function () { + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); + let middlewareWasExecuted = false; - afterEach('Shut down server afterwards', async function () { - destroyTestCase(); - server.close(); - }); + let middlewareFunction = async function (middlewareStream) { + for await (let {type, allow} of middlewareStream) { + if (type === AGAction.AUTHENTICATE) { + middlewareWasExecuted = true; + } + allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); - it('Should not disconnect socket if server does not receive a pong from client before timeout', async function () { - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber, - pingTimeoutDisabled: true + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + (async () => { + try { + await client.invoke('login', {username: 'bob'}); + } catch (err) {} + })(); + + await client.listener('authenticate').once(); + assert.equal(middlewareWasExecuted, true); }); + }); - let serverWarning = null; - (async () => { - for await (let {warning} of server.listener('warning')) { - serverWarning = warning; - } - })(); + describe('PUBLISH_IN action', function () { + it('Should run PUBLISH_IN action in middleware if client publishes to a channel', async function () { + let middlewareWasExecuted = false; + let middlewareAction = null; - let serverDisconnectionCode = null; - (async () => { - for await (let event of server.listener('disconnection')) { - serverDisconnectionCode = event.code; - } - })(); + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.PUBLISH_IN) { + middlewareWasExecuted = true; + middlewareAction = action; + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); - let clientError = null; - (async () => { - for await (let {error} of client.listener('error')) { - clientError = error; - } - })(); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - let clientDisconnectCode = null; - (async () => { - for await (let event of client.listener('disconnect')) { - clientDisconnectCode = event.code; - } - })(); + await client.invokePublish('hello', 'world'); - await wait(1000); - assert.equal(clientError, null); - assert.equal(clientDisconnectCode, null); + assert.equal(middlewareWasExecuted, true); + assert.notEqual(middlewareAction, null); + assert.equal(middlewareAction.channel, 'hello'); + assert.equal(middlewareAction.data, 'world'); + }); - assert.equal(serverWarning, null); - assert.equal(serverDisconnectionCode, null); - }); - }); - }); + it('Should be able to delay and block publish using PUBLISH_IN middleware', async function () { + let middlewareWasExecuted = false; - describe('Middleware', function () { - let middlewareFunction; - let middlewareWasExecuted = false; + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.PUBLISH_IN) { + middlewareWasExecuted = true; + let error = new Error('Blocked by middleware'); + error.name = 'BlockedError'; + await wait(50); + action.block(error); + continue; + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); - beforeEach('Launch server without middleware before start', async function () { - portNumber++; - server = socketClusterServer.listen(portNumber, { - authKey: serverOptions.authKey, - wsEngine: WS_ENGINE - }); - await server.listener('ready').once(); - }); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - afterEach('Shut down server afterwards', async function () { - destroyTestCase(); - server.close(); - }); + let helloChannel = client.subscribe('hello'); + await helloChannel.listener('subscribe').once(); + + let receivedMessages = []; + (async () => { + for await (let data of helloChannel) { + receivedMessages.push(data); + } + })(); - describe('MIDDLEWARE_AUTHENTICATE', function () { - it('Should not run authenticate middleware if JWT token does not exist', async function () { - middlewareFunction = async function (req) { - middlewareWasExecuted = true; - }; - server.addMiddleware(server.MIDDLEWARE_AUTHENTICATE, middlewareFunction); + let error; + try { + await client.invokePublish('hello', 'world'); + } catch (err) { + error = err; + } + await wait(100); - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber + assert.equal(middlewareWasExecuted, true); + assert.notEqual(error, null); + assert.equal(error.name, 'BlockedError'); + assert.equal(receivedMessages.length, 0); }); - await client.listener('connect').once(); - assert.notEqual(middlewareWasExecuted, true); - }); + it('Delaying PUBLISH_IN action for one client should not affect other clients', async function () { + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.PUBLISH_IN) { + if (action.socket.request.url.indexOf('?delayMe=true') !== -1) { + // Long delay. + await wait(5000); + action.allow(); + continue; + } + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); - it('Should run authenticate middleware if JWT token exists', async function () { - global.localStorage.setItem('socketCluster.authToken', validSignedAuthTokenBob); + let clientA = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - middlewareFunction = async function (req) { - middlewareWasExecuted = true; - }; - server.addMiddleware(server.MIDDLEWARE_AUTHENTICATE, middlewareFunction); + let clientB = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + query: { + delayMe: true + }, + authTokenName: 'socketcluster.authToken' + }); - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber - }); + let clientC = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - (async () => { - try { - await client.invoke('login', {username: 'bob'}); - } catch (err) {} - })(); + await clientC.listener('connect').once(); - await client.listener('authenticate').once(); - assert.equal(middlewareWasExecuted, true); - }); - }); + let receivedMessages = []; + (async () => { + for await (let data of clientC.subscribe('foo')) { + receivedMessages.push(data); + } + })(); - describe('MIDDLEWARE_HANDSHAKE_SC', function () { - it('Should trigger correct events if MIDDLEWARE_HANDSHAKE_SC blocks with an error', async function () { - let middlewareWasExecuted = false; - let serverWarnings = []; - let clientErrors = []; - let abortStatus; + clientA.transmitPublish('foo', 'a1'); + clientA.transmitPublish('foo', 'a2'); + + clientB.transmitPublish('foo', 'b1'); + clientB.transmitPublish('foo', 'b2'); - middlewareFunction = async function (req) { await wait(100); - middlewareWasExecuted = true; - let err = new Error('SC handshake failed because the server was too lazy'); - err.name = 'TooLazyHandshakeError'; - throw err; - }; - server.addMiddleware(server.MIDDLEWARE_HANDSHAKE_SC, middlewareFunction); - (async () => { - for await (let {warning} of server.listener('warning')) { - serverWarnings.push(warning); - } - })(); + assert.equal(receivedMessages.length, 2); + assert.equal(receivedMessages[0], 'a1'); + assert.equal(receivedMessages[1], 'a2'); - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber + clientA.disconnect(); + clientB.disconnect(); + clientC.disconnect(); }); - (async () => { - for await (let {error} of client.listener('error')) { - clientErrors.push(error); - } - })(); + it('Should allow to change message in middleware when client invokePublish', async function() { + let clientMessage = 'world'; + let middlewareMessage = 'intercepted'; + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.PUBLISH_IN) { + action.allow({data: middlewareMessage}); + } else { + action.allow(); + } + } + }; - (async () => { - let event = await client.listener('connectAbort').once(); - abortStatus = event.code; - })(); + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); - await wait(200); - assert.equal(middlewareWasExecuted, true); - assert.notEqual(clientErrors[0], null); - assert.equal(clientErrors[0].name, 'TooLazyHandshakeError'); - assert.notEqual(clientErrors[1], null); - assert.equal(clientErrors[1].name, 'SocketProtocolError'); - assert.notEqual(serverWarnings[0], null); - assert.equal(serverWarnings[0].name, 'TooLazyHandshakeError'); - assert.notEqual(abortStatus, null); - }); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let helloChannel = client.subscribe('hello'); + await helloChannel.listener('subscribe').once(); + + let receivedMessages = []; + (async () => { + for await (let data of helloChannel) { + receivedMessages.push(data); + } + })(); - it('Should send back default 4008 status code if MIDDLEWARE_HANDSHAKE_SC blocks without providing a status code', async function () { - let middlewareWasExecuted = false; - let abortStatus; - let abortReason; + let error; + try { + await client.invokePublish('hello', clientMessage); + } catch (err) { + error = err; + } - middlewareFunction = async function (req) { await wait(100); - middlewareWasExecuted = true; - let err = new Error('SC handshake failed because the server was too lazy'); - err.name = 'TooLazyHandshakeError'; - throw err; - }; - server.addMiddleware(server.MIDDLEWARE_HANDSHAKE_SC, middlewareFunction); - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber + assert.notEqual(clientMessage, middlewareMessage); + assert.equal(receivedMessages[0], middlewareMessage); }); - (async () => { - let event = await client.listener('connectAbort').once(); - abortStatus = event.code; - abortReason = event.reason; - })(); + it('Should allow to change message in middleware when client transmitPublish', async function() { + let clientMessage = 'world'; + let middlewareMessage = 'intercepted'; + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.PUBLISH_IN) { + action.allow({data: middlewareMessage}); + } else { + action.allow(); + } + } + }; - await wait(200); - assert.equal(middlewareWasExecuted, true); - assert.equal(abortStatus, 4008); - assert.equal(abortReason, 'TooLazyHandshakeError: SC handshake failed because the server was too lazy'); - }); + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - it('Should send back custom status code if MIDDLEWARE_HANDSHAKE_SC blocks by providing a status code', async function () { - let middlewareWasExecuted = false; - let abortStatus; - let abortReason; + let helloChannel = client.subscribe('hello'); + await helloChannel.listener('subscribe').once(); + + let receivedMessages = []; + (async () => { + for await (let data of helloChannel) { + receivedMessages.push(data); + } + })(); + + let error; + try { + await client.transmitPublish('hello', clientMessage); + } catch (err) { + error = err; + } - middlewareFunction = async function (req) { await wait(100); - middlewareWasExecuted = true; - let err = new Error('SC handshake failed because of invalid query auth parameters'); - err.name = 'InvalidAuthQueryHandshakeError'; - // Set custom 4501 status code as a property of the error. - // We will treat this code as a fatal authentication failure on the front end. - // A status code of 4500 or higher means that the client shouldn't try to reconnect. - err.statusCode = 4501; - throw err; - }; - server.addMiddleware(server.MIDDLEWARE_HANDSHAKE_SC, middlewareFunction); - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber + assert.notEqual(clientMessage, middlewareMessage); + assert.equal(receivedMessages[0], middlewareMessage); + }) + }); + + describe('SUBSCRIBE action', function () { + it('Should run SUBSCRIBE action in middleware if client subscribes to a channel', async function () { + let middlewareWasExecuted = false; + let middlewareAction = null; + + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.SUBSCRIBE) { + middlewareWasExecuted = true; + middlewareAction = action; + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + await client.subscribe('hello').listener('subscribe').once(); + + assert.equal(middlewareWasExecuted, true); + assert.notEqual(middlewareAction, null); + assert.equal(middlewareAction.channel, 'hello'); }); - (async () => { - let event = await client.listener('connectAbort').once(); - abortStatus = event.code; - abortReason = event.reason; - })(); + it('Should maintain pub/sub order if SUBSCRIBE action is delayed in middleware even if client starts out in disconnected state', async function () { + let middlewareActions = []; - await wait(200); - assert.equal(middlewareWasExecuted, true); - assert.equal(abortStatus, 4501); - assert.equal(abortReason, 'InvalidAuthQueryHandshakeError: SC handshake failed because of invalid query auth parameters'); - }); + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + middlewareActions.push(action); + if (action.type === AGAction.SUBSCRIBE) { + await wait(100); + action.allow(); + continue; + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + autoConnect: false, + authTokenName: 'socketcluster.authToken' + }); - it('Should connect with a delay if next() is called after a timeout inside the middleware function', async function () { - let createConnectionTime = null; - let connectEventTime = null; - let abortStatus; - let abortReason; + let receivedMessage; - middlewareFunction = async function (req) { - await wait(500); - }; - server.addMiddleware(server.MIDDLEWARE_HANDSHAKE_SC, middlewareFunction); + let fooChannel = client.subscribe('foo'); + client.transmitPublish('foo', 'bar'); - createConnectionTime = Date.now(); - client = socketClusterClient.create({ - hostname: clientOptions.hostname, - port: portNumber + for await (let data of fooChannel) { + receivedMessage = data; + break; + } + + assert.equal(receivedMessage, 'bar'); + assert.equal(middlewareActions.length, 2); + assert.equal(middlewareActions[0].type, AGAction.SUBSCRIBE); + assert.equal(middlewareActions[0].channel, 'foo'); + assert.equal(middlewareActions[1].type, AGAction.PUBLISH_IN); + assert.equal(middlewareActions[1].channel, 'foo'); }); + }); + }); - (async () => { - let event = await client.listener('connectAbort').once(); - abortStatus = event.code; - abortReason = event.reason; - })(); + describe('MIDDLEWARE_OUTBOUND', function () { + describe('PUBLISH_OUT action', function () { + it('Should run PUBLISH_OUT action in middleware if client publishes to a channel', async function () { + let middlewareWasExecuted = false; + let middlewareAction = null; + + let middlewareFunction = async function (middlewareStream) { + for await (let action of middlewareStream) { + if (action.type === AGAction.PUBLISH_OUT) { + middlewareWasExecuted = true; + middlewareAction = action; + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_OUTBOUND, middlewareFunction); - await client.listener('connect').once(); - connectEventTime = Date.now(); - assert.equal(connectEventTime - createConnectionTime > 400, true); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + await client.subscribe('hello').listener('subscribe').once(); + await client.invokePublish('hello', 123); + + assert.equal(middlewareWasExecuted, true); + assert.notEqual(middlewareAction, null); + assert.equal(middlewareAction.channel, 'hello'); + assert.equal(middlewareAction.data, 123); + }); }); }); });