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 new file mode 100644 index 0000000..ca250f2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +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 +'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 above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +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/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 f3a25d4..e230110 100644 --- a/index.js +++ b/index.js @@ -2,23 +2,31 @@ * Module dependencies. */ -var http = require('http'); +const http = require('http'); /** - * Expose SCServer constructor. + * Expose AGServer constructor. * * @api public */ -module.exports.SCServer = require('./scserver'); +module.exports.AGServer = require('./server'); /** - * Expose SCSocket constructor. + * Expose AGServerSocket constructor. * * @api public */ -module.exports.SCSocket = require('./scsocket'); +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,26 +34,26 @@ module.exports.SCSocket = require('./scsocket'); * @param {Number} port * @param {Function} callback * @param {Object} options - * @return {SCServer} websocket cluster server + * @return {AGServer} websocket cluster server * @api public */ module.exports.listen = function (port, options, fn) { - if ('function' == typeof options) { + if (typeof options === 'function') { fn = options; options = {}; } - var server = http.createServer(function (req, res) { + let server = http.createServer((req, res) => { res.writeHead(501); res.end('Not Implemented'); }); - var engine = module.exports.attach(server, options); - engine.httpServer = server; + let socketClusterServer = module.exports.attach(server, options); + socketClusterServer.httpServer = server; server.listen(port, fn); - return engine; + return socketClusterServer; }; /** @@ -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; - var socketClusterServer = new module.exports.SCServer(options); + let socketClusterServer = new module.exports.AGServer(options); return socketClusterServer; }; diff --git a/package.json b/package.json index 3dadc97..8c86ae4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socketcluster-server", - "version": "9.1.3", + "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.0.0", - "base64id": "0.1.0", - "component-emitter": "1.2.1", - "lodash.clonedeep": "4.5.0", - "sc-auth": "~4.1.2", - "sc-errors": "~1.3.3", - "sc-formatter": "~3.0.2", - "sc-simple-broker": "~2.1.0", - "uuid": "3.1.0", - "uws": "9.14.0", - "ws": "3.3.3" + "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": "3.0.2", - "socketcluster-client": "^8.0.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 dad04cc..0000000 --- a/response.js +++ /dev/null @@ -1,55 +0,0 @@ -var scErrors = require('sc-errors'); -var InvalidActionError = scErrors.InvalidActionError; - -var Response = function (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) { - var responseData = { - rid: this.id - }; - if (data !== undefined) { - responseData.data = data; - } - this._respond(responseData, options); - } -}; - -Response.prototype.error = function (error, data, options) { - if (this.id) { - var err = scErrors.dehydrateError(error); - - var 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 e7baf90..0000000 --- a/scserver.js +++ /dev/null @@ -1,953 +0,0 @@ -var SCSocket = require('./scsocket'); -var AuthEngine = require('sc-auth').AuthEngine; -var formatter = require('sc-formatter'); -var EventEmitter = require('events').EventEmitter; -var base64id = require('base64id'); -var async = require('async'); -var url = require('url'); -var crypto = require('crypto'); -var uuid = require('uuid'); -var SCSimpleBroker = require('sc-simple-broker').SCSimpleBroker; - -var scErrors = require('sc-errors'); -var AuthTokenExpiredError = scErrors.AuthTokenExpiredError; -var AuthTokenInvalidError = scErrors.AuthTokenInvalidError; -var AuthTokenNotBeforeError = scErrors.AuthTokenNotBeforeError; -var AuthTokenError = scErrors.AuthTokenError; -var SilentMiddlewareBlockedError = scErrors.SilentMiddlewareBlockedError; -var InvalidOptionsError = scErrors.InvalidOptionsError; -var InvalidActionError = scErrors.InvalidActionError; -var BrokerError = scErrors.BrokerError; -var ServerProtocolError = scErrors.ServerProtocolError; - - -var SCServer = function (options) { - var self = this; - - EventEmitter.call(this); - - var opts = { - brokerEngine: new SCSimpleBroker(), - wsEngine: 'uws', - wsEngineServerOptions: {}, - maxPayload: null, - allowClientPublish: true, - ackTimeout: 10000, - handshakeTimeout: 10000, - pingTimeout: 20000, - pingInterval: 8000, - origins: '*:*', - appName: uuid.v4(), - path: '/socketcluster/', - authDefaultExpiry: 86400, - authSignAsync: false, - authVerifyAsync: true, - pubSubBatchDuration: null, - middlewareEmitWarnings: true - }; - - for (var i in options) { - if (options.hasOwnProperty(i)) { - opts[i] = options[i]; - } - } - - this.options = opts; - - this.MIDDLEWARE_HANDSHAKE_WS = 'handshakeWS'; - this.MIDDLEWARE_HANDSHAKE_SC = 'handshakeSC'; - this.MIDDLEWARE_EMIT = 'emit'; - 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_EMIT] = []; - 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.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; - this._path = opts.path; - this.isReady = false; - - this.brokerEngine.once('ready', function () { - self.isReady = true; - EventEmitter.prototype.emit.call(self, 'ready'); - }); - - var wsEngine = require(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'); - } - var 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 - }; - this.defaultSignatureOptions = { - expiresIn: opts.authDefaultExpiry, - async: this.authSignAsync - }; - - if (opts.authAlgorithm != null) { - this.defaultVerificationOptions.algorithms = [opts.authAlgorithm]; - 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(); - - var 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(EventEmitter.prototype); - -SCServer.prototype.setAuthEngine = function (authEngine) { - this.auth = authEngine; -}; - -SCServer.prototype.setCodecEngine = function (codecEngine) { - this.codec = codecEngine; -}; - -SCServer.prototype._handleServerError = function (error) { - if (typeof error == 'string') { - error = new ServerProtocolError(error); - } - this.emit('error', error); -}; - -SCServer.prototype._handleSocketError = function (error) { - // We don't want to crash the entire worker on socket error - // so we emit it as a warning instead. - this.emit('warning', error); -}; - -SCServer.prototype._handleHandshakeTimeout = function (scSocket) { - scSocket.disconnect(4005); -}; - -SCServer.prototype._subscribeSocket = function (socket, channelOptions, callback) { - var self = this; - - if (Array.isArray(channelOptions)) { - var tasks = []; - for (var i in channelOptions) { - if (channelOptions.hasOwnProperty(i)) { - (function (singleChannelOptions) { - tasks.push(function (cb) { - self._subscribeSocketToSingleChannel(socket, singleChannelOptions, cb); - }); - })(channelOptions[i]); - } - } - async.waterfall(tasks, function (err) { - callback && callback(err); - }); - } else { - this._subscribeSocketToSingleChannel(socket, channelOptions, callback); - } -}; - -SCServer.prototype._subscribeSocketToSingleChannel = function (socket, channelOptions, callback) { - var self = this; - - if (!channelOptions) { - callback && callback('Socket ' + socket.id + ' provided a malformated channel payload'); - return; - } - - if (this.socketChannelLimit && socket.channelSubscriptionsCount >= this.socketChannelLimit) { - callback && callback('Socket ' + socket.id + ' tried to exceed the channel subscription limit of ' + - this.socketChannelLimit); - return; - } - - var channelName = channelOptions.channel; - - if (typeof channelName != 'string') { - callback && callback('Socket ' + socket.id + ' provided an invalid channel name'); - return; - } - - if (socket.channelSubscriptionsCount == null) { - socket.channelSubscriptionsCount = 0; - } - if (socket.channelSubscriptions[channelName] == null) { - socket.channelSubscriptions[channelName] = true; - socket.channelSubscriptionsCount++; - } - - this.brokerEngine.subscribeSocket(socket, channelName, function (err) { - if (err) { - delete socket.channelSubscriptions[channelName]; - socket.channelSubscriptionsCount--; - } else { - socket.emit('subscribe', channelName, channelOptions); - self.emit('subscription', socket, channelName, channelOptions); - } - callback && callback(err); - }); -}; - -SCServer.prototype._unsubscribeSocket = function (socket, channels, callback) { - var self = this; - - if (channels == null) { - channels = []; - for (var channel in socket.channelSubscriptions) { - if (socket.channelSubscriptions.hasOwnProperty(channel)) { - channels.push(channel); - } - } - } - if (Array.isArray(channels)) { - var tasks = []; - var len = channels.length; - for (var i = 0; i < len; i++) { - (function (channel) { - tasks.push(function (cb) { - self._unsubscribeSocketFromSingleChannel(socket, channel, cb); - }); - })(channels[i]); - } - async.waterfall(tasks, function (err) { - callback && callback(err); - }); - } else { - this._unsubscribeSocketFromSingleChannel(socket, channels, callback); - } -}; - -SCServer.prototype._unsubscribeSocketFromSingleChannel = function (socket, channel, callback) { - var self = this; - - if (typeof channel != 'string') { - callback && callback('Socket ' + socket.id + ' provided an invalid channel name'); - return; - } - - delete socket.channelSubscriptions[channel]; - if (socket.channelSubscriptionsCount != null) { - socket.channelSubscriptionsCount--; - } - - this.brokerEngine.unsubscribeSocket(socket, channel, function (err) { - socket.emit('unsubscribe', channel); - self.emit('unsubscription', socket, channel); - callback && callback(err); - }); -}; - -SCServer.prototype._processTokenError = function (err) { - var authError = null; - var 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) { - var badAuthStatus = { - authError: error, - signedAuthToken: signedAuthToken - }; - scSocket.emit('badAuthToken', badAuthStatus); - this.emit('badSocketAuthToken', scSocket, badAuthStatus); -}; - -SCServer.prototype._processAuthToken = function (scSocket, signedAuthToken, callback) { - var self = this; - - this.auth.verifyToken(signedAuthToken, this.verificationKey, this.defaultVerificationOptions, function (err, authToken) { - if (authToken) { - scSocket.signedAuthToken = signedAuthToken; - scSocket.authToken = authToken; - 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) { - self._passThroughAuthenticateMiddleware({ - socket: scSocket, - signedAuthToken: scSocket.signedAuthToken, - authToken: scSocket.authToken - }, function (middlewareError, isBadToken) { - if (middlewareError) { - scSocket.authToken = null; - scSocket.authState = scSocket.UNAUTHENTICATED; - if (isBadToken) { - self._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); - }); - } else { - var errorData = self._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.emit('error', errorData.authError); - if (errorData.isBadToken) { - self._emitBadAuthTokenError(scSocket, errorData.authError, signedAuthToken); - } - } - callback(errorData.authError, errorData.isBadToken); - } - }); -}; - -SCServer.prototype._handleSocketConnection = function (wsSocket, upgradeReq) { - var self = this; - - if (this.options.wsEngine == 'ws') { - // Normalize ws module to match uws module. - wsSocket.upgradeReq = upgradeReq; - } - - var id = this.generateId(); - - var scSocket = new SCSocket(id, this, wsSocket); - scSocket.exchange = self.exchange; - - scSocket.on('error', function (err) { - self._handleSocketError(err); - }); - - self.pendingClients[id] = scSocket; - self.pendingClientsCount++; - - // Emit event to signal that a socket handshake has been initiated. - // The _handshake event is for internal use (including third-party plugins) - this.emit('_handshake', scSocket); - this.emit('handshake', scSocket); - - scSocket.on('#authenticate', function (signedAuthToken, respond) { - self._processAuthToken(scSocket, signedAuthToken, function (err, isBadToken) { - if (err) { - if (isBadToken) { - scSocket.deauthenticate(); - } - } else { - scSocket.emit('authenticate', scSocket.authToken); - self.emit('authentication', scSocket, scSocket.authToken); - } - var authStatus = { - isAuthenticated: !!scSocket.authToken, - authError: scErrors.dehydrateError(err) - }; - if (err && isBadToken) { - respond(err, authStatus); - } else { - respond(null, authStatus); - } - }); - }); - - scSocket.on('#removeAuthToken', function () { - var oldToken = scSocket.authToken; - scSocket.authToken = null; - scSocket.authState = scSocket.UNAUTHENTICATED; - scSocket.emit('deauthenticate', oldToken); - self.emit('deauthentication', scSocket, oldToken); - }); - - scSocket.on('#subscribe', function (channelOptions, res) { - if (!channelOptions) { - channelOptions = {}; - } else if (typeof channelOptions == 'string') { - channelOptions = { - channel: channelOptions - }; - } - // This is an invalid state; it means the client tried to subscribe before - // having completed the handshake. - if (scSocket.state == scSocket.OPEN) { - self._subscribeSocket(scSocket, channelOptions, function (err) { - if (err) { - var error = new BrokerError('Failed to subscribe socket to the ' + channelOptions.channel + ' channel - ' + err); - res(error); - scSocket.emit('error', error); - } else { - if (channelOptions.batch) { - res(undefined, undefined, {batch: true}); - } else { - res(); - } - } - }); - } else { - var error = new InvalidActionError('Cannot subscribe socket to a channel before it has completed the handshake'); - res(error); - self.emit('warning', error); - } - }); - - scSocket.on('#unsubscribe', function (channel, res) { - self._unsubscribeSocket(scSocket, channel, function (err) { - if (err) { - var error = new BrokerError('Failed to unsubscribe socket from the ' + channel + ' channel - ' + err); - res(error); - scSocket.emit('error', error); - } else { - res(); - } - }); - }); - - var cleanupSocket = function (type, code, data) { - clearTimeout(scSocket._handshakeTimeoutRef); - - scSocket.off('#handshake'); - scSocket.off('#authenticate'); - scSocket.off('#removeAuthToken'); - scSocket.off('#subscribe'); - scSocket.off('#unsubscribe'); - scSocket.off('authenticate'); - scSocket.off('deauthenticate'); - scSocket.off('_disconnect'); - scSocket.off('_connectAbort'); - - var isClientFullyConnected = !!self.clients[id]; - - if (isClientFullyConnected) { - delete self.clients[id]; - self.clientsCount--; - } - - var isClientPending = !!self.pendingClients[id]; - if (isClientPending) { - delete self.pendingClients[id]; - self.pendingClientsCount--; - } - - self._unsubscribeSocket(scSocket, null, function (err) { - if (err) { - scSocket.emit('error', new BrokerError('Failed to unsubscribe socket from all channels - ' + err)); - } else { - if (type == 'disconnect') { - self.emit('_disconnection', scSocket, code, data); - self.emit('disconnection', scSocket, code, data); - } else if (type == 'abort') { - self.emit('_connectionAbort', scSocket, code, data); - self.emit('connectionAbort', scSocket, code, data); - } - self.emit('_closure', scSocket, code, data); - self.emit('closure', scSocket, code, data); - } - }); - }; - - scSocket.once('_disconnect', cleanupSocket.bind(scSocket, 'disconnect')); - scSocket.once('_connectAbort', cleanupSocket.bind(scSocket, 'abort')); - - scSocket._handshakeTimeoutRef = setTimeout(this._handleHandshakeTimeout.bind(this, scSocket), this.handshakeTimeout); - scSocket.once('#handshake', function (data, respond) { - if (!data) { - data = {}; - } - var signedAuthToken = data.authToken || null; - clearTimeout(scSocket._handshakeTimeoutRef); - - self._passThroughHandshakeSCMiddleware({ - socket: scSocket - }, function (err) { - if (err) { - respond(err); - return; - } - self._processAuthToken(scSocket, signedAuthToken, function (err, isBadToken) { - if (scSocket.state == scSocket.CLOSED) { - return; - } - - var clientSocketStatus = { - id: scSocket.id, - pingTimeout: self.pingTimeout - }; - var serverSocketStatus = { - id: scSocket.id, - pingTimeout: self.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 (self.pendingClients[id]) { - delete self.pendingClients[id]; - self.pendingClientsCount--; - } - self.clients[id] = scSocket; - self.clientsCount++; - - scSocket.state = scSocket.OPEN; - - scSocket.emit('connect', serverSocketStatus); - scSocket.emit('_connect', serverSocketStatus); - - self.emit('_connection', scSocket, serverSocketStatus); - self.emit('connection', scSocket, serverSocketStatus); - - if (clientSocketStatus.isAuthenticated) { - scSocket.emit('authenticate', scSocket.authToken); - self.emit('authentication', scSocket, scSocket.authToken); - } - // Treat authentication failure as a 'soft' error - respond(null, clientSocketStatus); - }); - }); - }); -}; - -SCServer.prototype.close = function () { - this.isReady = false; - this.wsServer.close.apply(this.wsServer, arguments); -}; - -SCServer.prototype.getPath = function () { - return this._path; -}; - -SCServer.prototype.generateId = function () { - return base64id.generateId(); -}; - -SCServer.prototype.addMiddleware = function (type, middleware) { - this._middleware[type].push(middleware); -}; - -SCServer.prototype.removeMiddleware = function (type, middleware) { - var middlewareFunctions = this._middleware[type]; - - this._middleware[type] = middlewareFunctions.filter(function (fn) { - return fn != middleware; - }); -}; - -SCServer.prototype.verifyHandshake = function (info, cb) { - var self = this; - - var req = info.req; - var origin = info.origin; - if (origin == 'null' || origin == null) { - origin = '*'; - } - var ok = false; - - if (this._allowAllOrigins) { - ok = true; - } else { - try { - var 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) { - var handshakeMiddleware = this._middleware[this.MIDDLEWARE_HANDSHAKE_WS]; - if (handshakeMiddleware.length) { - var callbackInvoked = false; - async.applyEachSeries(handshakeMiddleware, req, function (err) { - if (callbackInvoked) { - self.emit('warning', new InvalidActionError('Callback for ' + self.MIDDLEWARE_HANDSHAKE_WS + ' middleware was already invoked')); - } else { - callbackInvoked = true; - if (err) { - if (err === true) { - err = new SilentMiddlewareBlockedError('Action was silently blocked by ' + self.MIDDLEWARE_HANDSHAKE_WS + ' middleware', self.MIDDLEWARE_HANDSHAKE_WS); - } else if (self.middlewareEmitWarnings) { - self.emit('warning', err); - } - cb(false, 401, err); - } else { - cb(true); - } - } - }); - } else { - cb(true); - } - } else { - var err = new ServerProtocolError('Failed to authorize socket handshake - Invalid origin: ' + origin); - this.emit('warning', err); - cb(false, 403, err); - } -}; - -SCServer.prototype._isPrivateTransmittedEvent = function (event) { - return typeof event == 'string' && event.indexOf('#') == 0; -}; - -SCServer.prototype.verifyInboundEvent = function (socket, eventName, eventData, cb) { - var request = { - socket: socket, - event: eventName, - data: eventData - }; - - var token = socket.getAuthToken(); - if (this.isAuthTokenExpired(token)) { - request.authTokenExpiredError = new AuthTokenExpiredError('The socket auth token has expired', token.exp); - - socket.deauthenticate(); - } - - this._passThroughMiddleware(request, cb); -}; - -SCServer.prototype.isAuthTokenExpired = function (token) { - if (token && token.exp != null) { - var currentTime = Date.now(); - var expiryMilliseconds = token.exp * 1000; - return currentTime > expiryMilliseconds; - } - return false; -}; - -SCServer.prototype._passThroughMiddleware = function (options, cb) { - var self = this; - - var callbackInvoked = false; - - var request = { - socket: options.socket - }; - - if (options.authTokenExpiredError != null) { - request.authTokenExpiredError = options.authTokenExpiredError; - } - - var event = options.event; - - if (this._isPrivateTransmittedEvent(event)) { - if (event == '#subscribe') { - var 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. - cb(request.authTokenExpiredError, eventData); - } else { - async.applyEachSeries(this._middleware[this.MIDDLEWARE_SUBSCRIBE], request, - function (err) { - if (callbackInvoked) { - self.emit('warning', new InvalidActionError('Callback for ' + self.MIDDLEWARE_SUBSCRIBE + ' middleware was already invoked')); - } else { - callbackInvoked = true; - if (err) { - if (err === true) { - err = new SilentMiddlewareBlockedError('Action was silently blocked by ' + self.MIDDLEWARE_SUBSCRIBE + ' middleware', self.MIDDLEWARE_SUBSCRIBE); - } else if (self.middlewareEmitWarnings) { - self.emit('warning', err); - } - } - if (request.data !== undefined) { - eventData.data = request.data; - } - cb(err, eventData); - } - } - ); - } - } else if (event == '#publish') { - if (this.allowClientPublish) { - var eventData = options.data || {}; - request.channel = eventData.channel; - request.data = eventData.data; - - async.applyEachSeries(this._middleware[this.MIDDLEWARE_PUBLISH_IN], request, - function (err) { - if (callbackInvoked) { - self.emit('warning', new InvalidActionError('Callback for ' + self.MIDDLEWARE_PUBLISH_IN + ' middleware was already invoked')); - } else { - callbackInvoked = true; - if (request.data !== undefined) { - eventData.data = request.data; - } - if (err) { - if (err === true) { - err = new SilentMiddlewareBlockedError('Action was silently blocked by ' + self.MIDDLEWARE_PUBLISH_IN + ' middleware', self.MIDDLEWARE_PUBLISH_IN); - } else if (self.middlewareEmitWarnings) { - self.emit('warning', err); - } - cb(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'); - self.emit('warning', err); - cb(err, eventData, request.ackData); - return; - } - self.exchange.publish(request.channel, request.data, function (err) { - if (err) { - err = new BrokerError(err); - self.emit('warning', err); - } - cb(err, eventData, request.ackData); - }); - } - } - } - ); - } else { - var noPublishError = new InvalidActionError('Client publish feature is disabled'); - self.emit('warning', noPublishError); - cb(noPublishError, options.data); - } - } else { - // Do not allow blocking other reserved events or it could interfere with SC behaviour - cb(null, options.data); - } - } else { - request.event = event; - request.data = options.data; - - async.applyEachSeries(this._middleware[this.MIDDLEWARE_EMIT], request, - function (err) { - if (callbackInvoked) { - self.emit('warning', new InvalidActionError('Callback for ' + self.MIDDLEWARE_EMIT + ' middleware was already invoked')); - } else { - callbackInvoked = true; - if (err) { - if (err === true) { - err = new SilentMiddlewareBlockedError('Action was silently blocked by ' + self.MIDDLEWARE_EMIT + ' middleware', self.MIDDLEWARE_EMIT); - } else if (self.middlewareEmitWarnings) { - self.emit('warning', err); - } - } - cb(err, request.data); - } - } - ); - } -}; - -SCServer.prototype._passThroughAuthenticateMiddleware = function (options, cb) { - var self = this; - var callbackInvoked = false; - - var request = { - socket: options.socket, - authToken: options.authToken - }; - - async.applyEachSeries(this._middleware[this.MIDDLEWARE_AUTHENTICATE], request, - function (err, results) { - if (callbackInvoked) { - self.emit('warning', new InvalidActionError('Callback for ' + self.MIDDLEWARE_AUTHENTICATE + ' middleware was already invoked')); - } else { - callbackInvoked = true; - var isBadToken = false; - if (results.length) { - isBadToken = results[results.length - 1] || false; - } - if (err) { - if (err === true) { - err = new SilentMiddlewareBlockedError('Action was silently blocked by ' + self.MIDDLEWARE_AUTHENTICATE + ' middleware', self.MIDDLEWARE_AUTHENTICATE); - } else if (self.middlewareEmitWarnings) { - self.emit('warning', err); - } - } - cb(err, isBadToken); - } - } - ); -}; - -SCServer.prototype._passThroughHandshakeSCMiddleware = function (options, cb) { - var self = this; - var callbackInvoked = false; - - var request = { - socket: options.socket - }; - - async.applyEachSeries(this._middleware[this.MIDDLEWARE_HANDSHAKE_SC], request, - function (err) { - if (callbackInvoked) { - self.emit('warning', new InvalidActionError('Callback for ' + self.MIDDLEWARE_HANDSHAKE_SC + ' middleware was already invoked')); - } else { - callbackInvoked = true; - if (err) { - if (err === true) { - err = new SilentMiddlewareBlockedError('Action was silently blocked by ' + self.MIDDLEWARE_HANDSHAKE_SC + ' middleware', self.MIDDLEWARE_HANDSHAKE_SC); - } else if (self.middlewareEmitWarnings) { - self.emit('warning', err); - } - } - cb(err); - } - } - ); -}; - -SCServer.prototype.verifyOutboundEvent = function (socket, eventName, eventData, options, cb) { - var self = this; - - var callbackInvoked = false; - - if (eventName == '#publish') { - var request = { - socket: socket, - channel: eventData.channel, - data: eventData.data - }; - async.applyEachSeries(this._middleware[this.MIDDLEWARE_PUBLISH_OUT], request, - function (err) { - if (callbackInvoked) { - self.emit('warning', new InvalidActionError('Callback for ' + self.MIDDLEWARE_PUBLISH_OUT + ' middleware was already invoked')); - } else { - callbackInvoked = true; - if (request.data !== undefined) { - eventData.data = request.data; - } - if (err) { - if (err === true) { - err = new SilentMiddlewareBlockedError('Action was silently blocked by ' + self.MIDDLEWARE_PUBLISH_OUT + ' middleware', self.MIDDLEWARE_PUBLISH_OUT); - } else if (self.middlewareEmitWarnings) { - self.emit('warning', err); - } - cb(err, eventData); - } else { - if (options && request.useCache) { - options.useCache = true; - } - cb(null, eventData); - } - } - } - ); - } else { - cb(null, eventData); - } -}; - -module.exports = SCServer; diff --git a/scsocket.js b/scsocket.js deleted file mode 100644 index ad5d773..0000000 --- a/scsocket.js +++ /dev/null @@ -1,480 +0,0 @@ -var cloneDeep = require('lodash.clonedeep'); -var Emitter = require('component-emitter'); -var Response = require('./response').Response; - -var scErrors = require('sc-errors'); -var InvalidArgumentsError = scErrors.InvalidArgumentsError; -var SocketProtocolError = scErrors.SocketProtocolError; -var TimeoutError = scErrors.TimeoutError; - - -var SCSocket = function (id, server, socket) { - var self = this; - - Emitter.call(this); - - this._localEvents = { - 'subscribe': 1, - 'unsubscribe': 1, - 'connect': 1, - '_connect': 1, - 'disconnect': 1, - '_disconnect': 1, - 'connectAbort': 1, - '_connectAbort': 1, - 'close': 1, - '_close': 1, - 'message': 1, - 'error': 1, - 'authenticate': 1, - 'deauthenticate': 1, - 'badAuthToken': 1, - 'raw': 1 - }; - - this._autoAckEvents = { - '#publish': 1 - }; - - this.id = id; - this.server = server; - this.socket = socket; - this.state = this.CONNECTING; - - this.request = this.socket.upgradeReq || {}; - - if (this.server.options.wsEngine == 'uws') { - this.request.connection = this.socket._socket; - } - 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', function (err) { - Emitter.prototype.emit.call(self, 'error', err); - }); - - this.socket.on('close', function (code, data) { - self._onSCClose(code, data); - }); - - this._pingIntervalTicker = setInterval(this._sendPing.bind(this), this.server.pingInterval); - this._resetPongTimeout(); - - // Receive incoming raw messages - this.socket.on('message', function (message, flags) { - self._resetPongTimeout(); - - Emitter.prototype.emit.call(self, 'message', message); - - var obj; - try { - obj = self.decode(message); - } catch (err) { - if (err.name == 'Error') { - err.name = 'InvalidMessageError'; - } - Emitter.prototype.emit.call(self, 'error', err); - return; - } - - // If pong - if (obj == '#2') { - var token = self.getAuthToken(); - if (self.server.isAuthTokenExpired(token)) { - self.deauthenticate(); - } - } else { - if (Array.isArray(obj)) { - var len = obj.length; - for (var i = 0; i < len; i++) { - self._handleEventObject(obj[i], message); - } - } else { - self._handleEventObject(obj, message); - } - } - }); -}; - -SCSocket.prototype = Object.create(Emitter.prototype); - -SCSocket.CONNECTING = SCSocket.prototype.CONNECTING = 'connecting'; -SCSocket.OPEN = SCSocket.prototype.OPEN = 'open'; -SCSocket.CLOSED = SCSocket.prototype.CLOSED = 'closed'; - -SCSocket.AUTHENTICATED = SCSocket.prototype.AUTHENTICATED = 'authenticated'; -SCSocket.UNAUTHENTICATED = SCSocket.prototype.UNAUTHENTICATED = 'unauthenticated'; - -SCSocket.ignoreStatuses = scErrors.socketProtocolIgnoreStatuses; -SCSocket.errorStatuses = scErrors.socketProtocolErrorStatuses; - -SCSocket.prototype._sendPing = function () { - if (this.state != this.CLOSED) { - this.sendObject('#1'); - } -}; - -SCSocket.prototype._handleEventObject = function (obj, message) { - var self = this; - - if (obj && obj.event != null) { - var eventName = obj.event; - - if (self._localEvents[eventName] == null) { - var response = new Response(self, obj.cid); - self.server.verifyInboundEvent(self, eventName, obj.data, function (err, newEventData, ackData) { - if (err) { - response.error(err, ackData); - } else { - if (eventName == '#disconnect') { - var disconnectData = newEventData || {}; - self._onSCClose(disconnectData.code, disconnectData.data); - } else { - if (self._autoAckEvents[eventName]) { - if (ackData !== undefined) { - response.end(ackData); - } else { - response.end(); - } - Emitter.prototype.emit.call(self, eventName, newEventData); - } else { - Emitter.prototype.emit.call(self, eventName, newEventData, response.callback.bind(response)); - } - } - } - }); - } - } else if (obj && obj.rid != null) { - // If incoming message is a response to a previously sent message - var ret = self._callbackMap[obj.rid]; - if (ret) { - clearTimeout(ret.timeout); - delete self._callbackMap[obj.rid]; - var rehydratedError = scErrors.hydrateError(obj.error); - ret.callback(rehydratedError, obj.data); - } - } else { - // The last remaining case is to treat the message as raw - Emitter.prototype.emit.call(self, 'raw', message); - } -}; - -SCSocket.prototype._resetPongTimeout = function () { - var self = this; - - clearTimeout(this._pingTimeoutTicker); - this._pingTimeoutTicker = setTimeout(function() { - self._onSCClose(4001); - self.socket.close(4001); - }, this.server.pingTimeout); -}; - -SCSocket.prototype._nextCallId = function () { - return this._cid++; -}; - -SCSocket.prototype.getState = function () { - return this.state; -}; - -SCSocket.prototype.getBytesReceived = function () { - return this.socket.bytesReceived; -}; - -SCSocket.prototype._onSCClose = function (code, data) { - clearInterval(this._pingIntervalTicker); - clearTimeout(this._pingTimeoutTicker); - - if (this.state != this.CLOSED) { - var prevState = this.state; - this.state = this.CLOSED; - - if (prevState == this.CONNECTING) { - // Private connectAbort event for internal use only - Emitter.prototype.emit.call(this, '_connectAbort', code, data); - Emitter.prototype.emit.call(this, 'connectAbort', code, data); - } else { - // Private disconnect event for internal use only - Emitter.prototype.emit.call(this, '_disconnect', code, data); - Emitter.prototype.emit.call(this, 'disconnect', code, data); - } - // Private close event for internal use only - Emitter.prototype.emit.call(this, '_close', code, data); - Emitter.prototype.emit.call(this, 'close', code, data); - - if (!SCSocket.ignoreStatuses[code]) { - var failureMessage; - if (data) { - var reasonString; - if (typeof data == 'object') { - try { - reasonString = JSON.stringify(data); - } catch(error) { - reasonString = data.toString(); - } - } else { - reasonString = data; - } - failureMessage = 'Socket connection failed: ' + reasonString; - } else { - failureMessage = 'Socket connection failed for unknown reasons'; - } - var err = new SocketProtocolError(SCSocket.errorStatuses[code] || failureMessage, code); - Emitter.prototype.emit.call(this, 'error', err); - } - } -}; - -SCSocket.prototype.disconnect = function (code, data) { - code = code || 1000; - - if (typeof code != 'number') { - var err = new InvalidArgumentsError('If specified, the code argument must be a number'); - Emitter.prototype.emit.call(this, 'error', err); - } - - if (this.state != this.CLOSED) { - var packet = { - code: code, - data: data - }; - this.emit('#disconnect', packet); - this._onSCClose(code, data); - this.socket.close(code); - } -}; - -SCSocket.prototype.terminate = function () { - this.socket.terminate(); -}; - -SCSocket.prototype.send = function (data, options) { - var self = this; - - this.socket.send(data, options, function (err) { - if (err) { - self._onSCClose(1006, err.toString()); - } - }); -}; - -SCSocket.prototype.decode = function (message) { - return this.server.codec.decode(message); -}; - -SCSocket.prototype.encode = function (object) { - return this.server.codec.encode(object); -}; - -SCSocket.prototype.sendObjectBatch = function (object) { - var self = this; - - this._batchSendList.push(object); - if (this._batchTimeout) { - return; - } - - this._batchTimeout = setTimeout(function () { - delete self._batchTimeout; - if (self._batchSendList.length) { - var str; - try { - str = self.encode(self._batchSendList); - } catch (err) { - Emitter.prototype.emit.call(self, 'error', err); - } - if (str != null) { - self.send(str); - } - self._batchSendList = []; - } - }, this.server.options.pubSubBatchDuration || 0); -}; - -SCSocket.prototype.sendObjectSingle = function (object) { - var str; - try { - str = this.encode(object); - } catch (err) { - Emitter.prototype.emit.call(this, 'error', err); - } - if (str != null) { - this.send(str); - } -}; - -SCSocket.prototype.sendObject = function (object, options) { - if (options && options.batch) { - this.sendObjectBatch(object); - } else { - this.sendObjectSingle(object); - } -}; - -SCSocket.prototype.emit = function (event, data, callback, options) { - var self = this; - - if (this._localEvents[event] == null) { - this.server.verifyOutboundEvent(this, event, data, options, function (err, newData) { - var eventObject = { - event: event - }; - if (newData !== undefined) { - eventObject.data = newData; - } - - if (err) { - if (callback) { - eventObject.cid = self._nextCallId(); - callback(err, eventObject); - } - } else { - if (callback) { - eventObject.cid = self._nextCallId(); - var timeout = setTimeout(function () { - var error = new TimeoutError("Event response for '" + event + "' timed out"); - - delete self._callbackMap[eventObject.cid]; - callback(error, eventObject); - }, self.server.ackTimeout); - - self._callbackMap[eventObject.cid] = {callback: callback, timeout: timeout}; - } - if (options && options.useCache && options.stringifiedData != null) { - // Optimized - self.send(options.stringifiedData); - } else { - self.sendObject(eventObject); - } - } - }); - } else { - Emitter.prototype.emit.apply(this, arguments); - } -}; - -SCSocket.prototype.setAuthToken = function (data, options, callback) { - var self = this; - - var authToken = cloneDeep(data); - this.authState = this.AUTHENTICATED; - - if (options == null) { - options = {}; - } else { - options = cloneDeep(options); - if (options.algorithm != null) { - delete options.algorithm; - var err = new InvalidArgumentsError('Cannot change auth token algorithm at runtime - It must be specified as a config option on launch'); - Emitter.prototype.emit.call(this, 'error', err); - } - } - - options.mutatePayload = true; - - var 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. - var 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.server.auth.signToken(authToken, this.server.signatureKey, options, function (err, signedToken) { - if (err) { - Emitter.prototype.emit.call(self, 'error', err); - self._onSCClose(4002, err.toString()); - self.socket.close(4002); - callback && callback(err); - } else { - var tokenData = { - token: signedToken - }; - self.emit('#setAuthToken', tokenData, callback); - } - }); - - this.authToken = authToken; - this.emit('authenticate', this.authToken); - this.server.emit('authentication', this, this.authToken); -}; - -SCSocket.prototype.getAuthToken = function () { - return this.authToken; -}; - -SCSocket.prototype.deauthenticate = function (callback) { - var oldToken = this.authToken; - this.authToken = null; - this.authState = this.UNAUTHENTICATED; - this.emit('#removeAuthToken', null, callback); - this.emit('deauthenticate', oldToken); - this.server.emit('deauthentication', this, oldToken); -}; - -SCSocket.prototype.kickOut = function (channel, message, callback) { - if (channel == null) { - for (var i in this.channelSubscriptions) { - if (this.channelSubscriptions.hasOwnProperty(i)) { - this.emit('#kickOut', {message: message, channel: i}); - } - } - } else { - this.emit('#kickOut', {message: message, channel: channel}); - } - this.server.brokerEngine.unsubscribeSocket(this, channel, callback); -}; - -SCSocket.prototype.subscriptions = function () { - var subs = []; - for (var i in this.channelSubscriptions) { - if (this.channelSubscriptions.hasOwnProperty(i)) { - subs.push(i); - } - } - return subs; -}; - -SCSocket.prototype.isSubscribed = function (channel) { - return !!this.channelSubscriptions[channel]; -}; - -module.exports = SCSocket; 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 73b55cd..f5a671d 100644 --- a/test/integration.js +++ b/test/integration.js @@ -1,1118 +1,3763 @@ -var assert = require('assert'); -var socketClusterServer = require('../'); -var socketCluster = require('socketcluster-client'); -var localStorage = require('localStorage'); +const assert = require('assert'); +const socketClusterServer = require('../'); +const AGAction = require('../action'); +const socketClusterClient = require('socketcluster-client'); +const localStorage = require('localStorage'); +const AGSimpleBroker = require('ag-simple-broker'); // Add to the global scope like in browser. global.localStorage = localStorage; -var PORT = 8008; +let clientOptions; +let serverOptions; -var clientOptions = { - hostname: '127.0.0.1', - port: PORT -}; - -var serverOptions = { - authKey: 'testkey' -}; - -var allowedUsers = { +let allowedUsers = { bob: true, alice: true }; -var TEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 10; +const PORT_NUMBER = 8008; +const WS_ENGINE = 'ws'; +const LOG_WARNINGS = false; +const LOG_ERRORS = false; -var validSignedAuthTokenBob = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImJvYiIsImV4cCI6MTU5NjE0NzQ3NzQ3LCJpYXQiOjE1MDI3NDc3NDZ9.hjR769TX0vpDzZPl7a1UgudYtuUj8KikJ105IV3UHsc'; -var validSignedAuthTokenAlice = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFsaWNlIiwiaWF0IjoxNTE4NzI4MjU5LCJleHAiOjE1MTg4MTQ2NTl9.PUkz5_OvfVO9fMJo_2-rJtcDsEFHJCz6yDaMMb9R8Ls'; -var invalidSignedAuthToken = 'fakebGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fakec2VybmFtZSI6ImJvYiIsImlhdCI6MTUwMjYyNTIxMywiZXhwIjoxNTAyNzExNjEzfQ.fakemYcOOjM9bzmS4UYRvlWSk_lm3WGHvclmFjLbyOk'; +const TEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 10; -var server, client; +let validSignedAuthTokenBob = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImJvYiIsImV4cCI6MzE2Mzc1ODk3OTA4MDMxMCwiaWF0IjoxNTAyNzQ3NzQ2fQ.dSZOfsImq4AvCu-Or3Fcmo7JNv1hrV3WqxaiSKkTtAo'; +let validSignedAuthTokenAlice = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFsaWNlIiwiaWF0IjoxNTE4NzI4MjU5LCJleHAiOjMxNjM3NTg5NzkwODAzMTB9.XxbzPPnnXrJfZrS0FJwb_EAhIu2VY5i7rGyUThtNLh4'; +let invalidSignedAuthToken = 'fakebGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fakec2VybmFtZSI6ImJvYiIsImlhdCI6MTUwMjYyNTIxMywiZXhwIjoxNTAyNzExNjEzfQ.fakemYcOOjM9bzmS4UYRvlWSk_lm3WGHvclmFjLbyOk'; -var connectionHandler = function (socket) { - socket.on('login', function (userDetails, respond) { - if (allowedUsers[userDetails.username]) { - socket.setAuthToken(userDetails); - respond(); - } else { - var err = new Error('Failed to login'); - err.name = 'FailedLoginError'; - respond(err); - } +let server, client; + +function wait(duration) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, duration); }); - socket.on('loginWithTenDayExpiry', function (userDetails, respond) { - if (allowedUsers[userDetails.username]) { - socket.setAuthToken(userDetails, { - expiresIn: TEN_DAYS_IN_SECONDS - }); - respond(); - } else { - var err = new Error('Failed to login'); - err.name = 'FailedLoginError'; - respond(err); +} + +async function resolveAfterTimeout(duration, value) { + await wait(duration); + return value; +}; + +function connectionHandler(socket) { + (async () => { + for await (let rpc of socket.procedure('login')) { + if (rpc.data && allowedUsers[rpc.data.username]) { + socket.setAuthToken(rpc.data); + rpc.end(); + } else { + let err = new Error('Failed to login'); + err.name = 'FailedLoginError'; + rpc.error(err); + } } - }); - socket.on('loginWithTenDayExp', function (userDetails, respond) { - if (allowedUsers[userDetails.username]) { - userDetails.exp = Math.round(Date.now() / 1000) + TEN_DAYS_IN_SECONDS; - socket.setAuthToken(userDetails); - respond(); - } else { - var err = new Error('Failed to login'); - err.name = 'FailedLoginError'; - respond(err); + })(); + + (async () => { + for await (let rpc of socket.procedure('loginWithTenDayExpiry')) { + if (allowedUsers[rpc.data.username]) { + socket.setAuthToken(rpc.data, { + expiresIn: TEN_DAYS_IN_SECONDS + }); + rpc.end(); + } else { + let err = new Error('Failed to login'); + err.name = 'FailedLoginError'; + rpc.error(err); + } } - }); - socket.on('loginWithTenDayExpAndExpiry', function (userDetails, respond) { - if (allowedUsers[userDetails.username]) { - userDetails.exp = Math.round(Date.now() / 1000) + TEN_DAYS_IN_SECONDS; - socket.setAuthToken(userDetails, { - expiresIn: TEN_DAYS_IN_SECONDS * 100 // 1000 days - }); - respond(); - } else { - var err = new Error('Failed to login'); - err.name = 'FailedLoginError'; - respond(err); + })(); + + (async () => { + for await (let rpc of socket.procedure('loginWithTenDayExp')) { + if (allowedUsers[rpc.data.username]) { + rpc.data.exp = Math.round(Date.now() / 1000) + TEN_DAYS_IN_SECONDS; + socket.setAuthToken(rpc.data); + rpc.end(); + } else { + let err = new Error('Failed to login'); + err.name = 'FailedLoginError'; + rpc.error(err); + } } - }); - socket.on('loginWithIssAndIssuer', function (userDetails, respond) { - if (allowedUsers[userDetails.username]) { - userDetails.iss = 'foo'; - socket.setAuthToken(userDetails, { - issuer: 'bar' - }); - respond(); - } else { - var err = new Error('Failed to login'); - err.name = 'FailedLoginError'; - respond(err); + })(); + + (async () => { + for await (let rpc of socket.procedure('loginWithTenDayExpAndExpiry')) { + if (allowedUsers[rpc.data.username]) { + rpc.data.exp = Math.round(Date.now() / 1000) + TEN_DAYS_IN_SECONDS; + socket.setAuthToken(rpc.data, { + expiresIn: TEN_DAYS_IN_SECONDS * 100 // 1000 days + }); + rpc.end(); + } else { + let err = new Error('Failed to login'); + err.name = 'FailedLoginError'; + rpc.error(err); + } } - }); - socket.on('setAuthKey', function (newAuthKey, respond) { - server.signatureKey = newAuthKey; - server.verificationKey = newAuthKey; - respond(); - }); -}; + })(); + + (async () => { + for await (let rpc of socket.procedure('loginWithIssAndIssuer')) { + if (allowedUsers[rpc.data.username]) { + rpc.data.iss = 'foo'; + try { + await socket.setAuthToken(rpc.data, { + issuer: 'bar' + }); + } catch (err) {} + rpc.end(); + } else { + let err = new Error('Failed to login'); + err.name = 'FailedLoginError'; + rpc.error(err); + } + } + })(); -var destroyTestCase = function (next) { - if (client && client.state != client.CLOSED) { - client.once('disconnect', function () { - client.removeAllListeners('connectAbort'); - next(); - }); - client.once('connectAbort', function () { - client.removeAllListeners('disconnect'); - next(); - }); - client.disconnect(); - } else { - next(); - } + (async () => { + for await (let rpc of socket.procedure('setAuthKey')) { + server.signatureKey = rpc.data; + server.verificationKey = rpc.data; + rpc.end(); + } + })(); + + (async () => { + for await (let rpc of socket.procedure('proc')) { + rpc.end('success ' + rpc.data); + } + })(); }; -describe('Integration tests', function () { - before('Run the server before start', function (done) { - server = socketClusterServer.listen(PORT, serverOptions); - server.on('connection', connectionHandler); - - server.addMiddleware(server.MIDDLEWARE_AUTHENTICATE, function (req, next) { - if (req.authToken.username == 'alice') { - var err = new Error('Blocked by MIDDLEWARE_AUTHENTICATE'); - err.name = 'AuthenticateMiddlewareError'; - next(err); - } else { - next(); +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); + } + })(); + } +} - server.on('ready', function () { - done(); - }); +describe('Integration tests', function () { + beforeEach('Prepare options', async function () { + clientOptions = { + hostname: '127.0.0.1', + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }; + serverOptions = { + authKey: 'testkey', + wsEngine: WS_ENGINE + }; }); - after('Shut down server afterwards', function (done) { - server.close(); - done(); + 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'); }); - afterEach('Shut down client after each test', function (done) { - destroyTestCase(function () { - global.localStorage.removeItem('socketCluster.authToken'); - done(); + 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(); + } + }); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); }); - }); - describe('Authentication', function () { - it('Should not send back error if JWT is not provided in handshake', function (done) { - client = socketCluster.connect(clientOptions); - client.once('connect', function (status) { - assert.equal(status.authError === undefined, true); - done(); - }); + 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(); + assert.equal(event.authError === undefined, true); }); - it('Should be authenticated on connect if previous JWT token is present', function (done) { - client = socketCluster.connect(clientOptions); - client.once('connect', function (statusA) { - client.emit('login', {username: 'bob'}); - client.once('authenticate', function (state) { - assert.equal(client.authState, 'authenticated'); - - client.once('disconnect', function () { - client.once('connect', function (statusB) { - assert.equal(statusB.isAuthenticated, true); - assert.equal(statusB.authError === undefined, true); - done(); - }); - - client.connect(); - }); + it('Should be authenticated on connect if previous JWT token is present', async function () { + client = socketClusterClient.create(clientOptions); + await client.listener('connect').once(); + client.invoke('login', {username: 'bob'}); + await client.listener('authenticate').once(); + assert.equal(client.authState, 'authenticated'); + client.disconnect(); + client.connect(); + let event = await client.listener('connect').once(); + assert.equal(event.isAuthenticated, true); + assert.equal(event.authError === undefined, true); + }); - client.disconnect(); - }); - }); + it('Should send back error if JWT is invalid during handshake', async function () { + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); + + client = socketClusterClient.create(clientOptions); + + await client.listener('connect').once(); + // Change the setAuthKey to invalidate the current token. + await client.invoke('setAuthKey', 'differentAuthKey'); + client.disconnect(); + client.connect(); + let event = await client.listener('connect').once(); + assert.equal(event.isAuthenticated, false); + assert.notEqual(event.authError, null); + assert.equal(event.authError.name, 'AuthTokenInvalidError'); }); - it('Should send back error if JWT is invalid during handshake', function (done) { - global.localStorage.setItem('socketCluster.authToken', validSignedAuthTokenBob); + it('Should allow switching between users', async function () { + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); - client = socketCluster.connect(clientOptions); - client.once('connect', function (statusA) { - // Change the setAuthKey to invalidate the current token. - client.emit('setAuthKey', 'differentAuthKey', function (err) { - assert.equal(err == null, true); + let authenticateEvents = []; + let deauthenticateEvents = []; + let authenticationStateChangeEvents = []; + let authStateChangeEvents = []; - client.once('disconnect', function () { - client.once('connect', function (statusB) { - assert.equal(statusB.isAuthenticated, false); - assert.notEqual(statusB.authError, null); - assert.equal(statusB.authError.name, 'AuthTokenInvalidError'); - done(); - }); + (async () => { + for await (let stateChangePacket of server.listener('authenticationStateChange')) { + authenticationStateChangeEvents.push(stateChangePacket); + } + })(); - client.connect(); - }); + (async () => { + for await (let {socket} of server.listener('connection')) { + (async () => { + for await (let {authToken} of socket.listener('authenticate')) { + authenticateEvents.push(authToken); + } + })(); + (async () => { + for await (let {oldAuthToken} of socket.listener('deauthenticate')) { + deauthenticateEvents.push(oldAuthToken); + } + })(); + (async () => { + for await (let stateChangeData of socket.listener('authStateChange')) { + authStateChangeEvents.push(stateChangeData); + } + })(); + } + })(); + + let clientSocketId; + client = socketClusterClient.create(clientOptions); + await client.listener('connect').once(); + clientSocketId = client.id; + client.invoke('login', {username: 'alice'}); + + await wait(100); + + assert.equal(deauthenticateEvents.length, 0); + assert.equal(authenticateEvents.length, 2); + assert.equal(authenticateEvents[0].username, 'bob'); + assert.equal(authenticateEvents[1].username, 'alice'); + + assert.equal(authenticationStateChangeEvents.length, 1); + assert.notEqual(authenticationStateChangeEvents[0].socket, null); + assert.equal(authenticationStateChangeEvents[0].socket.id, clientSocketId); + assert.equal(authenticationStateChangeEvents[0].oldAuthState, 'unauthenticated'); + assert.equal(authenticationStateChangeEvents[0].newAuthState, 'authenticated'); + assert.notEqual(authenticationStateChangeEvents[0].authToken, null); + assert.equal(authenticationStateChangeEvents[0].authToken.username, 'bob'); + + assert.equal(authStateChangeEvents.length, 1); + assert.equal(authStateChangeEvents[0].oldAuthState, 'unauthenticated'); + assert.equal(authStateChangeEvents[0].newAuthState, 'authenticated'); + assert.notEqual(authStateChangeEvents[0].authToken, null); + assert.equal(authStateChangeEvents[0].authToken.username, 'bob'); + }); - client.disconnect(); - }); - }); + it('Should emit correct events/data when socket is deauthenticated', async function () { + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); + + let authenticationStateChangeEvents = []; + let authStateChangeEvents = []; + + (async () => { + for await (let stateChangePacket of server.listener('authenticationStateChange')) { + authenticationStateChangeEvents.push(stateChangePacket); + } + })(); + + client = socketClusterClient.create(clientOptions); + + (async () => { + for await (let event of client.listener('connect')) { + client.deauthenticate(); + } + })(); + + let {socket} = await server.listener('connection').once(); + let initialAuthToken = socket.authToken; + + (async () => { + for await (let stateChangeData of socket.listener('authStateChange')) { + authStateChangeEvents.push(stateChangeData); + } + })(); + + let {oldAuthToken} = await socket.listener('deauthenticate').once(); + assert.equal(oldAuthToken, initialAuthToken); + + assert.equal(authStateChangeEvents.length, 2); + assert.equal(authStateChangeEvents[0].oldAuthState, 'unauthenticated'); + assert.equal(authStateChangeEvents[0].newAuthState, 'authenticated'); + assert.notEqual(authStateChangeEvents[0].authToken, null); + assert.equal(authStateChangeEvents[0].authToken.username, 'bob'); + assert.equal(authStateChangeEvents[1].oldAuthState, 'authenticated'); + assert.equal(authStateChangeEvents[1].newAuthState, 'unauthenticated'); + assert.equal(authStateChangeEvents[1].authToken, null); + + assert.equal(authenticationStateChangeEvents.length, 2); + assert.notEqual(authenticationStateChangeEvents[0], null); + assert.equal(authenticationStateChangeEvents[0].oldAuthState, 'unauthenticated'); + assert.equal(authenticationStateChangeEvents[0].newAuthState, 'authenticated'); + assert.notEqual(authenticationStateChangeEvents[0].authToken, null); + assert.equal(authenticationStateChangeEvents[0].authToken.username, 'bob'); + assert.notEqual(authenticationStateChangeEvents[1], null); + assert.equal(authenticationStateChangeEvents[1].oldAuthState, 'authenticated'); + assert.equal(authenticationStateChangeEvents[1].newAuthState, 'unauthenticated'); + assert.equal(authenticationStateChangeEvents[1].authToken, null); }); - it('Should allow switching between users', function (done) { - client = socketCluster.connect(clientOptions); - client.once('connect', function (statusA) { - client.emit('login', {username: 'alice'}); + 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.once('authTokenChange', function (signedToken) { - assert.equal(client.authState, 'authenticated'); - assert.notEqual(client.authToken, null); - assert.equal(client.authToken.username, 'alice'); + client = socketClusterClient.create(clientOptions); - done(); - }); - }); + 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_AUTHENTICATE blocks the authentication', function (done) { - global.localStorage.setItem('socketCluster.authToken', validSignedAuthTokenAlice); + it('Should not authenticate the client if MIDDLEWARE_INBOUND blocks the authentication', async function () { + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenAlice); - client = socketCluster.connect(clientOptions); + 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. - client.once('connect', function (statusB) { - // Any token containing the username 'alice' should be blocked by the MIDDLEWARE_AUTHENTICATE middleware. - // This will only affects token-based authentication, not the credentials-based login event. - assert.equal(statusB.isAuthenticated, false); - assert.notEqual(statusB.authError, null); - assert.equal(statusB.authError.name, 'AuthenticateMiddlewareError'); - done(); - }); + let event = await client.listener('connect').once(); + // 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 inside login callback if token engine signing is synchronous', function (done) { - var port = 8009; - server = socketClusterServer.listen(port, { + describe('Server authentication', function () { + it('Token should be available after the authenticate listener resolves', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - authSignAsync: false - }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - client.once('connect', function (statusA) { - client.emit('login', {username: 'bob'}, function (err) { - assert.equal(client.authState, 'authenticated'); - assert.notEqual(client.authToken, null); - assert.equal(client.authToken.username, 'bob'); - done(); - }); - }); + wsEngine: WS_ENGINE }); - }); + bindFailureHandlers(server); - it('If token engine signing is asynchronous, authentication can be captured using the authenticate event', function (done) { - var port = 8010; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey, - authSignAsync: true - }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - client.once('connect', function (statusA) { - client.emit('login', {username: 'bob'}); - client.on('authenticate', function (newSignedToken) { - assert.equal(client.authState, 'authenticated'); - assert.notEqual(client.authToken, null); - assert.equal(client.authToken.username, 'bob'); - done(); - }); - }); - }); - }); + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); - it('Should still work if token verification is asynchronous', function (done) { - var port = 8011; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey, - authVerifyAsync: false - }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - client.once('connect', function (statusA) { - client.emit('login', {username: 'bob'}); - client.once('authenticate', function (newSignedToken) { - client.once('disconnect', function () { - client.once('connect', function (statusB) { - assert.equal(statusB.isAuthenticated, true); - assert.notEqual(client.authToken, null); - assert.equal(client.authToken.username, 'bob'); - done(); - }); - client.connect(); - }); - client.disconnect(); - }); - }); - }); - }); + await server.listener('ready').once(); - it('Should set the correct expiry when using expiresIn option when creating a JWT with socket.setAuthToken', function (done) { - var port = 8012; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey, - authVerifyAsync: false - }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - client.once('connect', function (statusA) { - client.once('authenticate', function (newSignedToken) { - assert.notEqual(client.authToken, null); - assert.notEqual(client.authToken.exp, null); - var dateMillisecondsInTenDays = Date.now() + TEN_DAYS_IN_SECONDS * 1000; - var dateDifference = Math.abs(dateMillisecondsInTenDays - client.authToken.exp * 1000); - // Expiry must be accurate within 1000 milliseconds. - assert.equal(dateDifference < 1000, true); - done(); - }); - client.emit('loginWithTenDayExpiry', {username: 'bob'}); - }); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - }); - it('Should set the correct expiry when adding exp claim when creating a JWT with socket.setAuthToken', function (done) { - var port = 8013; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey, - authVerifyAsync: false - }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - client.once('connect', function (statusA) { - client.once('authenticate', function (newSignedToken) { - assert.notEqual(client.authToken, null); - assert.notEqual(client.authToken.exp, null); - var dateMillisecondsInTenDays = Date.now() + TEN_DAYS_IN_SECONDS * 1000; - var dateDifference = Math.abs(dateMillisecondsInTenDays - client.authToken.exp * 1000); - // Expiry must be accurate within 1000 milliseconds. - assert.equal(dateDifference < 1000, true); - done(); - }); - client.emit('loginWithTenDayExp', {username: 'bob'}); - }); - }); + await client.listener('connect').once(); + + client.invoke('login', {username: 'bob'}); + await client.listener('authenticate').once(); + + assert.equal(client.authState, 'authenticated'); + assert.notEqual(client.authToken, null); + assert.equal(client.authToken.username, 'bob'); }); - it('The exp claim should have priority over expiresIn option when using socket.setAuthToken', function (done) { - var port = 8014; - server = socketClusterServer.listen(port, { + it('Authentication can be captured using the authenticate listener', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - authVerifyAsync: false + wsEngine: WS_ENGINE, + authTokenName: 'socketcluster.authToken' }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - client.once('connect', function (statusA) { - client.once('authenticate', function (newSignedToken) { - assert.notEqual(client.authToken, null); - assert.notEqual(client.authToken.exp, null); - var dateMillisecondsInTenDays = Date.now() + TEN_DAYS_IN_SECONDS * 1000; - var dateDifference = Math.abs(dateMillisecondsInTenDays - client.authToken.exp * 1000); - // Expiry must be accurate within 1000 milliseconds. - assert.equal(dateDifference < 1000, true); - done(); - }); - client.emit('loginWithTenDayExpAndExpiry', {username: 'bob'}); - }); + 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, + authTokenName: 'socketcluster.authToken' }); + + await client.listener('connect').once(); + + client.invoke('login', {username: 'bob'}); + await client.listener('authenticate').once(); + + assert.equal(client.authState, 'authenticated'); + assert.notEqual(client.authToken, null); + assert.equal(client.authToken.username, 'bob'); }); - it('Should send back error if socket.setAuthToken tries to set both iss claim and issuer option', function (done) { - var port = 8015; - server = socketClusterServer.listen(port, { + it('Previously authenticated client should still be authenticated after reconnecting', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { authKey: serverOptions.authKey, - authVerifyAsync: false + wsEngine: WS_ENGINE }); - var warningMap = {}; + bindFailureHandlers(server); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - client.once('connect', function (statusA) { - client.once('authenticate', function (newSignedToken) { - throw new Error('Should not pass authentication because the signature should fail'); - }); - server.on('warning', function (warning) { - assert.notEqual(warning, null); - warningMap[warning.name] = warning; - }); - client.once('error', function (err) { - assert.notEqual(err, null); - assert.equal(err.name, 'SocketProtocolError'); - }); - client.emit('loginWithIssAndIssuer', {username: 'bob'}); - setTimeout(function () { - server.removeAllListeners('warning'); - assert.notEqual(warningMap['SocketProtocolError'], null); - done(); - }, 1000); - }); + (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' }); + + await client.listener('connect').once(); + + client.invoke('login', {username: 'bob'}); + + await client.listener('authenticate').once(); + + client.disconnect(); + client.connect(); + + let event = await client.listener('connect').once(); + + assert.equal(event.isAuthenticated, true); + assert.notEqual(client.authToken, null); + assert.equal(client.authToken.username, 'bob'); }); - }); - describe('Event flow', function () { - it('Should support subscription batching', function (done) { - var port = 8016; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey - }); - server.on('connection', function (socket) { - connectionHandler(socket); - var isFirstMessage = true; - socket.on('message', function (rawMessage) { - if (isFirstMessage) { - var data = JSON.parse(rawMessage); - // All 20 subscriptions should arrive as a single message. - assert.equal(data.length, 20); - isFirstMessage = false; - } - }); + it('Should set the correct expiry when using expiresIn option when creating a JWT with socket.setAuthToken', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE }); + bindFailureHandlers(server); - var subscribeMiddlewareCounter = 0; - // 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 - var err = new Error('You cannot subscribe to channel 12'); - err.name = 'UnauthorizedSubscribeError'; - next(err); - return; + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); } - next(); - }); + })(); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - var channelList = []; - for (var i = 0; i < 20; i++) { - var subscribeOptions = { - batch: true - }; - if (i == 10) { - subscribeOptions.data = {foo: 123}; - } - channelList.push( - client.subscribe('my-channel-' + i, subscribeOptions) - ); - } - channelList[12].on('subscribe', function (err) { - throw new Error('The my-channel-12 channel should have been blocked by MIDDLEWARE_SUBSCRIBE'); - }); - channelList[12].on('subscribeFail', function (err) { - assert.notEqual(err, null); - assert.equal(err.name, 'UnauthorizedSubscribeError'); - }); - channelList[19].watch(function (data) { - assert.equal(data, 'Hello!'); - assert.equal(subscribeMiddlewareCounter, 20); - done(); - }); - channelList[0].on('subscribe', function () { - client.publish('my-channel-19', 'Hello!'); - }); + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); + + await client.listener('connect').once(); + client.invoke('loginWithTenDayExpiry', {username: 'bob'}); + await client.listener('authenticate').once(); + + assert.notEqual(client.authToken, null); + assert.notEqual(client.authToken.exp, null); + let dateMillisecondsInTenDays = Date.now() + TEN_DAYS_IN_SECONDS * 1000; + let dateDifference = Math.abs(dateMillisecondsInTenDays - client.authToken.exp * 1000); + // Expiry must be accurate within 1000 milliseconds. + assert.equal(dateDifference < 1000, true); }); - it('should remove client data from the server when client disconnects before authentication process finished', function (done) { - var port = 8017; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey + it('Should set the correct expiry when adding exp claim when creating a JWT with socket.setAuthToken', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE }); - server.setAuthEngine({ - verifyToken: function (signedAuthToken, verificationKey, defaultVerificationOptions, callback) { - setTimeout(function () { - callback(null, {}) - }, 500) + 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, + authTokenName: 'socketcluster.authToken' }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - var serverSocket; - server.on('handshake', function (socket) { - serverSocket = socket; - }); - setTimeout(function () { - assert.equal(server.clientsCount, 0); - assert.equal(server.pendingClientsCount, 1); - assert.notEqual(serverSocket, null); - assert.equal(Object.keys(server.pendingClients)[0], serverSocket.id); - client.disconnect(); - }, 100); - setTimeout(function () { - assert.equal(Object.keys(server.clients).length, 0); - assert.equal(server.clientsCount, 0); - assert.equal(server.pendingClientsCount, 0); - assert.equal(JSON.stringify(server.pendingClients), '{}'); - done(); - }, 1000); - }); + + await client.listener('connect').once(); + client.invoke('loginWithTenDayExp', {username: 'bob'}); + await client.listener('authenticate').once(); + + assert.notEqual(client.authToken, null); + assert.notEqual(client.authToken.exp, null); + let dateMillisecondsInTenDays = Date.now() + TEN_DAYS_IN_SECONDS * 1000; + let dateDifference = Math.abs(dateMillisecondsInTenDays - client.authToken.exp * 1000); + // Expiry must be accurate within 1000 milliseconds. + assert.equal(dateDifference < 1000, true); }); - it('Client should not be able to subscribe to a channel before the handshake has completed', function (done) { - var port = 8018; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey - }); - server.setAuthEngine({ - verifyToken: function (signedAuthToken, verificationKey, defaultVerificationOptions, callback) { - setTimeout(function () { - callback(null, {}) - }, 500) - } + it('The exp claim should have priority over expiresIn option when using socket.setAuthToken', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); + bindFailureHandlers(server); - var isSubscribed = false; - var error; + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); - server.on('subscription', function (socket, channel) { - isSubscribed = true; - }); + await server.listener('ready').once(); - // 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; - } - }; + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - // 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}'); - }); + await client.listener('connect').once(); + client.invoke('loginWithTenDayExpAndExpiry', {username: 'bob'}); + await client.listener('authenticate').once(); - setTimeout(function () { - assert.equal(isSubscribed, false); - assert.notEqual(error, null); - assert.equal(error.name, 'InvalidActionError'); - done(); - }, 1000); - }); + assert.notEqual(client.authToken, null); + assert.notEqual(client.authToken.exp, null); + let dateMillisecondsInTenDays = Date.now() + TEN_DAYS_IN_SECONDS * 1000; + let dateDifference = Math.abs(dateMillisecondsInTenDays - client.authToken.exp * 1000); + // Expiry must be accurate within 1000 milliseconds. + assert.equal(dateDifference < 1000, true); }); - it('Server-side socket disconnect event should not trigger if the socket did not complete the handshake; instead, it should trigger connectAbort', function (done) { - var port = 8019; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey + it('Should send back error if socket.setAuthToken tries to set both iss claim and issuer option', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE }); - server.setAuthEngine({ - verifyToken: function (signedAuthToken, verificationKey, defaultVerificationOptions, callback) { - setTimeout(function () { - callback(null, {}) - }, 500) + bindFailureHandlers(server); + + let warningMap = {}; + + (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' }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - var socketDisconnected = false; - var socketDisconnectedBeforeConnect = false; - var clientSocketAborted = false; + await client.listener('connect').once(); - var connectionOnServer = false; + (async () => { + await client.listener('authenticate').once(); + throw new Error('Should not pass authentication because the signature should fail'); + })(); - server.once('connection', function () { - connectionOnServer = true; - }); + (async () => { + for await (let {warning} of server.listener('warning')) { + assert.notEqual(warning, null); + warningMap[warning.name] = warning; + } + })(); - server.once('handshake', function (socket) { - assert.equal(server.pendingClientsCount, 1); - assert.notEqual(server.pendingClients[socket.id], null); - socket.once('disconnect', function () { - if (!connectionOnServer) { - socketDisconnectedBeforeConnect = true; - } - socketDisconnected = true; - }); - socket.once('connectAbort', function () { - clientSocketAborted = true; - }); - }); + (async () => { + for await (let {error} of server.listener('error')) { + assert.notEqual(error, null); + assert.equal(error.name, 'SocketProtocolError'); + } + })(); - var serverDisconnected = false; - var serverSocketAborted = false; + let closePackets = []; - server.once('disconnection', function () { - serverDisconnected = true; - }); + (async () => { + let event = await client.listener('close').once(); + closePackets.push(event); + })(); - server.once('connectionAbort', function () { - serverSocketAborted = true; - }); + let error; + try { + await client.invoke('loginWithIssAndIssuer', {username: 'bob'}); + } catch (err) { + error = err; + } - setTimeout(function () { - client.disconnect(); - }, 100); - setTimeout(function () { - assert.equal(socketDisconnected, false); - assert.equal(socketDisconnectedBeforeConnect, false); - assert.equal(clientSocketAborted, true); - assert.equal(serverSocketAborted, true); - assert.equal(serverDisconnected, false); - done(); - }, 1000); - }); + assert.notEqual(error, null); + assert.equal(error.name, 'BadConnectionError'); + + await wait(1000); + + assert.equal(closePackets.length, 1); + assert.equal(closePackets[0].code, 4002); + server.closeListener('warning'); + assert.notEqual(warningMap['SocketProtocolError'], null); }); - it('Server-side socket disconnect event should trigger if the socket completed the handshake (not connectAbort)', function (done) { - var port = 8020; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey + it('Should trigger an authTokenSigned event and socket.signedAuthToken should be set after calling the socket.setAuthToken method', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE }); - server.setAuthEngine({ - verifyToken: function (signedAuthToken, verificationKey, defaultVerificationOptions, callback) { - setTimeout(function () { - callback(null, {}) - }, 10) + bindFailureHandlers(server); + + let authTokenSignedEventEmitted = false; + + (async () => { + for await (let {socket} of server.listener('connection')) { + (async () => { + for await (let {signedAuthToken} of socket.listener('authTokenSigned')) { + authTokenSignedEventEmitted = true; + assert.notEqual(signedAuthToken, null); + assert.equal(signedAuthToken, socket.signedAuthToken); + } + })(); + + (async () => { + for await (let req of socket.procedure('login')) { + if (allowedUsers[req.data.username]) { + socket.setAuthToken(req.data); + req.end(); + } else { + let err = new Error('Failed to login'); + err.name = 'FailedLoginError'; + req.error(err); + } + } + })(); } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); - var socketDisconnected = false; - var socketDisconnectedBeforeConnect = false; - var clientSocketAborted = false; + await client.listener('connect').once(); - var connectionOnServer = false; + await Promise.all([ + client.invoke('login', {username: 'bob'}), + client.listener('authenticate').once() + ]); - server.once('connection', function () { - connectionOnServer = true; - }); + assert.equal(authTokenSignedEventEmitted, true); + }); - server.once('handshake', function (socket) { - assert.equal(server.pendingClientsCount, 1); - assert.notEqual(server.pendingClients[socket.id], null); - socket.once('disconnect', function () { - if (!connectionOnServer) { - socketDisconnectedBeforeConnect = true; - } - socketDisconnected = true; - }); - socket.once('connectAbort', function () { - clientSocketAborted = true; - }); - }); + 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, + ackTimeout: 1000 + }); + bindFailureHandlers(server); - var serverDisconnected = false; - var serverSocketAborted = false; + let serverWarnings = []; - server.once('disconnection', function () { - serverDisconnected = true; + (async () => { + await server.listener('ready').once(); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); + await client.listener('connect').once(); + client.invoke('login', {username: 'bob'}); + })(); - server.once('connectionAbort', function () { - serverSocketAborted = true; - }); + let {socket} = await server.listener('connection').once(); - setTimeout(function () { - client.disconnect(); - }, 200); - setTimeout(function () { - assert.equal(socketDisconnectedBeforeConnect, false); - assert.equal(socketDisconnected, true); - assert.equal(clientSocketAborted, false); - assert.equal(serverDisconnected, true); - assert.equal(serverSocketAborted, false); - done(); - }, 1000); - }); + (async () => { + for await (let {warning} of server.listener('warning')) { + serverWarnings.push(warning); + } + })(); + + let req = await socket.procedure('login').once(); + if (allowedUsers[req.data.username]) { + req.end(); + socket.disconnect(); + let error; + try { + await socket.setAuthToken(req.data, {rejectOnFailedDelivery: true}); + } catch (err) { + error = err; + } + assert.notEqual(error, null); + assert.equal(error.name, 'AuthError'); + await wait(0); + 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'; + req.error(err); + } }); - it('Server-side socket connect event and server connection event should trigger', function (done) { - var port = 8021; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey + 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, + ackTimeout: 1000 }); + bindFailureHandlers(server); - var connectionEmitted = false; - var connectionStatus; + let serverWarnings = []; - server.on('connection', connectionHandler); - server.once('connection', function (socket, status) { - connectionEmitted = true; - connectionStatus = status; - // Modify the status object and make sure that it doesn't get modified - // on the client. - status.foo = 123; - }); - server.on('ready', function () { - client = socketCluster.connect({ + (async () => { + await server.listener('ready').once(); + client = socketClusterClient.create({ hostname: clientOptions.hostname, - port: port, - multiplex: false + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' }); + await client.listener('connect').once(); + client.invoke('login', {username: 'bob'}); + })(); - var connectEmitted = false; - var _connectEmitted = false; - var connectStatus; - var socketId; + let {socket} = await server.listener('connection').once(); - server.once('handshake', function (socket) { - socket.once('connect', function (status) { - socketId = socket.id; - connectEmitted = true; - connectStatus = status; + (async () => { + for await (let {warning} of server.listener('warning')) { + serverWarnings.push(warning); + } + })(); + + let req = await socket.procedure('login').once(); + if (allowedUsers[req.data.username]) { + req.end(); + socket.disconnect(); + let error; + try { + await socket.setAuthToken(req.data); + } catch (err) { + error = err; + } + assert.equal(error, null); + await wait(0); + assert.notEqual(serverWarnings[0], null); + assert.equal(serverWarnings[0].name, 'BadConnectionError'); + } else { + let err = new Error('Failed to login'); + err.name = 'FailedLoginError'; + req.error(err); + } + }); + + it('The verifyToken method of the authEngine receives correct params', async function () { + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); + + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionHandler(socket); + } + })(); + + (async () => { + await server.listener('ready').once(); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + })(); + + return new Promise((resolve) => { + server.setAuthEngine({ + verifyToken: async (signedAuthToken, verificationKey, verificationOptions) => { + await wait(500); + assert.equal(signedAuthToken, validSignedAuthTokenBob); + assert.equal(verificationKey, serverOptions.authKey); + assert.notEqual(verificationOptions, null); + assert.notEqual(verificationOptions.socket, null); + resolve(); + return Promise.resolve({}); + } + }); + }); + }); + + it('Should remove client data from the server when client disconnects before authentication process finished', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + server.setAuthEngine({ + verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { + return resolveAfterTimeout(500, {}); + } + }); + + (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 serverSocket; + (async () => { + for await (let {socket} of server.listener('handshake')) { + serverSocket = socket; + } + })(); + + await wait(100); + assert.equal(server.clientsCount, 0); + assert.equal(server.pendingClientsCount, 1); + assert.notEqual(serverSocket, null); + assert.equal(Object.keys(server.pendingClients)[0], serverSocket.id); + client.disconnect(); + + await wait(1000); + assert.equal(Object.keys(server.clients).length, 0); + assert.equal(server.clientsCount, 0); + 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 () { + 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(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + 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 () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + let connectionEmitted = false; + let connectionEvent; + + (async () => { + for await (let event of server.listener('connection')) { + connectionEvent = event; + connectionHandler(event.socket); + connectionEmitted = true; + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let connectEmitted = false; + let connectStatus; + let socketId; + + (async () => { + for await (let {socket} of server.listener('handshake')) { + (async () => { + for await (let serverSocketStatus of socket.listener('connect')) { + socketId = socket.id; + connectEmitted = true; + connectStatus = serverSocketStatus; + // This is to check that mutating the status on the server + // doesn't affect the status sent to the client. + serverSocketStatus.foo = 123; + } + })(); + } + })(); + + let clientConnectEmitted = false; + let clientConnectStatus = false; + + (async () => { + for await (let event of client.listener('connect')) { + clientConnectEmitted = true; + clientConnectStatus = event; + } + })(); + + await wait(300); + + assert.equal(connectEmitted, true); + assert.equal(connectionEmitted, true); + assert.equal(clientConnectEmitted, true); + + assert.notEqual(connectionEvent, null); + assert.equal(connectionEvent.id, socketId); + assert.equal(connectionEvent.pingTimeout, server.pingTimeout); + assert.equal(connectionEvent.authError, null); + assert.equal(connectionEvent.isAuthenticated, false); + + assert.notEqual(connectStatus, null); + assert.equal(connectStatus.id, socketId); + assert.equal(connectStatus.pingTimeout, server.pingTimeout); + assert.equal(connectStatus.authError, null); + assert.equal(connectStatus.isAuthenticated, false); + + assert.notEqual(clientConnectStatus, null); + assert.equal(clientConnectStatus.id, socketId); + assert.equal(clientConnectStatus.pingTimeout, server.pingTimeout); + assert.equal(clientConnectStatus.authError, null); + assert.equal(clientConnectStatus.isAuthenticated, false); + assert.equal(clientConnectStatus.foo, null); + // Client socket status should be a clone of server socket status; not + // 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 () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + server.setAuthEngine({ + verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { + return resolveAfterTimeout(500, {}); + } + }); + + let connectionOnServer = false; + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionOnServer = true; + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let socketDisconnected = false; + let socketDisconnectedBeforeConnect = false; + let clientSocketAborted = false; + + (async () => { + let {socket} = await server.listener('handshake').once(); + assert.equal(server.pendingClientsCount, 1); + assert.notEqual(server.pendingClients[socket.id], null); + + (async () => { + await socket.listener('disconnect').once(); + if (!connectionOnServer) { + socketDisconnectedBeforeConnect = true; + } + socketDisconnected = true; + })(); + + (async () => { + let event = await socket.listener('connectAbort').once(); + clientSocketAborted = true; + assert.equal(event.code, 4444); + assert.equal(event.reason, 'Disconnect before handshake'); + })(); + })(); + + let serverDisconnected = false; + let serverSocketAborted = false; + + (async () => { + await server.listener('disconnection').once(); + serverDisconnected = true; + })(); + + (async () => { + await server.listener('connectionAbort').once(); + serverSocketAborted = true; + })(); + + await wait(100); + client.disconnect(4444, 'Disconnect before handshake'); + + await wait(1000); + assert.equal(socketDisconnected, false); + assert.equal(socketDisconnectedBeforeConnect, false); + assert.equal(clientSocketAborted, true); + assert.equal(serverSocketAborted, true); + assert.equal(serverDisconnected, false); + }); + + it('Server-side socket disconnect event should trigger if the socket completed the handshake (not connectAbort)', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + server.setAuthEngine({ + verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { + return resolveAfterTimeout(10, {}); + } + }); + + let connectionOnServer = false; + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionOnServer = true; + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let socketDisconnected = false; + let socketDisconnectedBeforeConnect = false; + let clientSocketAborted = false; + + (async () => { + let {socket} = await server.listener('handshake').once(); + assert.equal(server.pendingClientsCount, 1); + assert.notEqual(server.pendingClients[socket.id], null); + + (async () => { + let event = await socket.listener('disconnect').once(); + if (!connectionOnServer) { + socketDisconnectedBeforeConnect = true; + } + socketDisconnected = true; + assert.equal(event.code, 4445); + assert.equal(event.reason, 'Disconnect after handshake'); + })(); + + (async () => { + let event = await socket.listener('connectAbort').once(); + clientSocketAborted = true; + })(); + })(); + + let serverDisconnected = false; + let serverSocketAborted = false; + + (async () => { + await server.listener('disconnection').once(); + serverDisconnected = true; + })(); + + (async () => { + await server.listener('connectionAbort').once(); + serverSocketAborted = true; + })(); + + await wait(200); + client.disconnect(4445, 'Disconnect after handshake'); + + await wait(1000); + + assert.equal(socketDisconnectedBeforeConnect, false); + assert.equal(socketDisconnected, true); + assert.equal(clientSocketAborted, false); + assert.equal(serverDisconnected, true); + assert.equal(serverSocketAborted, false); + }); + + it('The close event should trigger when the socket loses the connection before the handshake', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + server.setAuthEngine({ + verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { + return resolveAfterTimeout(500, {}); + } + }); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionOnServer = true; + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let serverSocketClosed = false; + let serverSocketAborted = false; + let serverClosure = false; + + (async () => { + for await (let {socket} of server.listener('handshake')) { + let event = await socket.listener('close').once(); + serverSocketClosed = true; + assert.equal(event.code, 4444); + assert.equal(event.reason, 'Disconnect before handshake'); + } + })(); + + (async () => { + for await (let event of server.listener('connectionAbort')) { + serverSocketAborted = true; + } + })(); + + (async () => { + for await (let event of server.listener('closure')) { + assert.equal(event.socket.state, event.socket.CLOSED); + serverClosure = true; + } + })(); + + await wait(100); + client.disconnect(4444, 'Disconnect before handshake'); + + await wait(1000); + assert.equal(serverSocketClosed, true); + assert.equal(serverSocketAborted, true); + assert.equal(serverClosure, true); + }); + + it('The close event should trigger when the socket loses the connection after the handshake', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE + }); + bindFailureHandlers(server); + + server.setAuthEngine({ + verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { + return resolveAfterTimeout(0, {}); + } + }); + + (async () => { + for await (let {socket} of server.listener('connection')) { + connectionOnServer = true; + connectionHandler(socket); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let serverSocketClosed = false; + let serverDisconnection = false; + let serverClosure = false; + + (async () => { + for await (let {socket} of server.listener('handshake')) { + let event = await socket.listener('close').once(); + serverSocketClosed = true; + assert.equal(event.code, 4445); + assert.equal(event.reason, 'Disconnect after handshake'); + } + })(); + + (async () => { + for await (let event of server.listener('disconnection')) { + serverDisconnection = true; + } + })(); + + (async () => { + for await (let event of server.listener('closure')) { + assert.equal(event.socket.state, event.socket.CLOSED); + serverClosure = true; + } + })(); + + await wait(100); + client.disconnect(4445, 'Disconnect after handshake'); + + await wait(1000); + assert.equal(serverSocketClosed, true); + assert.equal(serverDisconnection, true); + assert.equal(serverClosure, true); + }); + + 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); + + (async () => { + 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(); + } + } + })(); + } + })(); + + for (let i = 0; i < 30; i++) { + (async () => { + let result; + try { + result = await client.invoke('foo', i); + } catch (error) { + return; + } + })(); + } + + await wait(200); + + // 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); + }); + + 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 handledPackets = []; + let closedReceiver = false; + + (async () => { + 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; + })(); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + 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('Socket streams should be closed eventually if socket disconnects (close mode)', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE, + socketStreamCleanupMode: 'close' + }); + bindFailureHandlers(server); + + let handledPackets = []; + let closedReceiver = false; + + (async () => { + 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; + })(); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + 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, 15); + assert.equal(closedReceiver, true); + }); + + 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, + socketStreamCleanupMode: 'none' + }); + bindFailureHandlers(server); + + let handledPackets = []; + let closedReceiver = false; + + (async () => { + for await (let {socket} of server.listener('connection')) { + (async () => { + for await (let packet of socket.receiver('foo')) { + await wait(30); + handledPackets.push(packet); + } + closedReceiver = false; + })(); + } + })(); + + await server.listener('ready').once(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + 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, 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 {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'); + } + } + })(); + } + })(); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + 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'); + }); + }); + + 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 + }); + bindFailureHandlers(server); + + (async () => { + await wait(10); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + client.transmit('customRemoteEvent', 'This is data'); + })(); + + for await (let {socket} of server.listener('connection')) { + for await (let data of socket.receiver('customRemoteEvent')) { + assert.equal(data, 'This is data'); + break; + } + break; + } + }); + }); + + 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: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + await client.listener('connect').once(); + for (let i = 0; i < 20; i++) { + await wait(10); + client.transmitPublish('foo', i); + } + + 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); + }); + + 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); + + let backpressureHistory = []; + + (async () => { + for await (let {socket} of server.listener('connection')) { + (async () => { + await socket.listener('subscribe').once(); + + for (let i = 0; i < 20; i++) { + await wait(10); + server.exchange.transmitPublish('foo', i); + backpressureHistory.push(socket.getOutboundBackpressure()); + } + })(); + } + })(); + + server.setMiddleware(server.MIDDLEWARE_OUTBOUND, 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: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + await client.subscribe('foo').listener('subscribe').once(); + + 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 + }); + bindFailureHandlers(server); + + let backpressureHistory = []; + + server.setMiddleware(server.MIDDLEWARE_INBOUND_RAW, async (middlewareStream) => { + for await (let action of middlewareStream) { + backpressureHistory.push(action.socket.getBackpressure()); + 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: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + await client.listener('connect').once(); + for (let i = 0; i < 20; i++) { + await wait(10); + client.transmitPublish('foo', i); + } + + 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); + }); + }); + + 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 + }); + 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, + authTokenName: 'socketcluster.authToken' + }); + + 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(receivedMessages.length, 1); + }); + + 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 + }); + 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, + authTokenName: 'socketcluster.authToken' + }); + + assert.equal(client.state, client.CLOSED); + + let receivedMessages = []; + + (async () => { + for await (let data of client.subscribe('foo')) { + receivedMessages.push(data); + } + })(); + + client.invokePublish('foo', 123); + + await wait(100); + assert.equal(client.state, client.OPEN); + assert.equal(receivedMessages.length, 1); + }); + + 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); + + server.setAuthEngine({ + verifyToken: function (signedAuthToken, verificationKey, verificationOptions) { + return resolveAfterTimeout(500, {}); + } + }); + + (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 isSubscribed = false; + let error; + + (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}'); + }); + + await wait(1000); + assert.equal(isSubscribed, false); + assert.notEqual(error, null); + assert.equal(error.name, 'BadConnectionError'); + }); + + 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 + }); + 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, + authTokenName: 'socketcluster.authToken' + }); + + let nullInChannelArrayError; + let objectAsChannelNameError; + let nullChannelNameError; + let nullUnsubscribeError; + + 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); + }); + + 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 () => { + 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: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + (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 }); - socket.once('_connect', function () { - _connectEmitted = true; + } + })(); + + (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('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); - var clientConnectEmitted = false; - var clientConnectStatus = false; + await server.listener('ready').once(); + }); - client.once('connect', function (status) { - clientConnectEmitted = true; - clientConnectStatus = status; + 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' }); - setTimeout(function () { - assert.equal(connectEmitted, true); - assert.equal(_connectEmitted, true); - assert.equal(connectionEmitted, true); - assert.equal(clientConnectEmitted, true); - - assert.notEqual(connectionStatus, null); - assert.equal(connectionStatus.id, socketId); - assert.equal(connectionStatus.pingTimeout, server.pingTimeout); - assert.equal(connectionStatus.authError, null); - assert.equal(connectionStatus.isAuthenticated, false); - - assert.notEqual(connectStatus, null); - assert.equal(connectStatus.id, socketId); - assert.equal(connectStatus.pingTimeout, server.pingTimeout); - assert.equal(connectStatus.authError, null); - assert.equal(connectStatus.isAuthenticated, false); - - assert.notEqual(clientConnectStatus, null); - assert.equal(clientConnectStatus.id, socketId); - assert.equal(clientConnectStatus.pingTimeout, server.pingTimeout); - assert.equal(clientConnectStatus.authError, null); - assert.equal(clientConnectStatus.isAuthenticated, false); - assert.equal(clientConnectStatus.foo, null); - // Client socket status should be a clone of server socket status; not - // a reference to the same object. - assert.notEqual(clientConnectStatus.foo, connectStatus.foo); - - done(); - }, 300); + 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); }); }); - it('The close event should trigger when the socket loses the connection before the handshake', function (done) { - var port = 8022; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey - }); - server.setAuthEngine({ - verifyToken: function (signedAuthToken, verificationKey, defaultVerificationOptions, callback) { - setTimeout(function () { - callback(null, {}) - }, 500) - } + 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(); }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ + + 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, - multiplex: false + port: PORT_NUMBER, + pingTimeoutDisabled: true, + authTokenName: 'socketcluster.authToken' }); - var serverSocketClosed = false; - var serverSocketAborted = false; - var serverClosure = false; + let serverWarning = null; + (async () => { + for await (let {warning} of server.listener('warning')) { + serverWarning = warning; + } + })(); - server.on('handshake', function (socket) { - socket.once('close', function () { - serverSocketClosed = true; - }); - }); + let serverDisconnectionCode = null; + (async () => { + for await (let event of server.listener('disconnection')) { + serverDisconnectionCode = event.code; + } + })(); - server.once('connectionAbort', function () { - serverSocketAborted = true; + 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); - server.on('closure', function (socket) { - assert.equal(socket.state, socket.CLOSED); - serverClosure = true; + 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' }); - setTimeout(function () { - client.disconnect(); - }, 100); - setTimeout(function () { - assert.equal(serverSocketClosed, true); - assert.equal(serverSocketAborted, true); - assert.equal(serverClosure, true); - done(); - }, 1000); + 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); }); }); + }); - it('The close event should trigger when the socket loses the connection after the handshake', function (done) { - var port = 8023; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey + describe('Middleware', function () { + beforeEach('Launch server without middleware before start', async function () { + server = socketClusterServer.listen(PORT_NUMBER, { + authKey: serverOptions.authKey, + wsEngine: WS_ENGINE }); - server.setAuthEngine({ - verifyToken: function (signedAuthToken, verificationKey, defaultVerificationOptions, callback) { - setTimeout(function () { - callback(null, {}) - }, 0) + 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(); + }); }); - server.on('connection', connectionHandler); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false + + 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); }); - var serverSocketClosed = false; - var serverSocketDisconnected = false; - var serverClosure = false; + 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); - server.on('handshake', function (socket) { - socket.once('close', function () { - serverSocketClosed = true; + 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'); }); - server.once('disconnection', function () { - serverSocketDisconnected = true; + 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'); }); - server.on('closure', function (socket) { - assert.equal(socket.state, socket.CLOSED); - serverClosure = true; + 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'); }); - setTimeout(function () { - client.disconnect(); - }, 100); - setTimeout(function () { - assert.equal(serverSocketClosed, true); - assert.equal(serverSocketDisconnected, true); - assert.equal(serverClosure, true); - done(); - }, 300); + 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; + })(); + + (async () => { + await clientB.listener('connect').once(); + clientBIsConnected = true; + })(); + + await wait(100); + + assert.equal(clientAIsConnected, true); + assert.equal(clientBIsConnected, false); + + clientA.disconnect(); + clientB.disconnect(); + }); }); }); - it('Exchange is attached to socket before the handshake event is triggered', function (done) { - var port = 8024; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey - }); + 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); - server.on('connection', connectionHandler); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); + let result = await client.invoke('proc', 123); - server.once('handshake', function (socket) { - assert.notEqual(socket.exchange, null); + assert.equal(middlewareWasExecuted, true); + assert.notEqual(middlewareAction, null); + assert.equal(result, 'success 123'); }); - setTimeout(function () { - done(); - }, 300); + it('Should send back custom Error if INVOKE action in middleware blocks the client 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; + + let customError = new Error('Invoke action was blocked'); + customError.name = 'BlockedInvokeError'; + action.block(customError); + continue; + } + action.allow(); + } + }; + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + let result; + let error; + try { + result = await client.invoke('proc', 123); + } catch (err) { + error = err; + } + + assert.equal(result, null); + assert.notEqual(error, null); + assert.equal(error.name, 'BlockedInvokeError'); + }); }); - }); - it('Server should be able to handle invalid #subscribe and #unsubscribe and #publish packets without crashing', function (done) { - var port = 8025; - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey + 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); + }); + + it('Should run AUTHENTICATE action in middleware if JWT token exists', async function () { + global.localStorage.setItem('socketcluster.authToken', validSignedAuthTokenBob); + 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' + }); + + (async () => { + try { + await client.invoke('login', {username: 'bob'}); + } catch (err) {} + })(); + + await client.listener('authenticate').once(); + assert.equal(middlewareWasExecuted, true); + }); }); - server.on('connection', connectionHandler); + 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 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); + + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + await client.invokePublish('hello', 'world'); - server.on('ready', function () { - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false + assert.equal(middlewareWasExecuted, true); + assert.notEqual(middlewareAction, null); + assert.equal(middlewareAction.channel, 'hello'); + assert.equal(middlewareAction.data, 'world'); }); - var nullInChannelArrayError; - var objectAsChannelNameError; - var nullChannelNameError; - var nullUnsubscribeError; - - var undefinedPublishError; - var objectAsChannelNamePublishError; - var 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; + it('Should be able to delay and block publish using PUBLISH_IN middleware', async function () { + 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); + + 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); + } + })(); + + let error; + try { + await client.invokePublish('hello', 'world'); + } catch (err) { + error = err; } - }; - client.transport._callbackMap[8] = { - event: '#publish', - data: {"channel": null}, - callback: function (err) { - nullPublishError = err; + await wait(100); + + assert.equal(middlewareWasExecuted, true); + assert.notEqual(error, null); + assert.equal(error.name, 'BlockedError'); + assert.equal(receivedMessages.length, 0); + }); + + 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); + + 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 clientC = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); + + await clientC.listener('connect').once(); + + let receivedMessages = []; + (async () => { + for await (let data of clientC.subscribe('foo')) { + receivedMessages.push(data); + } + })(); + + clientA.transmitPublish('foo', 'a1'); + clientA.transmitPublish('foo', 'a2'); + + clientB.transmitPublish('foo', 'b1'); + clientB.transmitPublish('foo', 'b2'); + + await wait(100); + + assert.equal(receivedMessages.length, 2); + assert.equal(receivedMessages[0], 'a1'); + assert.equal(receivedMessages[1], 'a2'); + + clientA.disconnect(); + clientB.disconnect(); + clientC.disconnect(); + }); + + 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(); + } + } + }; + + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); + + 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); + } + })(); + + let error; + try { + await client.invokePublish('hello', clientMessage); + } catch (err) { + error = err; } - }; - // Trick the server by sending a fake subscribe before the handshake is done. - client.on('connect', function () { - 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(100); + + assert.notEqual(clientMessage, middlewareMessage); + assert.equal(receivedMessages[0], middlewareMessage); }); - setTimeout(function () { - assert.notEqual(nullInChannelArrayError, null); - // console.log('nullInChannelArrayError:', nullInChannelArrayError); - assert.notEqual(objectAsChannelNameError, null); - // console.log('objectAsChannelNameError:', objectAsChannelNameError); - assert.notEqual(nullChannelNameError, null); - // console.log('nullChannelNameError:', nullChannelNameError); - assert.notEqual(nullUnsubscribeError, null); - // console.log('nullUnsubscribeError:', nullUnsubscribeError); - assert.notEqual(undefinedPublishError, null); - // console.log('undefinedPublishError:', undefinedPublishError); - assert.notEqual(objectAsChannelNamePublishError, null); - // console.log('objectAsChannelNamePublishError:', objectAsChannelNamePublishError); - assert.notEqual(nullPublishError, null); - // console.log('nullPublishError:', nullPublishError); - - done(); - }, 300); - }); - }); - }); - describe('Middleware', function () { - var middlewareFunction; - var port = 8026; - var middlewareWasExecuted = false; + 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(); + } + } + }; - beforeEach('Launch server without middleware before start', function (done) { - server = socketClusterServer.listen(port, { - authKey: serverOptions.authKey - }); - server.on('ready', function () { - done(); - }); - }); + server.setMiddleware(server.MIDDLEWARE_INBOUND, middlewareFunction); + + 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); + } + })(); + + let error; + try { + await client.transmitPublish('hello', clientMessage); + } catch (err) { + error = err; + } + + await wait(100); - afterEach('Shut down server afterwards', function (done) { - destroyTestCase(function () { - server.close(); - port++; - done(); + 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); - describe('MIDDLEWARE_AUTHENTICATE', function () { + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - it('Should not run authenticate middleware if JWT token does not exist', function (done) { - middlewareFunction = function (req, next) { - middlewareWasExecuted = true; - next(); - }; - server.addMiddleware(server.MIDDLEWARE_AUTHENTICATE, middlewareFunction); + await client.subscribe('hello').listener('subscribe').once(); - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false + assert.equal(middlewareWasExecuted, true); + assert.notEqual(middlewareAction, null); + assert.equal(middlewareAction.channel, 'hello'); }); - client.once('connect', function () { - assert.notEqual(middlewareWasExecuted, true); - done(); + 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 = []; + + 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' + }); + + let receivedMessage; + + let fooChannel = client.subscribe('foo'); + client.transmitPublish('foo', 'bar'); + + 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'); }); }); + }); - it('Should run authenticate middleware if JWT token exists', function (done) { - global.localStorage.setItem('socketCluster.authToken', validSignedAuthTokenBob); + 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); - middlewareFunction = function (req, next) { - middlewareWasExecuted = true; - next(); - }; - server.addMiddleware(server.MIDDLEWARE_AUTHENTICATE, middlewareFunction); + client = socketClusterClient.create({ + hostname: clientOptions.hostname, + port: PORT_NUMBER, + authTokenName: 'socketcluster.authToken' + }); - client = socketCluster.connect({ - hostname: clientOptions.hostname, - port: port, - multiplex: false - }); + await client.subscribe('hello').listener('subscribe').once(); + await client.invokePublish('hello', 123); - client.emit('login', {username: 'bob'}); - client.once('authenticate', function (state) { assert.equal(middlewareWasExecuted, true); - done(); + assert.notEqual(middlewareAction, null); + assert.equal(middlewareAction.channel, 'hello'); + assert.equal(middlewareAction.data, 123); }); }); });