├── .codeclimate.yml ├── .github ├── pull_request_template.md └── workflows │ └── actions.yml ├── .gitignore ├── .jshintrc ├── Changelog.md ├── api ├── api.js ├── apis │ └── radar.js ├── lib │ ├── client.js │ └── router.js └── readme.md ├── bin └── server.js ├── configuration.js ├── configurator.js ├── doc ├── RadarMessageSpecificationV2.md └── service_interface.md ├── index.js ├── package-lock.json ├── package.json ├── readme.md ├── sample ├── css │ └── style.css ├── engine.io.js ├── index.html ├── server.js └── views.js ├── src ├── client │ ├── client_session.js │ ├── client_session_state_machine.js │ └── socket_client_session_adapter.js ├── core │ ├── id.js │ ├── index.js │ ├── resources │ │ ├── message_list │ │ │ └── index.js │ │ ├── presence │ │ │ ├── index.js │ │ │ ├── presence_manager.js │ │ │ ├── presence_store.js │ │ │ ├── readme.md │ │ │ └── sentry.js │ │ ├── resource.js │ │ ├── status │ │ │ └── index.js │ │ └── stream │ │ │ ├── index.js │ │ │ └── subscriber_state.js │ ├── stamper.js │ └── type.js ├── middleware │ ├── index.js │ ├── legacy_auth_manager.js │ ├── quota_limiter.js │ ├── quota_manager.js │ └── runner.js └── server │ ├── server.js │ ├── service_interface.js │ └── session_manager.js └── test ├── client.auth.test.js ├── client.connect.test.js ├── client.message.test.js ├── client.presence.remote.test.js ├── client.presence.sentry.test.js ├── client.presence.test.js ├── client.rate_limit.test.js ├── client.reconnect.test.js ├── client.socket_client_session_adapter.test.js ├── client.status.test.js ├── client.stream.test.js ├── client.test.js ├── common.js ├── configurator.test.js ├── core.id.unit.test.js ├── integration └── service_interface.test.js ├── lib ├── assert_helper.js ├── formatter.js └── radar.js ├── message_list.unit.test.js ├── presence.manager.unit.test.js ├── presence.remote.test.js ├── presence.unit.test.js ├── quota_limiter.test.js ├── quota_manager.test.js ├── radar_api.test.js ├── sentry.unit.test.js ├── server.auth.unit.test.js ├── server.memory.test.js ├── server.middleware.unit.test.js ├── server.session_manager.test.js ├── server.unit.test.js ├── service_interface.test.js ├── stamper.test.js └── status.unit.test.js /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | eslint: 3 | enabled: false 4 | exclude_paths: 5 | - "sample/*" 6 | ratings: 7 | paths: 8 | - "client/**" 9 | - "core/**" 10 | - "middleware/**" 11 | - "server/**" 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | Describe the original problem and the changes made on this PR. 4 | 5 | ### References 6 | 7 | * Github issue: 8 | 9 | ### Risks 10 | 11 | * High | Medium | Low : How might failures be experienced? All code changes 12 | carry a minimum of risk of **Low**, and **None** should be a rare exception. 13 | * Rollback: 14 | -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | main: 5 | name: npm test 6 | runs-on: ubuntu-22.04 7 | strategy: 8 | matrix: 9 | version: 10 | - 20 11 | - 22 12 | - 24 13 | steps: 14 | - uses: zendesk/checkout@v4 15 | - uses: zendesk/setup-node@v3 16 | with: 17 | node-version: ${{ matrix.version }} 18 | - name: install 19 | run: | 20 | sudo apt update 21 | sudo apt install -y redis-server 22 | npm install 23 | - name: node_js ${{ matrix.version }} 24 | run: | 25 | redis-server --version 26 | verbose=1 npm test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | client/dist 3 | .bundle/ 4 | vendor/bundle/ 5 | npm-debug.log 6 | devmode.log 7 | logs/radar.log 8 | *.swp 9 | .vscode 10 | 11 | # mac os file explorer settings 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals" : { 3 | "describe" : false, 4 | "it" : false, 5 | "before" : false, 6 | "beforeEach" : false, 7 | "after" : false, 8 | "afterEach" : false 9 | }, 10 | "node": true 11 | } 12 | -------------------------------------------------------------------------------- /api/api.js: -------------------------------------------------------------------------------- 1 | const hostname = require('os').hostname() 2 | const Router = require('./lib/router.js') 3 | const RadarApi = require('./apis/radar.js') 4 | 5 | const api = new Router() 6 | 7 | function homepage (req, res) { 8 | res.setHeader('Content-Type', 'text/plain') // IE will otherwise try to save the response instead of just showing it 9 | res.end(JSON.stringify({ pong: 'Radar running at ' + hostname })) 10 | } 11 | 12 | // Monitor API 13 | 14 | api.get(/^(\/ping)?\/?$/, homepage) 15 | api.get(/^\/engine.io\/ping.*$/, homepage) 16 | 17 | // Radar API 18 | 19 | api.post(/^\/radar\/status/, RadarApi.setStatus) 20 | api.get(/^\/radar\/status/, RadarApi.getStatus) 21 | api.post(/^\/radar\/message/, RadarApi.setMessage) 22 | api.get(/^\/radar\/message/, RadarApi.getMessage) 23 | api.get(/^\/radar\/presence(.*)/, RadarApi.getPresence) 24 | 25 | module.exports = api 26 | -------------------------------------------------------------------------------- /api/apis/radar.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-deprecated-api */ 2 | 3 | const url = require('url') 4 | const Status = require('../../src/core').Status 5 | const MessageList = require('../../src/core').MessageList 6 | const Presence = require('../../src/core').Presence 7 | const PresenceManager = require('../../src/core').PresenceManager 8 | const Type = require('../../src/core').Type 9 | const hostname = require('os').hostname() 10 | 11 | function jsonResponse (response, object) { 12 | response.setHeader('Content-type', 'application/json') 13 | response.setHeader('Cache-Control', 'no-cache') 14 | response.end(JSON.stringify(object)) 15 | } 16 | 17 | function parseData (response, data, ResourceType) { 18 | let parameters = data 19 | 20 | if (typeof data === 'string') { 21 | try { 22 | parameters = JSON.parse(data) 23 | } catch (e) { 24 | parameters = false 25 | } 26 | } 27 | 28 | if (!parameters || !parameters.accountName || !parameters.scope) { 29 | return jsonResponse(response, {}) 30 | } 31 | 32 | const resourceTo = ResourceType.prototype.type + ':/' + parameters.accountName + '/' + parameters.scope 33 | const options = Type.getByExpression(resourceTo) 34 | const resource = new ResourceType(resourceTo, {}, options) 35 | 36 | resource.accountName = parameters.accountName 37 | resource.scope = parameters.scope 38 | 39 | resource.key = parameters.key 40 | resource.value = parameters.value 41 | 42 | return resource 43 | } 44 | 45 | // Note that Firefox needs a application/json content type or it will show a warning 46 | 47 | // curl -k -H "Content-Type: application/json" -X POST -d '{"accountName":"test","scope":"ticket/1","key":"greeting","value":"hello"}' https://localhost/radar/status 48 | function setStatus (req, res, re, data) { 49 | const status = parseData(res, data, Status) 50 | 51 | if (status) { 52 | if (!status.key || !status.value) { 53 | return jsonResponse(res, {}) 54 | } 55 | 56 | status._set(status.to, 57 | { 58 | op: 'set', 59 | to: status.to, 60 | key: status.key, 61 | value: status.value 62 | }, status.options.policy || {}, function () { 63 | jsonResponse(res, {}) 64 | }) 65 | } 66 | } 67 | 68 | // curl -k "https://localhost/radar/status?accountName=test&scope=ticket/1" 69 | function getStatus (req, res) { 70 | const parts = url.parse(req.url, true) 71 | const status = parseData(res, parts.query, Status) 72 | 73 | if (status) { 74 | status._get('status:/' + status.accountName + '/' + status.scope, function (replies) { 75 | jsonResponse(res, replies) 76 | }) 77 | } 78 | } 79 | 80 | function setMessage (req, res, re, data) { 81 | const message = parseData(res, data, MessageList) 82 | 83 | if (message) { 84 | if (!message.value) { 85 | return jsonResponse(res, {}) 86 | } 87 | 88 | message._publish(message.to, message.options.policy || {}, 89 | { 90 | op: 'publish', 91 | to: 'message:/' + message.accountName + '/' + message.scope, 92 | value: message.value 93 | }, function () { 94 | jsonResponse(res, {}) 95 | }) 96 | } 97 | } 98 | 99 | function getMessage (req, res) { 100 | const parts = url.parse(req.url, true) 101 | const message = parseData(res, parts.query, MessageList) 102 | 103 | if (message) { 104 | message._sync(message.to, message.options.policy || {}, function (replies) { 105 | jsonResponse(res, replies) 106 | }) 107 | } 108 | } 109 | 110 | // curl -k "https://localhost/radar/presence?accountName=test&scope=ticket/1" 111 | // Version 1: 112 | // - Response for single scope: { userId: userType } 113 | // - Response for multiple scopes (comma separated): { scope: { userId: userType } } 114 | // Version 2: 115 | // - Version number is required (e.g. &version=2) 116 | // - Response for single scope: { userId: { "clients": { clientId1: {}, clientId2: {} }, userType: } } 117 | // - Response for multiple scopes: { scope1: ... above ..., scope2: ... above ... } 118 | function getPresence (req, res) { 119 | const parts = url.parse(req.url, true) 120 | const q = parts.query 121 | if (!q || !q.accountName) { return res.end() } 122 | if (!(q.scope || q.scopes)) { return res.end() } 123 | const versionNumber = parseInt(q.version, 10) 124 | // Sadly, the responses are different when dealing with multiple scopes so can't just put these in a control flow 125 | if (q.scope) { 126 | const monitor = new PresenceManager('presence:/' + q.accountName + '/' + q.scope, {}, Presence.sentry) 127 | monitor.fullRead(function (online) { 128 | res.setHeader('Content-type', 'application/json') 129 | res.setHeader('Cache-Control', 'no-cache') 130 | res.setHeader('X-Radar-Host', hostname) 131 | if (versionNumber === 2) { 132 | res.end(JSON.stringify(monitor.getClientsOnline()) + '\n') 133 | } else { 134 | res.end(JSON.stringify(online) + '\n') 135 | } 136 | }) 137 | } else { 138 | const scopes = q.scopes.split(',') 139 | const result = {} // key: scope - value: replies 140 | scopes.forEach(function (scope) { 141 | const monitor = new PresenceManager('presence:/' + q.accountName + '/' + scope, {}, Presence.sentry) 142 | monitor.fullRead(function (online) { 143 | if (versionNumber === 2) { 144 | result[scope] = monitor.getClientsOnline() 145 | } else { 146 | result[scope] = online 147 | } 148 | if (Object.keys(result).length === scopes.length) { 149 | res.setHeader('Content-type', 'application/json') 150 | res.setHeader('Cache-Control', 'no-cache') 151 | res.setHeader('X-Radar-Host', hostname) 152 | res.end(JSON.stringify(result) + '\n') 153 | } 154 | }) 155 | }) 156 | } 157 | } 158 | 159 | module.exports = { 160 | setStatus: setStatus, 161 | getStatus: getStatus, 162 | setMessage: setMessage, 163 | getMessage: getMessage, 164 | getPresence: getPresence 165 | } 166 | -------------------------------------------------------------------------------- /api/lib/client.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-deprecated-api */ 2 | 3 | const https = require('https') 4 | const http = require('http') 5 | const qs = require('querystring') 6 | const urlmodule = require('url') 7 | const logging = require('minilog')('client') 8 | 9 | function Scope (defaults) { 10 | // Clone Client.def. We don't want to change the defaults when we modify options further. 11 | this.defaults = JSON.parse(JSON.stringify(defaults)) 12 | } 13 | 14 | Scope.prototype.get = function (path) { 15 | const c = new Client() 16 | 17 | // Note: assigning this.defaults to c.options will still cause issues! 18 | // The problem is that since we modify c.options when forming requests 19 | // we would end up modifying the defaults values as well. 20 | // JSON.parse(JSON.stringify) is just used as a lazy way to create a deep copy 21 | c.options = JSON.parse(JSON.stringify(this.defaults)) 22 | c.set('method', 'GET') 23 | .set('path', path) 24 | return c 25 | } 26 | 27 | Scope.prototype.post = function (path) { 28 | const c = new Client() 29 | 30 | c.options = JSON.parse(JSON.stringify(this.defaults)) 31 | c.set('method', 'POST') 32 | .set('path', path) 33 | 34 | return c 35 | } 36 | 37 | function Client () { 38 | this.options = { 39 | headers: {}, 40 | secure: false 41 | } 42 | } 43 | 44 | Client.prototype.set = function (key, value) { 45 | this.options[key] = value 46 | return this 47 | } 48 | 49 | Client.prototype.header = function (key, value) { 50 | this.options.headers = this.options.headers || {} 51 | this.options.headers[key] = value 52 | return this 53 | } 54 | 55 | Client.prototype.data = function (data) { 56 | if (this.options.method === 'GET') { 57 | // Append to QS 58 | logging.debug('GET append', data) 59 | this.options.path += '?' + qs.stringify(data) 60 | } else { 61 | // JSON encoding 62 | this.options.headers = this.options.headers || {} 63 | this.options.headers['Content-Type'] = 'application/json' 64 | this.options.data = JSON.stringify(data) 65 | this.options.headers['Content-Length'] = this.options.data.length 66 | } 67 | return this 68 | } 69 | 70 | Client.prototype.end = function (callback) { 71 | this.options.redirects = 0 72 | this._end(callback) 73 | } 74 | 75 | Client.prototype._end = function (callback) { 76 | const self = this 77 | const options = this.options 78 | const secure = this.options.secure 79 | let resData = '' 80 | const protocol = (secure ? https : http) 81 | 82 | if (this.beforeRequest) { 83 | this.beforeRequest(this) 84 | } 85 | 86 | logging.info('New API Request. Sending a ' + 87 | (secure ? 'https ' : 'http') + 88 | 'request. Options: ', options) 89 | 90 | const proxy = protocol.request(options, function (response) { 91 | response.on('data', function (chunk) { resData += chunk }) 92 | response.on('end', function () { 93 | const isRedirect = Math.floor(response.statusCode / 100) === 3 && response.headers && response.headers.location 94 | 95 | logging.debug('Response for the request "' + options.method + ' ' + options.host + options.path + '" has been ended.') 96 | 97 | if (isRedirect && self.options.redirects === 0) { 98 | logging.debug('Redirect to: ', response.headers.location) 99 | return self._redirect(response) 100 | } 101 | 102 | if (response.headers['content-type'] && 103 | response.headers['content-type'].toLowerCase().indexOf('application/json') > -1) { 104 | try { 105 | resData = JSON.parse(resData) 106 | } catch (jsonParseError) { 107 | return self._error(jsonParseError, resData, callback) 108 | } 109 | } 110 | 111 | // Detect errors 112 | if (response.statusCode >= 400) { 113 | return self._error(new Error('Unexpected HTTP status code ' + response.statusCode), resData, callback) 114 | } else if (resData === '') { 115 | return self._error(new Error('Response was empty.'), resData, callback) 116 | } 117 | 118 | logging.info('The request "' + 119 | options.method + ' ' + options.host + options.path + 120 | '" has been responded successfully.') 121 | 122 | logging.debug('Response body: ', resData) 123 | 124 | if (callback) { 125 | callback(undefined, resData) 126 | } 127 | }) 128 | }).on('error', function (err) { self._error(err, callback) }) 129 | 130 | if (options.data && options.method !== 'GET') { 131 | proxy.write(options.data) 132 | } 133 | 134 | proxy.end() 135 | } 136 | 137 | Client.prototype._error = function (error, resData, callback) { 138 | logging.error('#api_error - An Error occured', error, 139 | 'Received response: ' + resData + '') 140 | 141 | if (callback) { 142 | callback(error, resData) 143 | } 144 | } 145 | 146 | Client.prototype._redirect = function (response, callback) { 147 | if (!/^https?:/.test(response.headers.location)) { 148 | response.headers.location = urlmodule.resolve(this.options.url, response.headers.location) 149 | } 150 | 151 | // Parse location to check for port 152 | const parts = urlmodule.parse(response.headers.location) 153 | if (parts.protocol === 'http:') { 154 | this.options.secure = false 155 | this.options.port = parts.port || 80 156 | } 157 | 158 | this.options.url = parts.href 159 | this._end(callback) 160 | } 161 | 162 | module.exports = Scope 163 | -------------------------------------------------------------------------------- /api/lib/router.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-deprecated-api */ 2 | 3 | const url = require('url') 4 | const logging = require('minilog')('radar:api-router') 5 | 6 | function Router () { 7 | this.urlMap = [] 8 | } 9 | 10 | // Route an incoming API request 11 | Router.prototype.route = function (req, res) { 12 | logging.info('Routing request "' + req.method + ' ' + req.url + '"') 13 | 14 | const pathname = url.parse(req.url).pathname.replace(/^\/?node/, '') 15 | const len = this.urlMap.length 16 | let i = -1 17 | let urlHandler 18 | 19 | while (++i <= len) { 20 | if (this.urlMap[i] && this.urlMap[i].method === req.method && this.urlMap[i].re.test(pathname)) { 21 | urlHandler = this.urlMap[i] 22 | break 23 | } 24 | } 25 | 26 | if (!urlHandler) { 27 | return false 28 | } 29 | 30 | if (req.method === 'POST') { 31 | let data = '' 32 | 33 | req.on('data', function (chunk) { 34 | data += chunk 35 | }) 36 | 37 | req.on('end', function () { 38 | logging.debug('Post data sent to ' + req.url + ' ended.') 39 | urlHandler.callback.apply(undefined, [req, res, urlHandler.re.exec(pathname), data]) 40 | }) 41 | } else { 42 | urlHandler.callback.apply(undefined, [req, res, urlHandler.re.exec(pathname)]) 43 | } 44 | 45 | return true 46 | } 47 | 48 | Router.prototype.get = function (regexp, callback) { 49 | this.urlMap.push({ method: 'GET', re: regexp, callback: callback }) 50 | } 51 | 52 | Router.prototype.post = function (regexp, callback) { 53 | this.urlMap.push({ method: 'POST', re: regexp, callback: callback }) 54 | } 55 | 56 | Router.prototype.attach = function (httpServer) { 57 | const self = this 58 | 59 | // Cache and clean up listeners 60 | const oldListeners = httpServer.listeners('request') 61 | httpServer.removeAllListeners('request') 62 | 63 | // Add request handler 64 | httpServer.on('request', function (req, res) { 65 | if (!self.route(req, res)) { 66 | logging.info('Routing to old listeners') 67 | for (let i = 0, l = oldListeners.length; i < l; i++) { 68 | oldListeners[i].call(httpServer, req, res) 69 | } 70 | } 71 | }) 72 | } 73 | 74 | module.exports = Router 75 | -------------------------------------------------------------------------------- /api/readme.md: -------------------------------------------------------------------------------- 1 | ## The Radar API 2 | 3 | Overview: 4 | 5 | - Presence can only be set from the Radar client, not via the API. This is because "being present" means that you are available for push communication, so you need the full client. 6 | - All the POST apis respond with: 7 | 8 | ```js 9 | {} 10 | 200: OK 11 | ``` 12 | 13 | ### Status 14 | 15 | #### /radar/status [POST] 16 | 17 | curl -k -H "Content-Type: application/json" -X POST -d '{"accountName":"test","scope":"ticket/1","key":"greeting","value":"hello"}' https://localhost/radar/status 18 | 19 | You probably want to set the key to the current user's ID if you want to have each user have it's own value in the same scope. 20 | 21 | You can store any arbitrary string as the value. 22 | 23 | ##### /radar/status [GET] 24 | 25 | curl -k "https://localhost/radar/status?accountName=test&scope=ticket/1" 26 | 27 | ###### Response - Status 28 | ```js 29 | { 30 | 1: 'foo', 31 | 2: 'bar', 32 | 123: 'foo' 33 | } 34 | 200: OK 35 | ``` 36 | 37 | The keys and values of the hash are determined by the content of the status. Often these are userID: value pairs. 38 | 39 | ### Presence 40 | 41 | Presence can only be set from the Radar client, not via the API. 42 | 43 | #### /radar/presence [GET] 44 | 45 | curl -k "https://localhost/radar/presence?accountName=test&scope=ticket/1" 46 | 47 | You can use scopes=one,two to get multiple scopes in one get: 48 | 49 | curl -k "https://localhost/radar/presence?accountName=test&scopes=ticket/1,ticket/2" 50 | 51 | ###### Response - Presence 52 | ```js 53 | { 54 | 1: 0, 55 | 2: 2, 56 | 123: 4 57 | } 58 | 200: OK 59 | ``` 60 | 61 | This the keys are the user IDs of present users, and the values are the user types of the users (0 = end user, 2 = agent, 4 = admin). 62 | 63 | When getting multiple scopes, the format is: 64 | 65 | ```js 66 | { 67 | "scope1": { ... }, 68 | "scope2": { ... } 69 | } 70 | ``` 71 | 72 | 73 | ### MessageList 74 | 75 | #### /radar/message [POST] 76 | 77 | curl -k -H "Content-Type: application/json" -X POST -d '{"accountName":"test","scope":"chat/123", "value":"hello"}' https://localhost/radar/message 78 | 79 | #### /radar/message [GET] 80 | 81 | curl -k "https://localhost/radar/message?accountName=test&scope=dev/test" 82 | 83 | ###### Response - MessageList 84 | ```js 85 | [ 86 | { to: 'message:/dev/test', value: ... }, 87 | { to: 'message:/dev/test', value: ... } 88 | ] 89 | 200: OK 90 | ``` 91 | 92 | The response includes all the messages have not yet expired (based on the message retention policy on the server side, e.g. time-limited for some resources). 93 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const http = require('http') 3 | const configuration = require('../configurator.js').load({ persistence: true }) 4 | const Radar = require('../src/server/server.js') 5 | const Api = require('../api/api.js') 6 | const Minilog = require('minilog') 7 | 8 | // Configure log output 9 | Minilog.pipe(Minilog.suggest.deny(/.*/, (process.env.radar_log ? process.env.radar_log : 'debug'))) 10 | .pipe(Minilog.backends.nodeConsole.formatWithStack) 11 | .pipe(Minilog.backends.nodeConsole) 12 | 13 | function p404 (req, res) { 14 | console.log('Returning Error 404 for ' + req.method + ' ' + req.url) 15 | res.statusCode = 404 16 | res.end('404 Not Found') 17 | } 18 | 19 | const httpServer = http.createServer(p404) 20 | 21 | // Radar API 22 | Api.attach(httpServer) 23 | 24 | // Radar server 25 | const radar = new Radar() 26 | radar.attach(httpServer, configuration) 27 | 28 | httpServer.listen(configuration.port, function () { 29 | console.log('Radar Server listening on port ' + configuration.port) 30 | }) 31 | -------------------------------------------------------------------------------- /configuration.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Default (optional) 3 | // Will fallback to localhost:6379 4 | redis_host: 'localhost', 5 | redis_port: 6379, 6 | 7 | // (Optional) Only usable if you define use_connection. 8 | // Lets you specify a number of redis options and pick one. 9 | connection_settings: { 10 | legacy: { 11 | host: 'localhost', 12 | port: 6379 13 | }, 14 | cluster1: { 15 | // Sentinel master name is required 16 | id: 'mymaster', 17 | sentinels: [ 18 | { 19 | host: 'localhost', 20 | port: 26379 21 | }] 22 | }, 23 | cluster2: { 24 | id: 'mymaster', 25 | sentinels: [ 26 | { 27 | host: 'localhost', 28 | port: 36379 29 | }, 30 | { 31 | host: 'localhost', 32 | port: 36380 33 | }, 34 | { 35 | host: 'localhost', 36 | port: 36381 37 | }] 38 | } 39 | }, 40 | 41 | // Only used if a connection_settings hash is present. 42 | // (Optional). will fallback to default if not present. 43 | // use_connection: 'legacy', 44 | 45 | // Radar config: Port for radar to run on. 46 | port: 8000 47 | } 48 | -------------------------------------------------------------------------------- /configurator.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Configurator: Handles configuration for the Radar server. 3 | * 4 | * It provides the default configuration. 5 | * For each allowed variable, it provides a default, which can be overwritten 6 | * through ENV or ARGV 7 | * 8 | * All the knowledge of what comes in, belongs here 9 | * 10 | * ARGV > ENV > DEFAULTS 11 | * 12 | */ 13 | /* eslint-disable node/no-deprecated-api */ 14 | 15 | // Minimal radar settings 16 | const defaultSettings = [ 17 | { 18 | name: 'port', 19 | description: 'port to listen', 20 | env: 'RADAR_PORT', 21 | abbr: 'p', 22 | full: 'port', 23 | default: '8000' 24 | }, 25 | { 26 | name: 'redisUrl', 27 | description: 'Redis url', 28 | env: 'RADAR_REDIS_URL', 29 | abbr: 'r', 30 | full: 'redis_url', 31 | default: 'redis://localhost:6379' 32 | }, 33 | { 34 | name: 'sentinelMasterName', 35 | description: 'sentinel master name', 36 | env: 'RADAR_SENTINEL_MASTER_NAME', 37 | full: 'sentinel_master_name' 38 | }, 39 | { 40 | name: 'sentinelUrls', 41 | description: 'sentinel urls', 42 | env: 'RADAR_SENTINEL_URLS', 43 | full: 'sentinel_urls' 44 | } 45 | ] 46 | 47 | const Configurator = function (settings) { 48 | this.settings = clone(defaultSettings) 49 | 50 | if (settings) { 51 | const self = this 52 | settings.forEach(function (setting) { 53 | self.settings.push(clone(setting)) 54 | }) 55 | } 56 | } 57 | 58 | // Class methods 59 | 60 | // Creates a Configurator and returns loaded configuration 61 | Configurator.load = function () { 62 | const configurator = new Configurator() 63 | const configuration = configurator.load.apply(configurator, arguments) 64 | 65 | return configuration 66 | } 67 | 68 | // Instance methods 69 | 70 | Configurator.prototype.load = function () { 71 | const self = this 72 | const options = (arguments.length === 1 ? arguments[0] : {}) 73 | const cli = this._parseCli((options.argv || process.argv)) 74 | const env = this._parseEnv((options.env || process.env)) 75 | const config = this._defaultConfiguration() 76 | 77 | merge(config, options.config) 78 | 79 | this.settings.forEach(function (variable) { 80 | config[variable.name] = self._pickFirst(variable.name, cli, env, config) 81 | }) 82 | 83 | if (options.persistence) { 84 | config.persistence = self._forPersistence(config) 85 | } 86 | 87 | return config 88 | } 89 | 90 | // Private instance methods 91 | 92 | Configurator.prototype._parseCli = function (argv) { 93 | const parser = require('@gerhobbelt/nomnom')() 94 | 95 | this.settings.forEach(function (element) { 96 | parser.option(element.name, { 97 | help: element.description, 98 | full: element.full, 99 | abbr: element.abbr 100 | }) 101 | }) 102 | 103 | return parser.parse(argv) 104 | } 105 | 106 | Configurator.prototype._parseEnv = function (env) { 107 | const cleanEnv = {} 108 | let value 109 | 110 | this.settings.forEach(function (element) { 111 | value = env[element.env] 112 | if (value) { 113 | cleanEnv[element.name] = value 114 | } 115 | }) 116 | 117 | return cleanEnv 118 | } 119 | 120 | Configurator.prototype._defaultConfiguration = function () { 121 | const config = {} 122 | 123 | this.settings.forEach(function (element) { 124 | if (Object.prototype.hasOwnProperty.call(element, 'default')) { 125 | config[element.name] = element.default 126 | } 127 | }) 128 | 129 | return config 130 | } 131 | 132 | Configurator.prototype._pickFirst = function (propName) { 133 | const values = [].slice.call(arguments, 1) 134 | let i = 0 135 | let value = null 136 | 137 | while (!value && i <= values.length) { 138 | if (values[i] && values[i][propName]) { 139 | value = values[i][propName] 140 | } 141 | i++ 142 | } 143 | 144 | return value 145 | } 146 | 147 | Configurator.prototype._forPersistence = function (configuration) { 148 | let connection 149 | 150 | // Using sentinel 151 | if (configuration.sentinelMasterName) { 152 | if (!configuration.sentinelUrls) { 153 | throw Error('sentinelMasterName present but no sentinelUrls was provided. ') 154 | } 155 | 156 | connection = { 157 | id: configuration.sentinelMasterName 158 | } 159 | 160 | connection.sentinels = configuration.sentinelUrls 161 | .split(',') 162 | .map(parseUrl) 163 | } else { // Using standalone redis 164 | connection = parseUrl(configuration.redisUrl) 165 | } 166 | 167 | return { 168 | use_connection: 'main', 169 | connection_settings: { 170 | main: connection 171 | } 172 | } 173 | } 174 | 175 | // Private methods 176 | // TODO: Move to Util module, or somewhere else 177 | 178 | function parseUrl (redisUrl) { 179 | const parsedUrl = require('url').parse(redisUrl) 180 | const config = { 181 | host: parsedUrl.hostname, 182 | port: parsedUrl.port 183 | } 184 | 185 | if (parsedUrl.auth) { 186 | // the password part of user:pass format 187 | config.redis_auth = parsedUrl.auth.substr(parsedUrl.auth.indexOf(':') + 1) 188 | } 189 | 190 | if (redisUrl.startsWith('rediss://')) { 191 | config.tls = {} 192 | } 193 | 194 | return config 195 | } 196 | 197 | function merge (destination, source) { 198 | for (const name in source) { 199 | if (Object.prototype.hasOwnProperty.call(source, name)) { 200 | destination[name] = source[name] 201 | } 202 | } 203 | 204 | return destination 205 | } 206 | 207 | function clone (object) { 208 | return JSON.parse(JSON.stringify(object)) 209 | } 210 | 211 | module.exports = Configurator 212 | -------------------------------------------------------------------------------- /doc/service_interface.md: -------------------------------------------------------------------------------- 1 | # Radar Service Interface 2 | 3 | The Service Interface is a simple HTTP-based interface for services to query radar. 4 | 5 | ## Service Endpoint 6 | 7 | - http(s)://hostname:port/radar/service 8 | 9 | ## GET 10 | 11 | E.g. `http://localhost:8000/radar/service?to=status:/account/foobar` 12 | 13 | ### Querystring Parameters 14 | - `to` : the full resource scope, e.g. `status:/account/foobar` 15 | 16 | This is a simplified case of the POST endpoint for Radar resources supporting `get` operations. 17 | 18 | ### Response 19 | A `get server message` depending on the resource type, e.g. for a Status: 20 | 21 | ```json 22 | { 23 | "op": "get", 24 | "to": "status:/account/clients/1452556889570", 25 | "value": { 26 | "1452556889570": "sdf" 27 | } 28 | } 29 | ``` 30 | 31 | or for a Presence: 32 | 33 | ```json 34 | { 35 | "op": "get", 36 | "to": "presence:/account/box1", 37 | "value": { 38 | "1452560641403": 2 39 | } 40 | } 41 | ``` 42 | 43 | ## POST 44 | 45 | E.g. `http://localhost:8000/radar/service` 46 | 47 | ### Headers 48 | 49 | | name | value | 50 | | -----|------- | 51 | | Content-Type | application/json | 52 | 53 | ### Body 54 | A JSON-encoded [Radar message](https://github.com/zendesk/radar/blob/master/doc/RadarMessageSpecificationV2.md) 55 | 56 | ```json 57 | { 58 | "op": "set", 59 | "to": "status:/account/clients/1452556889570", 60 | "key": "0fe3a", 61 | "value": { 62 | "color": "c0ffee" 63 | } 64 | } 65 | ``` 66 | 67 | ### Response 68 | - 200 plus `application/json` Radar response message depending on input. 69 | - 400 on client-side error. 70 | - 500 on server error. 71 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | api: require('./api/api'), 3 | configurator: require('./configurator'), 4 | core: require('./src/core'), 5 | server: require('./src/server/server'), 6 | middleware: require('./src/middleware') 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "radar", 3 | "description": "Realtime apps with a high level API based on engine.io", 4 | "version": "0.43.0", 5 | "author": "Zendesk, Inc.", 6 | "license": "Apache-2.0", 7 | "engines": { 8 | "node": ">=10" 9 | }, 10 | "contributors": [ 11 | "Mikito Takada ", 12 | { 13 | "name": "Sam Shull", 14 | "url": "http://github.com/samshull" 15 | }, 16 | { 17 | "name": "Vanchi Koduvayur", 18 | "url": "https://github.com/vanchi-zendesk" 19 | }, 20 | { 21 | "name": "Nicolas Herment", 22 | "url": "https://github.com/nherment" 23 | }, 24 | "jden " 25 | ], 26 | "keywords": [ 27 | "realtime", 28 | "real-time", 29 | "pubsub", 30 | "pub-sub", 31 | "socketio", 32 | "server", 33 | "socket.io", 34 | "engine.io", 35 | "comet", 36 | "ajax" 37 | ], 38 | "bin": "./bin/server.js", 39 | "repository": { 40 | "type": "git", 41 | "url": "https://github.com/zendesk/radar.git" 42 | }, 43 | "dependencies": { 44 | "@gerhobbelt/nomnom": "^1.8.4-31", 45 | "async": "^3.2.4", 46 | "callback_tracker": "0.1.0", 47 | "concat-stream": "^2.0.0", 48 | "content-type": "^1.0.5", 49 | "engine.io": "^6.6.2", 50 | "http-attach": "^1.0.0", 51 | "javascript-state-machine": "^3.1.0", 52 | "lodash": "^4.17.21", 53 | "miniee": "0.0.5", 54 | "minilog": "^2.1.0", 55 | "mobx": "^6.10.2", 56 | "nonblocking": "^1.0.3", 57 | "persistence": "^2.1.0", 58 | "radar_message": "^1.4.0", 59 | "semver": "^7.5.4", 60 | "uuid": "^8.3.2" 61 | }, 62 | "devDependencies": { 63 | "chai": "^4.3.8", 64 | "chai-interface": "^2.0.3", 65 | "literal-stream": "^0.1.0", 66 | "mocha": "^11.4.0", 67 | "node-fetch": "^3.3.2", 68 | "proxyquire": "^2.1.3", 69 | "radar_client": "^0.17.3", 70 | "simple_sentinel": "github:zendesk/simple_sentinel", 71 | "sinon": "^13.0.2", 72 | "sinon-chai": "^3.7.0", 73 | "smooth-progress": "^1.1.0", 74 | "standard": "^16.0.4" 75 | }, 76 | "scripts": { 77 | "prestart": "npm run check-modules", 78 | "start": "node bin/server.js", 79 | "check-modules": "if [ -z \"$SKIP_PACKAGE_CHECK\" ] && [ ./package.json -nt ./node_modules ]; then echo updating modules && npm install; fi", 80 | "check-clean": "if [[ $(git diff --shortstat 2> /dev/null | tail -n1) != \"\" ]]; then npm run warn-dirty-tree && exit 1; fi", 81 | "warn-dirty-tree": "echo 'Your repo tree is dirty.'", 82 | "pretest": "npm run check-modules && npm run lint", 83 | "lint": "standard", 84 | "test": "npm run test:sentinel", 85 | "test:integration": "TEST=\"test/integration/*\" npm run test:one", 86 | "test:full": "npm run test:sentinel && npm run test:redis", 87 | "test:redis": "ls ./test/*.test.js | xargs -n 1 -t -I {} sh -c 'TEST=\"{}\" npm run test:one'", 88 | "pretest:sentinel": "./node_modules/.bin/simple_sentinel start", 89 | "test:sentinel": "ls ./test/*.test.js | xargs -n 1 -t -I {} sh -c 'TEST=\"{}\" RADAR_SENTINEL_URLS=sentinel://localhost:26379 RADAR_SENTINEL_MASTER_NAME=mymaster npm run test:one'", 90 | "posttest:sentinel": "./node_modules/.bin/simple_sentinel stop", 91 | "test:one": "./node_modules/.bin/mocha --reporter spec --slow 10000ms --timeout 25000ms --exit \"$TEST\"", 92 | "test:one-solo": "./node_modules/.bin/mocha --reporter spec --slow 10000ms --timeout 25000ms --exit", 93 | "test:debug": "./node_modules/.bin/mocha debug --reporter spec --slow 10000ms --exit \"$TEST\"", 94 | "test:memory": "mocha --expose-gc test/*.memory.test.js --reporter spec" 95 | }, 96 | "standard": { 97 | "ignore": [ 98 | "sample/" 99 | ] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## radar 2 | 3 | The real-time service layer for your web application 4 | 5 | [![npm](https://img.shields.io/npm/v/radar.svg)](https://www.npmjs.com/package/radar) 6 | [![CI](https://github.com/zendesk/radar/workflows/CI/badge.svg)](https://travis-ci.org/zendesk/radar) 7 | [![Dependency Status](https://david-dm.org/zendesk/radar.svg)](https://david-dm.org/zendesk/radar) 8 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com/) 9 | 10 | 11 | ## Documentation 12 | 13 | See [radar.zendesk.com](http://radar.zendesk.com) for detailed documentation. 14 | 15 | This is the project necessary for running a radar server. Documentation about building an app and using the client-side libraries is available at [radar.zendesk.com](http://radar.zendesk.com). Browse [radar client libraries and tools](https://github.com/zendesk?utf8=%E2%9C%93&query=radar). 16 | 17 | ## Installation 18 | Requires: redis 2.8+, node.js 4+ 19 | 20 | ###Installing from npm: 21 | 22 | ```sh 23 | $ npm install radar 24 | ``` 25 | 26 | 27 | ### Programmatic usage 28 | radar can be extended programmatically with custom code and middleware: 29 | 30 | ```js 31 | var http = require('http') 32 | var { Radar } = require('radar') 33 | 34 | var httpServer = http.createServer(function(req, res) { 35 | res.end('Nothing here.'); 36 | }); 37 | 38 | // Radar server 39 | var radar = new Radar(); 40 | radar.attach(httpServer, { redis_host: 'localhost', redis_port: 6379 }); 41 | 42 | httpServer.listen(8000); 43 | ``` 44 | 45 | See also the [`sample`](https://github.com/zendesk/radar/tree/master/sample) directory in the `radar` repository. 46 | 47 | 48 | ### Out-of-the-box usage 49 | ```sh 50 | $ git clone git@github.com:zendesk/radar.git 51 | $ cd radar 52 | $ npm install 53 | $ npm start 54 | ``` 55 | 56 | *See [radar.zendesk.com/server](http://radar.zendesk.com/server) for additional usage and configuration documentation* 57 | 58 | ## Contributing 59 | 60 | ## Running tests 61 | ```sh 62 | $ npm test 63 | ``` 64 | 65 | By default, tests are run only against redis sentinel. 66 | 67 | If you want to run against redis directly: `$ npm run test-redis` 68 | For direct redis and redis sentinel: `$ npm run test-full` 69 | 70 | 71 | ## Workflow 72 | 73 | - Fork http://github.com/zendesk/radar, clone, make changes (including a Changelog update), commit, push, PR 74 | 75 | 76 | ## Copyright and License 77 | 78 | Copyright 2016, Zendesk Inc. 79 | Licensed under the Apache License Version 2.0, http://www.apache.org/licenses/LICENSE-2.0 80 | -------------------------------------------------------------------------------- /sample/css/style.css: -------------------------------------------------------------------------------- 1 | body, div, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6, pre, 2 | form, fieldset, input, p, blockquote, table, th, td, embed, object, hr { 3 | padding: 0; 4 | margin: 0; 5 | } 6 | table { 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | } 10 | fieldset, img, abbr { 11 | border: 0; 12 | } 13 | 14 | address, caption, cite, code, dfn, em, 15 | h1, h2, h3, h4, h5, h6, strong, th, var { 16 | font-weight: normal; 17 | font-style: normal; 18 | } 19 | ul { 20 | list-style: none; 21 | } 22 | 23 | h1, h2, h3, h4, h5, h6 { 24 | font-size: 1.0em; 25 | } 26 | 27 | h1 { 28 | font-size: 36px; 29 | line-height: 1; 30 | margin-bottom: 0.5em; 31 | } 32 | 33 | h2 { 34 | font-size: 24px; 35 | text-align: left; 36 | color: black; 37 | line-height: 32px; 38 | margin-bottom: 15px; 39 | } 40 | 41 | h3 { 42 | font-size: 1.5em; 43 | line-height: 1; 44 | margin-bottom: .5em; 45 | padding-bottom: .5em; 46 | } 47 | 48 | h4 { 49 | font-size: 1.2em; 50 | line-height: 1.25; 51 | margin-bottom: 1.25em; 52 | } 53 | 54 | p { 55 | margin: 0 0 1.5em; 56 | max-width: 580px; 57 | } 58 | 59 | body { 60 | font-family: Helvetica, Arial, 'Liberation Sans', FreeSans, sans-serif; 61 | } 62 | 63 | 64 | .badge { 65 | padding: 0px 7px 0px; 66 | font-size: 12.025px; 67 | font-weight: bold; 68 | white-space: nowrap; 69 | color: white; 70 | background-color: #999; 71 | -webkit-border-radius: 9px; 72 | -moz-border-radius: 9px; 73 | border-radius: 9px; 74 | 75 | margin-right: 5px; 76 | 77 | } 78 | .badge-success { 79 | background-color: #5BB75B; 80 | background-image: -moz-linear-gradient(top, #62C462, #51A351); 81 | background-image: -ms-linear-gradient(top, #62C462, #51A351); 82 | background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#62C462), to(#51A351)); 83 | background-image: -webkit-linear-gradient(top, #62C462, #51A351); 84 | background-image: -o-linear-gradient(top, #62C462, #51A351); 85 | background-image: linear-gradient(top, #62C462, #51A351); 86 | background-repeat: repeat-x; 87 | } 88 | .badge-error { 89 | background-color: #B94A48; 90 | } 91 | 92 | #header { 93 | width: 200px; 94 | left: 10px; 95 | top: 0px; 96 | bottom: 1px; 97 | border-right: 1px solid #E2E2E2; 98 | background-color: white; 99 | z-index: 9999; 100 | 101 | position: fixed; 102 | border-bottom: 0px; 103 | bottom: 0px; 104 | } 105 | 106 | #content { 107 | position: absolute; 108 | top: 0px; 109 | right: 0px; 110 | left: 210px; 111 | } 112 | 113 | #content .section { 114 | display: block; 115 | border-bottom: 1px solid #E2E2E2; 116 | padding: 30px; 117 | padding-left: 50px; 118 | } 119 | 120 | #content .section ul { 121 | list-style-type: disc; 122 | } 123 | 124 | #content .section ul { 125 | margin: 0 1.5em 1.5em 0; 126 | padding-left: 1.5em; 127 | } 128 | 129 | #header .section { 130 | display: block; 131 | padding-top: 30px; 132 | } 133 | 134 | a, a:link, a:visited, a:active { 135 | color: black; 136 | text-decoration: none; 137 | border-bottom: 1px solid #CCC; 138 | } 139 | 140 | /* Info widget */ 141 | 142 | #info { 143 | position: absolute; 144 | text-align: center; 145 | width: 100%; 146 | z-index: 99; 147 | top: 0; 148 | } 149 | 150 | #info > p { 151 | display: inline-block; 152 | padding: 0 12px; 153 | line-height: 21px; 154 | border: 1px solid #A69C6D; 155 | border-top: 0; 156 | border-bottom-color: #8C845C; 157 | border-radius: 0 0 3px 3px; 158 | background: #FFF1A8; 159 | box-shadow: inset 0 1px #fff7cf,0 1px rgba(0,0,0,0.10); 160 | } 161 | 162 | /* Presence widget */ 163 | 164 | #online { 165 | padding-top: 40px; 166 | } 167 | 168 | #online ul { 169 | list-style-type: none; 170 | } 171 | 172 | #send { 173 | font-size: 24px; 174 | font-family: inherit; 175 | line-height: 1.4em; 176 | border: 0; 177 | outline: none; 178 | padding: 6px; 179 | border: 1px solid #999; 180 | } 181 | -------------------------------------------------------------------------------- /sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 54 | 55 | 56 |
57 | 58 | 70 | 71 |
72 | 73 |
74 |

Messages

75 |
76 | 77 | 83 |
84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /sample/server.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var http = require('http') 3 | 4 | var Minilog = require('minilog') 5 | var Radar = require('../index.js').server 6 | var Router = require('../api/lib/router.js') 7 | 8 | var server = http.createServer(function (req, res) { 9 | console.log('404', req.url) 10 | res.statusCode = 404 11 | res.end() 12 | }) 13 | 14 | var routes = new Router() 15 | 16 | routes.get(new RegExp('^/$'), function (req, res) { 17 | res.end(fs.readFileSync('./index.html').toString().replace('%user_id%', Math.floor(Math.random() * 100000))) 18 | }) 19 | 20 | routes.get(new RegExp('^/user/(.*)$'), function (req, res, re) { 21 | res.end(fs.readFileSync('./index.html').toString().replace('%user_id%', re[1])) 22 | }) 23 | 24 | routes.get(new RegExp('^/minilog.js$'), function (req, res) { 25 | res.setHeader('content-type', 'text/javascript') 26 | res.end(fs.readFileSync('../node_modules/minilog/dist/minilog.js')) 27 | }) 28 | 29 | routes.get(new RegExp('^/radar_client.js$'), function (req, res) { 30 | res.setHeader('content-type', 'text/javascript') 31 | res.end(fs.readFileSync('../node_modules/radar_client/dist/radar_client.js')) 32 | }) 33 | 34 | routes.get(new RegExp('^/engine.io.js$'), function (req, res) { 35 | res.setHeader('content-type', 'text/javascript') 36 | res.end(fs.readFileSync('./engine.io.js')) 37 | }) 38 | 39 | routes.get(new RegExp('^/css/style.css$'), function (req, res) { 40 | res.end(fs.readFileSync('./css/style.css')) 41 | }) 42 | 43 | routes.get(new RegExp('^/views.js$'), function (req, res) { 44 | res.setHeader('content-type', 'text/javascript') 45 | res.end(fs.readFileSync('./views.js')) 46 | }) 47 | 48 | Minilog.pipe(Minilog.backends.nodeConsole.formatWithStack) 49 | .pipe(Minilog.backends.nodeConsole) 50 | 51 | routes.attach(server) 52 | new Radar().attach(server, require('../configuration.js')) 53 | 54 | server.listen(8080) 55 | 56 | console.log('Server listening on localhost:8080') 57 | -------------------------------------------------------------------------------- /sample/views.js: -------------------------------------------------------------------------------- 1 | /* globals RadarClient */ 2 | function Status (elId) { 3 | this.elId = elId 4 | this.value = '' 5 | } 6 | 7 | Status.prototype.render = function (value) { 8 | value && (this.value = value) 9 | this.el || (this.el = document.getElementById(this.elId)) 10 | this.el.innerHTML = '

' + this.value + '

' 11 | } 12 | 13 | function OnlineList (elId) { 14 | this.elId = elId 15 | } 16 | 17 | OnlineList.prototype.render = function () { 18 | this.el || (this.el = document.getElementById(this.elId)) 19 | var str = '' 20 | for (var userId in model.online) { 21 | if (!model.online.hasOwnProperty(userId)) continue 22 | str += '
  • ' + userId + '
  • ' 27 | } 28 | this.el.innerHTML = '' 29 | } 30 | 31 | function OnlineToggle (elId) { 32 | this.elId = elId 33 | } 34 | 35 | OnlineToggle.prototype.render = function () { 36 | this.el || (this.el = document.getElementById(this.elId)) 37 | var status = (model.online[RadarClient.configuration('userId')] ? 'offline' : 'online') 38 | this.el.innerHTML = 'Go ' + status + '' 39 | } 40 | 41 | function MessageList (elId) { 42 | this.elId = elId 43 | } 44 | 45 | MessageList.prototype.render = function () { 46 | this.el || (this.el = document.getElementById(this.elId)) 47 | var str = '' 48 | model.messages.forEach(function (message) { 49 | str += '
  • ' + message.value + '
  • ' 50 | }) 51 | this.el.innerHTML = '' 52 | } 53 | 54 | var model = { 55 | online: {}, 56 | messages: [] 57 | } 58 | 59 | // function onlineUpdate (message) { 60 | // if (message.op !== 'online' && message.op !== 'offline') return 61 | // for (var userId in message.value) { 62 | // if (!message.value.hasOwnProperty(userId)) continue 63 | // model.online[userId] = !!(message.op === 'online') 64 | // } 65 | // } 66 | 67 | // var view = { 68 | // status: new Status('info'), 69 | // online: new OnlineList('online'), 70 | // toggle: new OnlineToggle('toggle'), 71 | // messages: new MessageList('messages') 72 | // } 73 | 74 | // function redraw () { 75 | // Object.keys(view).forEach(function (name) { 76 | // var view = window.view[name] 77 | // view.render() 78 | // }) 79 | // } 80 | -------------------------------------------------------------------------------- /src/client/client_session.js: -------------------------------------------------------------------------------- 1 | const log = require('minilog')('radar:client') 2 | const { EventEmitter } = require('events') 3 | const { inherits } = require('util') 4 | const ClientSessionStateMachine = require('./client_session_state_machine') 5 | 6 | // TODO: move to a SessionManager class 7 | const clientsById = {} 8 | 9 | function ClientSession (name, id, accountName, version, transport) { 10 | this.state = ClientSessionStateMachine.create(this) 11 | 12 | this.createdAt = Date.now() 13 | this.lastModified = Date.now() 14 | this.name = name 15 | this.accountName = accountName 16 | this.id = id 17 | this.subscriptions = {} 18 | this.presences = {} 19 | this.version = version || '0.0.0' 20 | EventEmitter.call(this) 21 | 22 | this.transport = transport 23 | this._setupTransport() 24 | clientsById[this.id] = this 25 | } 26 | 27 | inherits(ClientSession, EventEmitter) 28 | 29 | ClientSession.prototype._initialize = function (set) { 30 | const self = this 31 | 32 | if (set) { 33 | Object.keys(set).forEach(function (key) { 34 | self[key] = set[key] 35 | }) 36 | } 37 | 38 | if (this.state.can('initialize')) { 39 | this.state.initialize() 40 | } 41 | this.lastModified = Date.now() 42 | } 43 | 44 | ClientSession.prototype._cleanup = function () { 45 | if (this._cleanupTransport) { 46 | this._cleanupTransport() 47 | } 48 | 49 | delete clientsById[this.id] 50 | } 51 | 52 | // Instance methods 53 | 54 | // ClientSession Message API: 55 | // 56 | // Incoming messages: 57 | // clientSession.on('message', messageHandler) 58 | // Outcoming messages: 59 | // clientSession.send(message) 60 | 61 | ClientSession.prototype.send = function (message) { 62 | const data = JSON.stringify(message) 63 | log.info('#socket.message.outgoing', this.id, data) 64 | if (this.state.state === 'ended') { 65 | log.warn('Cannot send message after ClientSession ended', this.id, data) 66 | return 67 | } 68 | 69 | this.transport.send(data) 70 | } 71 | 72 | // Persist subscriptions and presences when not already persisted in memory 73 | ClientSession.prototype.storeData = function (messageIn) { 74 | let processedOp = false 75 | 76 | // Persist the message data, according to type 77 | switch (messageIn.op) { 78 | case 'unsubscribe': 79 | case 'sync': 80 | case 'subscribe': 81 | processedOp = this._storeDataSubscriptions(messageIn) 82 | break 83 | 84 | case 'set': 85 | processedOp = this._storeDataPresences(messageIn) 86 | break 87 | } 88 | 89 | // FIXME: For now log everything Later, enable sample logging 90 | if (processedOp) { 91 | this._logState() 92 | } 93 | 94 | return true 95 | } 96 | 97 | ClientSession.prototype.readData = function (cb) { 98 | const data = { subscriptions: this.subscriptions, presences: this.presences } 99 | 100 | if (cb) { 101 | cb(data) 102 | } else { 103 | return data 104 | } 105 | } 106 | 107 | // Private methods 108 | 109 | ClientSession.prototype._setupTransport = function () { 110 | const self = this 111 | 112 | if (!this.transport || !this.transport.on) { 113 | return 114 | } 115 | 116 | this.transport.on('message', emitClientMessage) 117 | function emitClientMessage (message) { 118 | const decoded = self._decodeIncomingMessage(message) 119 | if (!decoded) { 120 | log.warn('#socket.message.incoming.decode - could not decode') 121 | return 122 | } 123 | log.info('#socket.message.incoming', self.id, decoded) 124 | 125 | switch (self.state.state) { 126 | case 'initializing': 127 | case 'ready': 128 | self._initializeOnNameSync(decoded) 129 | self.emit('message', decoded) 130 | break 131 | } 132 | } 133 | 134 | this.transport.once('close', function () { 135 | log.info('#socket - disconnect', self.id) 136 | self.state.end() 137 | }) 138 | 139 | this._cleanupTransport = function () { 140 | self.transport.removeListener('message', emitClientMessage) 141 | delete self.transport 142 | } 143 | } 144 | 145 | ClientSession.prototype._initializeOnNameSync = function (message) { 146 | if (message.op !== 'nameSync') { return } 147 | 148 | log.info('#socket.message - nameSync', message, this.id) 149 | 150 | this.send({ op: 'ack', value: message && message.ack }) 151 | 152 | const association = message.options.association 153 | 154 | log.info('create: association name: ' + association.name + 155 | '; association id: ' + association.id) 156 | 157 | // (name, id, accountName, version, transport) 158 | 159 | this._initialize({ 160 | name: association.name, 161 | accountName: message.accountName, 162 | clientVersion: message.options.clientVersion 163 | }) 164 | } 165 | 166 | ClientSession.prototype._decodeIncomingMessage = function (message) { 167 | let decoded 168 | try { 169 | decoded = JSON.parse(message) 170 | } catch (e) { 171 | log.warn('#clientSession.message - json parse error', e) 172 | return 173 | } 174 | 175 | // Format check 176 | if (!decoded || !decoded.op) { 177 | log.warn('#socket.message - rejected', this.id, decoded) 178 | return 179 | } 180 | 181 | return decoded 182 | } 183 | 184 | ClientSession.prototype._logState = function () { 185 | const subCount = Object.keys(this.subscriptions).length 186 | const presCount = Object.keys(this.presences).length 187 | 188 | log.info('#storeData', { 189 | client_id: this.id, 190 | subscription_count: subCount, 191 | presence_count: presCount 192 | }) 193 | } 194 | 195 | ClientSession.prototype._storeDataSubscriptions = function (messageIn) { 196 | const message = _cloneForStorage(messageIn) 197 | const to = message.to 198 | let existingSubscription 199 | 200 | // Persist the message data, according to type 201 | switch (message.op) { 202 | case 'unsubscribe': 203 | if (this.subscriptions[to]) { 204 | delete this.subscriptions[to] 205 | return true 206 | } 207 | break 208 | 209 | case 'sync': 210 | case 'subscribe': 211 | existingSubscription = this.subscriptions[to] 212 | if (!existingSubscription || (existingSubscription.op !== 'sync' && message.op === 'sync')) { 213 | this.subscriptions[to] = message 214 | return true 215 | } 216 | } 217 | 218 | return false 219 | } 220 | 221 | ClientSession.prototype._storeDataPresences = function (messageIn) { 222 | const message = _cloneForStorage(messageIn) 223 | const to = message.to 224 | let existingPresence 225 | 226 | // Persist the message data, according to type 227 | if (message.op === 'set' && to.substr(0, 'presence:/'.length) === 'presence:/') { 228 | existingPresence = this.presences[to] 229 | 230 | // Should go offline 231 | if (existingPresence && messageIn.value === 'offline') { 232 | delete this.presences[to] 233 | return true 234 | } else if (!existingPresence && message.value !== 'offline') { 235 | this.presences[to] = message 236 | return true 237 | } 238 | } 239 | 240 | return false 241 | } 242 | 243 | // Private functions 244 | // TODO: move to util module 245 | function _cloneForStorage (messageIn) { 246 | const message = {} 247 | 248 | message.to = messageIn.to 249 | message.op = messageIn.op 250 | 251 | return message 252 | } 253 | 254 | // (String) => ClientSession 255 | function getClientSessionBySocketId (id) { 256 | return clientsById[id] 257 | } 258 | 259 | module.exports = ClientSession 260 | module.exports.get = getClientSessionBySocketId 261 | -------------------------------------------------------------------------------- /src/client/client_session_state_machine.js: -------------------------------------------------------------------------------- 1 | const StateMachine = require('javascript-state-machine') 2 | 3 | module.exports.create = function createClientSessionStateMachine (clientSession) { 4 | return new StateMachine({ 5 | init: 'initializing', 6 | transitions: [ 7 | { name: 'initialize', from: 'initializing', to: 'ready' }, 8 | { name: 'leave', from: 'ready', to: 'not ready' }, 9 | { name: 'comeback', from: 'not ready', to: 'ready' }, 10 | { name: 'end', from: ['initializing', 'ready', 'not ready'], to: 'ended' } 11 | ], 12 | methods: { 13 | onInitialize: function () { 14 | clientSession.emit('initialize') 15 | }, 16 | onEnd: function () { 17 | clientSession._cleanup() 18 | clientSession.emit('end') 19 | } 20 | } 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/client/socket_client_session_adapter.js: -------------------------------------------------------------------------------- 1 | function SocketClientSessionAdapter (clientSessionCtor) { 2 | this.ClientSession = clientSessionCtor 3 | } 4 | 5 | SocketClientSessionAdapter.prototype.adapt = function (socket) { 6 | const clientSession = new this.ClientSession(undefined, socket.id, undefined, undefined, socket) 7 | return clientSession 8 | } 9 | 10 | // (Any) => Boolean 11 | SocketClientSessionAdapter.prototype.canAdapt = function (socket) { 12 | return Boolean(socket && 13 | socket.id && 14 | typeof socket.send === 'function' && 15 | isEventEmitter(socket)) 16 | } 17 | 18 | function isEventEmitter (o) { 19 | return typeof o.on === 'function' && 20 | typeof o.once === 'function' && 21 | typeof o.removeListener === 'function' 22 | } 23 | 24 | module.exports = SocketClientSessionAdapter 25 | -------------------------------------------------------------------------------- /src/core/id.js: -------------------------------------------------------------------------------- 1 | // unique string ids are used in many places in radar 2 | // this module should be used to generating them, to 3 | // ensure consistency 4 | 5 | const { v4: defaultGenerator } = require('uuid') 6 | let generator = defaultGenerator 7 | 8 | // () => String 9 | function generateUniqueId () { 10 | return generator() 11 | } 12 | 13 | function setGenerator (fn) { 14 | generator = fn 15 | } 16 | 17 | module.exports = generateUniqueId 18 | module.exports.setGenerator = setGenerator 19 | Object.defineProperty(module.exports, 'defaultGenerator', { 20 | get: function () { 21 | return defaultGenerator 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | const Resource = require('./resources/resource') 2 | const MessageList = require('./resources/message_list') 3 | const Presence = require('./resources/presence') 4 | const Status = require('./resources/status') 5 | const Stream = require('./resources/stream') 6 | 7 | module.exports = { 8 | Persistence: require('persistence'), 9 | Type: require('./type.js'), 10 | PresenceManager: require('./resources/presence/presence_manager.js'), 11 | 12 | Resource: Resource, 13 | MessageList: MessageList, 14 | Presence: Presence, 15 | Status: Status, 16 | Resources: { 17 | MessageList: MessageList, 18 | Presence: Presence, 19 | Status: Status, 20 | Stream: Stream 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/core/resources/message_list/index.js: -------------------------------------------------------------------------------- 1 | const Resource = require('../resource.js') 2 | let Persistence = require('persistence') 3 | const logger = require('minilog')('radar:message_list') 4 | 5 | const defaultOptions = { 6 | policy: { 7 | maxPersistence: 14 * 24 * 60 * 60 // 2 weeks in seconds 8 | } 9 | } 10 | 11 | // Every time we use this channel, we prolong expiry to maxPersistence 12 | // This includes even the final unsubscribe, in case a client decides to rejoin 13 | // after being the last user. 14 | 15 | function MessageList (to, server, options) { 16 | Resource.call(this, to, server, options, defaultOptions) 17 | } 18 | 19 | MessageList.prototype = new Resource() 20 | MessageList.prototype.type = 'message' 21 | 22 | // Publish to Redis 23 | MessageList.prototype.publish = function (clientSession, message) { 24 | const self = this 25 | 26 | logger.debug('#message_list - publish', this.to, message, clientSession && clientSession.id) 27 | 28 | this._publish(this.to, this.options.policy, message, function () { 29 | self.ack(clientSession, message.ack) 30 | }) 31 | } 32 | 33 | MessageList.prototype._publish = function (to, policy, message, callback) { 34 | if (policy && policy.cache) { 35 | Persistence.persistOrdered(to, message) 36 | if (policy.maxPersistence) { 37 | Persistence.expire(to, policy.maxPersistence) 38 | } 39 | } 40 | Persistence.publish(to, message, callback) 41 | } 42 | 43 | MessageList.prototype.sync = function (clientSession, message) { 44 | const to = this.to 45 | 46 | logger.debug('#message_list - sync', this.to, message, clientSession && clientSession.id) 47 | 48 | this._sync(to, this.options.policy, function (replies) { 49 | clientSession.send({ 50 | op: 'sync', 51 | to: to, 52 | value: replies, 53 | time: Date.now() 54 | }) 55 | }) 56 | 57 | this.subscribe(clientSession, message) 58 | } 59 | 60 | MessageList.prototype._sync = function (to, policy, callback) { 61 | if (policy && policy.maxPersistence) { 62 | Persistence.expire(to, policy.maxPersistence) 63 | } 64 | 65 | Persistence.readOrderedWithScores(to, policy, callback) 66 | } 67 | 68 | MessageList.prototype.unsubscribe = function (clientSession, message) { 69 | Resource.prototype.unsubscribe.call(this, clientSession, message) 70 | // Note that since this is not synchronized across multiple backend servers, 71 | // it is possible for a channel that is subscribed elsewhere to have a TTL set 72 | // on it again. The assumption is that the TTL is so long that any normal 73 | // workflow will terminate before it is triggered. 74 | if (this.options && this.options.policy && 75 | this.options.policy.cache && 76 | this.options.policy.maxPersistence) { 77 | Persistence.expire(this.to, this.options.policy.maxPersistence) 78 | } 79 | } 80 | 81 | MessageList.setBackend = function (backend) { 82 | Persistence = backend 83 | } 84 | 85 | module.exports = MessageList 86 | -------------------------------------------------------------------------------- /src/core/resources/presence/index.js: -------------------------------------------------------------------------------- 1 | const Resource = require('../resource.js') 2 | const PresenceManager = require('./presence_manager.js') 3 | const Stamper = require('../../stamper.js') 4 | const logging = require('minilog')('radar:presence') 5 | 6 | const defaultOptions = { 7 | policy: { 8 | // 12 hours in seconds 9 | maxPersistence: 12 * 60 * 60, 10 | 11 | // Buffer time for a user to timeout after client disconnects (implicit) 12 | userExpirySeconds: 15 13 | } 14 | } 15 | 16 | function Presence (to, server, options) { 17 | this.sentry = server.sentry 18 | Resource.call(this, to, server, options, defaultOptions) 19 | this.setup() 20 | } 21 | 22 | Presence.prototype = new Resource() 23 | Presence.prototype.type = 'presence' 24 | 25 | Presence.prototype.setup = function () { 26 | const self = this 27 | 28 | this.manager = new PresenceManager(this.to, this.options.policy, this.sentry) 29 | 30 | this.manager.on('user_online', function (userId, userType, userData) { 31 | logging.info('#presence - user_online', userId, userType, self.to) 32 | const value = {} 33 | value[userId] = userType 34 | self.broadcast({ 35 | to: self.to, 36 | op: 'online', 37 | value: value, 38 | userData: userData 39 | }) 40 | }) 41 | 42 | this.manager.on('user_offline', function (userId, userType) { 43 | logging.info('#presence - user_offline', userId, userType, self.to) 44 | const value = {} 45 | value[userId] = userType 46 | self.broadcast({ 47 | to: self.to, 48 | op: 'offline', 49 | value: value 50 | }) 51 | }) 52 | 53 | this.manager.on('client_online', function (clientSessionId, userId, userType, userData, clientData) { 54 | logging.info('#presence - client_online', clientSessionId, userId, self.to, userData, clientData) 55 | self.broadcast({ 56 | to: self.to, 57 | op: 'client_online', 58 | value: { 59 | userId: userId, 60 | clientId: clientSessionId, 61 | userData: userData, 62 | clientData: clientData 63 | } 64 | }) 65 | }) 66 | 67 | this.manager.on('client_updated', function (clientSessionId, userId, userType, userData, clientData) { 68 | logging.info('#presence - client_updated', clientSessionId, userId, self.to, userData, clientData) 69 | self.broadcast({ 70 | to: self.to, 71 | op: 'client_updated', 72 | value: { 73 | userId: userId, 74 | clientId: clientSessionId, 75 | userData: userData, 76 | clientData: clientData 77 | } 78 | }) 79 | }) 80 | 81 | this.manager.on('client_offline', function (clientSessionId, userId, explicit) { 82 | logging.info('#presence - client_offline', clientSessionId, userId, explicit, self.to) 83 | self.broadcast({ 84 | to: self.to, 85 | op: 'client_offline', 86 | explicit: !!explicit, 87 | value: { 88 | userId: userId, 89 | clientId: clientSessionId 90 | } 91 | }, clientSessionId) 92 | }) 93 | } 94 | 95 | Presence.prototype.redisIn = function (message) { 96 | logging.info('#presence - incoming from #redis', this.to, message, 'subs:', 97 | Object.keys(this.subscribers).length) 98 | this.manager.processRedisEntry(message) 99 | } 100 | 101 | Presence.prototype.set = function (clientSession, message) { 102 | if (message.value !== 'offline') { 103 | this._setOnline(clientSession, message) 104 | } else { 105 | this._setOffline(clientSession, message) 106 | } 107 | } 108 | 109 | Presence.prototype._setOnline = function (clientSession, message) { 110 | const presence = this 111 | const userId = message.key 112 | 113 | function ackCheck () { 114 | presence.ack(clientSession, message.ack) 115 | } 116 | this.manager.addClient(clientSession.id, userId, 117 | message.type, 118 | message.userData, 119 | message.clientData, 120 | ackCheck) 121 | 122 | if (!this.subscribers[clientSession.id]) { 123 | // We use subscribe/unsubscribe to trap the "close" event, so subscribe now 124 | this.subscribe(clientSession) 125 | 126 | // We are subscribed, but not listening 127 | this.subscribers[clientSession.id] = { listening: false } 128 | } 129 | } 130 | 131 | Presence.prototype._setOffline = function (clientSession, message) { 132 | const presence = this 133 | const userId = message.key 134 | 135 | function ackCheck () { 136 | presence.ack(clientSession, message.ack) 137 | } 138 | 139 | // If this is client is not subscribed 140 | if (!this.subscribers[clientSession.id]) { 141 | // This is possible if a client does .set('offline') without 142 | // set-online/sync/subscribe 143 | Resource.prototype.unsubscribe.call(this, clientSession, message) 144 | } else { 145 | // Remove from local 146 | this.manager.removeClient(clientSession.id, userId, message.type, ackCheck) 147 | } 148 | } 149 | 150 | Presence.prototype.subscribe = function (clientSession, message) { 151 | Resource.prototype.subscribe.call(this, clientSession, message) 152 | this.subscribers[clientSession.id] = { listening: true } 153 | } 154 | 155 | Presence.prototype.unsubscribe = function (clientSession, message) { 156 | logging.info('#presence - implicit disconnect', clientSession.id, this.to) 157 | this.manager.disconnectClient(clientSession.id) 158 | 159 | Resource.prototype.unsubscribe.call(this, clientSession, message) 160 | } 161 | 162 | Presence.prototype.sync = function (clientSession, message) { 163 | const self = this 164 | this.fullRead(function (online) { 165 | if (message.options && parseInt(message.options.version, 10) === 2) { 166 | const value = self.manager.getClientsOnline() 167 | logging.info('#presence - sync', value) 168 | clientSession.send({ 169 | op: 'get', 170 | to: self.to, 171 | value: value 172 | }) 173 | } else { 174 | logging.warn('presence v1 received, sending online', self.to, clientSession.id) 175 | 176 | // Will deprecate when syncs no longer need to use "online" to look like 177 | // regular messages 178 | clientSession.send({ 179 | op: 'online', 180 | to: self.to, 181 | value: online 182 | }) 183 | } 184 | }) 185 | this.subscribe(clientSession, message) 186 | } 187 | 188 | // This is a full sync of the online status from Redis 189 | Presence.prototype.get = function (clientSession, message) { 190 | const self = this 191 | this.fullRead(function (online) { 192 | let value 193 | 194 | if (message.options && message.options.version === 2) { 195 | // pob 196 | value = self.manager.getClientsOnline() 197 | logging.info('#presence - get', value) 198 | } else { 199 | value = online 200 | } 201 | 202 | clientSession.send({ 203 | op: 'get', 204 | to: self.to, 205 | value: value 206 | }) 207 | }) 208 | } 209 | 210 | Presence.prototype.broadcast = function (message, except) { 211 | const self = this 212 | 213 | Stamper.stamp(message) 214 | 215 | this.emit('message:outgoing', message) 216 | 217 | logging.debug('#presence - update subscribed clients', message, except, this.to) 218 | 219 | Object.keys(this.subscribers).forEach(function (clientSessionId) { 220 | const clientSession = self.getClientSession(clientSessionId) 221 | if (clientSession && clientSessionId !== except && self.subscribers[clientSessionId].listening) { 222 | message.stamp.clientId = clientSessionId 223 | clientSession.send(message) 224 | } else { 225 | logging.warn('#clientSession - not sending: ', clientSessionId, message, except, 226 | 'explicit:', self.subscribers[clientSessionId], self.to) 227 | } 228 | }) 229 | } 230 | 231 | Presence.prototype.fullRead = function (callback) { 232 | this.manager.fullRead(callback) 233 | } 234 | 235 | Presence.prototype.destroy = function () { 236 | this.manager.destroy() 237 | Resource.prototype.destroy.call(this) 238 | } 239 | 240 | Presence.setBackend = function (backend) { 241 | PresenceManager.setBackend(backend) 242 | } 243 | 244 | module.exports = Presence 245 | -------------------------------------------------------------------------------- /src/core/resources/presence/presence_store.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const logging = require('minilog')('radar:presence_store') 3 | 4 | function PresenceStore (scope) { 5 | this.scope = scope 6 | this.map = {} 7 | this.cache = {} 8 | this.socketUserMap = {} 9 | this.userTypes = {} 10 | } 11 | 12 | require('util').inherits(PresenceStore, require('events').EventEmitter) 13 | 14 | // Cache the client data without adding 15 | PresenceStore.prototype.cacheAdd = function (clientSessionId, message) { 16 | this.cache[clientSessionId] = message 17 | } 18 | 19 | PresenceStore.prototype.cacheRemove = function (clientSessionId) { 20 | const val = this.cache[clientSessionId] 21 | delete this.cache[clientSessionId] 22 | return val 23 | } 24 | 25 | PresenceStore.prototype.add = function (clientSessionId, userId, userType, message) { 26 | const self = this 27 | const events = [] 28 | 29 | logging.debug('#presence - store.add', userId, clientSessionId, message, this.scope) 30 | this.cacheRemove(clientSessionId) 31 | 32 | if (!this.map[userId]) { 33 | events.push('user_added') 34 | this.map[userId] = {} 35 | this.userTypes[userId] = userType 36 | } 37 | 38 | if (!this.map[userId][clientSessionId]) { 39 | events.push('client_added') 40 | this.map[userId][clientSessionId] = message 41 | this.socketUserMap[clientSessionId] = userId 42 | } else { 43 | const previous = this.map[userId][clientSessionId] 44 | if (message.clientData && !_.isEqual(message.clientData, previous.clientData)) { 45 | events.push('client_updated') 46 | this.map[userId][clientSessionId] = message 47 | } 48 | } 49 | 50 | events.forEach(function (event) { 51 | logging.debug('#presence - store.emit', event, message, self.scope) 52 | 53 | self.emit(event, message) 54 | }) 55 | } 56 | 57 | PresenceStore.prototype.remove = function (clientSessionId, userId, message) { 58 | const self = this 59 | const events = [] 60 | 61 | logging.debug('#presence - store.remove', userId, clientSessionId, message, this.scope) 62 | 63 | this.cacheRemove(clientSessionId) 64 | 65 | // When non-existent, return 66 | if (!this.map[userId] || !this.map[userId][clientSessionId]) { 67 | return 68 | } 69 | 70 | events.push('client_removed') 71 | delete this.map[userId][clientSessionId] 72 | delete this.socketUserMap[clientSessionId] 73 | 74 | // Empty user 75 | if (Object.keys(this.map[userId]).length === 0) { 76 | events.push('user_removed') 77 | delete this.map[userId] 78 | delete this.userTypes[userId] 79 | } 80 | 81 | events.forEach(function (ev) { 82 | logging.debug('#presence - store.emit', ev, message, self.scope) 83 | self.emit(ev, message) 84 | }) 85 | } 86 | 87 | PresenceStore.prototype.removeClient = function (clientSessionId, message) { 88 | const userId = this.socketUserMap[clientSessionId] 89 | this.cacheRemove(clientSessionId) 90 | 91 | // When non-existent, return 92 | if (!userId) { 93 | logging.warn('#presence - store.removeClient: cannot find data for', 94 | clientSessionId, this.scope) 95 | return 96 | } 97 | 98 | logging.debug('#presence - store.removeClient', userId, clientSessionId, message, this.scope) 99 | delete this.map[userId][clientSessionId] 100 | delete this.socketUserMap[clientSessionId] 101 | 102 | logging.debug('#presence - store.emit', 'client_removed', message, this.scope) 103 | this.emit('client_removed', message) 104 | } 105 | 106 | PresenceStore.prototype.removeUserIfEmpty = function (userId, message) { 107 | if (this.userExists(userId) && this.userEmpty(userId)) { 108 | logging.debug('#presence - store.removeUserIfEmpty', userId, message, this.scope) 109 | delete this.map[userId] 110 | delete this.userTypes[userId] 111 | logging.debug('#presence - store.emit', 'user_removed', message, this.scope) 112 | this.emit('user_removed', message) 113 | } 114 | } 115 | 116 | PresenceStore.prototype.userOf = function (clientSessionId) { 117 | return this.socketUserMap[clientSessionId] 118 | } 119 | 120 | PresenceStore.prototype.get = function (clientSessionId, userId) { 121 | return (this.map[userId] && this.map[userId][clientSessionId]) 122 | } 123 | 124 | PresenceStore.prototype.users = function () { 125 | return Object.keys(this.map) 126 | } 127 | 128 | PresenceStore.prototype.sockets = function (userId) { 129 | return ((this.map[userId] && Object.keys(this.map[userId])) || []) 130 | } 131 | 132 | PresenceStore.prototype.forEachClient = function (callback) { 133 | const store = this 134 | this.users().forEach(function (userId) { 135 | store.sockets(userId).forEach(function (clientSessionId) { 136 | if (callback) callback(userId, clientSessionId, store.get(clientSessionId, userId)) 137 | }) 138 | }) 139 | } 140 | 141 | PresenceStore.prototype.userEmpty = function (userId) { 142 | return !!(this.map[userId] && Object.keys(this.map[userId]).length === 0) 143 | } 144 | 145 | PresenceStore.prototype.userTypeOf = function (userId) { 146 | return this.userTypes[userId] 147 | } 148 | 149 | PresenceStore.prototype.userExists = function (userId) { 150 | return !!this.map[userId] 151 | } 152 | 153 | // This returns a list of clientSessionIds, which is not costly. The code that calls 154 | // this code uses each clientSessionId in a separate chained call, the sum of which is 155 | // costly. 156 | PresenceStore.prototype.clientSessionIdsForSentryId = function (sentryId) { 157 | const map = this.map 158 | const clientSessionIds = [] 159 | Object.keys(map).forEach(function (userId) { 160 | Object.keys(map[userId]).forEach(function (clientSessionId) { 161 | const data = map[userId][clientSessionId] 162 | if (data && data.sentry === sentryId) { 163 | clientSessionIds.push(clientSessionId) 164 | } 165 | }) 166 | }) 167 | 168 | return clientSessionIds 169 | } 170 | 171 | module.exports = PresenceStore 172 | -------------------------------------------------------------------------------- /src/core/resources/presence/readme.md: -------------------------------------------------------------------------------- 1 | How Presence Works 2 | ================= 3 | 4 | Presence is split into four separate parts: the presence object, the presence manager, the presence store datastructure and the sentry system. 5 | 6 | Presence APIs 7 | ============ 8 | 9 | .set('online'): 10 | - This works by subscribing to the resource and adding the client/user to the manager. We subscribe to the resource here because client disconnection works through unsubcribe. We add an resource.unsubscribe to the 'close' event, and the close event fires during a n/w disconnect on the socket. 11 | 12 | .set('offline'): 13 | - Here we try to remove the client/user explicitly from the manager. We also unsubscribe from the resource if we never subscribed to it. 14 | 15 | .unsubscribe(): 16 | - When engine.io socket is broken, a 'close' event fires. We listen to the close event and invoke a resource's unsubscribe method in response. A client can send an unsubscribe message which can invoke this too. This tries to remove the client implicitly from the manager (so it can setup the user for a buffered expiry if needed). This is followed by a resource unsubscribe as well. 17 | 18 | .get/sync(): 19 | - These work by reloading everything from redis which synchronizes the presence store with what is in redis. Then, we ask the manager for a full report of users and clients and this is sent as the response. 20 | 21 | Presence Store 22 | ============= 23 | 24 | .add() 25 | - This adds a client/user/userType to the system. Events like user_added and client_added are thrown if they are entries. 26 | 27 | .remove() 28 | - This removes a client/user/userType from the store. Events like user_removed and client_removed are emitted if they are actually removed. 29 | 30 | .removeClient() 31 | - This only removes a clientId and prevents removal of an empty user if it is the last client. This will be used when we do implicit disconnections for a client. Please refer to Presence manager: .processRedisEntry() section on when this is invoked. 32 | 33 | Note that all these APIs ignore redundant operations and only emit events if there are actual changes to store state. So adding the same client twice does not emit client_added. Similarly, removing an non-existant client or a user will not cause any events. 34 | 35 | Presence manager 36 | ================ 37 | 38 | The manager forwards events from the presence store to the main presence object which then broadcasts them to interested clients. 39 | 40 | All messages received from the clients (through the main presence object), are published to redis. The message then reflects back and is correctly handled. Due to this, there is no difference between a local message and a remote message. Both are handled exactly the same way. 41 | 42 | .addClient() 43 | - Publish and persist a client/user addition to redis. 44 | 45 | .removeClient() 46 | - Publish and persist a client/user removal to redis. The message is marked as an explicit offline. (set(offline)) 47 | 48 | .disconnectClient() 49 | - Publish and persist a client removal to redis. This time, the message is marked explicit:false. 50 | 51 | .processRedisEntry() 52 | - This is the handler for all incoming messages from redis. Our own messages reflecting back are also handled here. Here, we analyse the message and appropriately add them to the store or remove them. If we are removing client/users, we infer the nature of disconnection (explicit or not). If it is an ungraceful disconnect (explicit:false), we only remove the client and setup an user expiry timeout which removes the user after a small period of time. The user is removed only if it is completely empty (zero clients for the user in the store). If an online message arrives within the time window, this timeout is cleared. 53 | 54 | Sentry system 55 | ============ 56 | 57 | The sentry system is designed to watch the liveness of other radar server instances connected to the same redis. Presence not only has to worry about clients disconnecting unexpectedly, but also servers themselves disappearing with all their clients along with it. Each radar server runs one instance of a sentry. Each online message entry (client+user) in the system has a sentry associated with it. The sentry of a message is the sentry-id of the server who owns the client. 58 | 59 | .start() 60 | - When the server starts, we load a hash which contains all online sentries. Each sentry-id is a server and we get a full picture of who is alive at the moment. We also publish/persist our own sentry-keep-alives to redis. In addition to this, we listen to the sentry pubsub channel for other sentry-keep-alives. 61 | 62 | .run() 63 | - start() sets up a interval timer (10000ms or configurable) which does two things: publish/persist a keep alive message, and look at our available information to determine expired sentries. For each of the expired sentries, we emit a 'down' event. 64 | 65 | .isAlive() 66 | - Looks at our sentry-map and returns alive or not. 67 | 68 | Using the sentry system, each online message is stamped with the sentry-id of the server before it is published/persisted to redis. When an online message is received from redis, we check if its sentry is still alive. If not, we treat it as an implicit disconnect. 69 | 70 | When the presence manager starts, it subscribes to the 'down' event from the server's sentry. If a sentry goes down, we find all clients for that sentry and implicitly disconnect them. 71 | 72 | Explicit/Implicit offlines workflow 73 | =================================== 74 | 75 | Explicit offlines happen when a client does .set('offline') on the resource. This has a simple workflow: Presence resource asks the manager to remove client/user from the manager. The manager will publish this request (with online:false, explicit:true) to redis and it will be received by all interested servers including itself. The processRedisEntry() method then will then ask the store to remove both client and user. The store produces a client_removed event on successful removal of the client, and a user_removed if there are no more clients for the user. These events will then be translated to client_offline and user_offline messages to be broadcast to subscribed clients. 76 | 77 | A little map: .set('offline') -> manager.removeClient() -> redis -> manager.processRedisEntry() -> store.remove() -> user_removed/client_removed events -> user_offline/client_offline messages. 78 | 79 | Implicit offlines are a little stranger. One way to generate them is to call .unsubscribe() from the client side on the resource. An unsubscribe() will be internally called if the socket closes unexpectedly as well. The presence resource will ask the manager to only remove the client in this case (manager.disconnectClient). This request will be published to redis with explicit:false. When processRedisEntry() receives it, it asks the store to only remove a client (store.removeClient). An empty user may be left behind in the store if this is the last client of that user. The manager will nevertheless setup a userExpiry timer for the user, even it there are other clients. When the timer fires, it will check if the user is empty and remove the user from the store. Each removal causes events which are translated into client_offline and user_offline messages to be broadcast to other clients. 80 | 81 | User expiry has to work correctly under more complex conditions: For example, client1 for user1 may do an implicit disconnect starting up a user expiry. At this point if client2 of user1 does an explicit disconnect, we should still do a delayed user_offline for that user (because client1 might comeback within that period). For more finer points on this, please refer to processRedisEntry() in presence manager. 82 | 83 | A little map: .unsubscribe() (either from socket close or client message) -> manager.disconnectClient() -> redis -> manager.processRedisEntry() -> store.removeClient() + user expiry setup -> client_removed event -> client_offline message. 84 | 85 | on user expiry timeout -> store.removeUserIfEmpty() (if user exists and no clients) -> user_removed -> user_offline message. 86 | 87 | Known issues 88 | ============ 89 | 90 | 1. Presence offline messages and subscribe(): When using a subscribe(), we are guaranteed to receive all subsequent online messages and all offline messages for those clients. However, it is possible that we do not receive any offline notifications for clients we never knew about. For a client who is tracking who is currently online, it makes sense. However, for a client who is tracking all presence notifications after subscribe() this is broken/inconsistent. A workaround is to always use sync(), without caring about the response callback for sync. (This will be fixed at a later point) 91 | -------------------------------------------------------------------------------- /src/core/resources/presence/sentry.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const Minilog = require('minilog') 3 | const logging = Minilog('radar:sentry') 4 | const Persistence = require('persistence') 5 | const redisSentriesKey = 'sentry:/radar' 6 | const id = require('../../id') 7 | 8 | const defaultOptions = { 9 | EXPIRY_OFFSET: 60 * 1000, // 1 minute max valid time for an sentry message 10 | REFRESH_INTERVAL: 10000, // 10 seconds to refresh own sentry 11 | CHECK_INTERVAL: 30000 // 30 seconds to check for new sentries 12 | } 13 | 14 | const parseJSON = function (message) { 15 | try { 16 | return JSON.parse(message) 17 | } catch (e) {} 18 | } 19 | 20 | const messageIsExpired = function (message) { 21 | return (!message || !message.expiration || (message.expiration <= Date.now())) 22 | } 23 | 24 | const messageExpiration = function (message) { 25 | return (message && message.expiration && (message.expiration - Date.now())) 26 | } 27 | 28 | const Sentry = function (name) { 29 | this.sentries = {} 30 | this._setName(name) 31 | 32 | this._expiryOffset = defaultOptions.EXPIRY_OFFSET 33 | this._refreshInterval = defaultOptions.REFRESH_INTERVAL 34 | this._checkInterval = defaultOptions.CHECK_INTERVAL 35 | } 36 | 37 | require('util').inherits(Sentry, require('events').EventEmitter) 38 | 39 | Sentry.prototype.start = function (options, callback) { 40 | const self = this 41 | const keepAliveOptions = {} 42 | 43 | options = options || {} 44 | 45 | if (typeof (options) === 'function') { 46 | callback = options 47 | } else { 48 | this._applyOptions(options) 49 | } 50 | 51 | if (this._refreshTimer) { return } 52 | 53 | logging.info('#presence - #sentry - starting', this.name) 54 | 55 | if (options.expiration) { 56 | keepAliveOptions.expiration = options.expiration 57 | } 58 | 59 | const upMessage = self._keepAlive(keepAliveOptions) 60 | 61 | this._loadAndCleanUpSentries(function () { 62 | self.emit('up', self.name, upMessage) 63 | Persistence.publish(redisSentriesKey, upMessage) 64 | self._startListening() 65 | if (callback) { callback() } 66 | }) 67 | 68 | this._refreshTimer = setTimeout(this._refresh.bind(this), Math.floor(this._refreshInterval)) 69 | this._checkSentriesTimer = setTimeout(this._checkSentries.bind(this), Math.floor(this._checkInterval)) 70 | } 71 | 72 | Sentry.prototype.stop = function (callback) { 73 | logging.info('#presence- #sentry stopping', this.name) 74 | 75 | this._stopTimer('_checkSentriesTimer') 76 | this._stopTimer('_refreshTimer') 77 | this.sentries = {} 78 | this._stopListening() 79 | 80 | if (callback) { callback() } 81 | } 82 | 83 | Sentry.prototype.sentryNames = function () { 84 | return Object.keys(this.sentries) 85 | } 86 | 87 | Sentry.prototype.isDown = function (name) { 88 | const lastMessage = this.sentries[name] 89 | const isSentryDown = messageIsExpired(lastMessage) 90 | 91 | if (isSentryDown) { 92 | const text = this.messageExpirationText(lastMessage) 93 | 94 | logging.debug('#presence - #sentry isDown', name, isSentryDown, text) 95 | } 96 | 97 | return isSentryDown 98 | } 99 | 100 | Sentry.prototype.messageExpirationText = function (message) { 101 | const expiration = messageExpiration(message) 102 | const text = expiration ? expiration + '/' + this._expiryOffset : 'not-present' 103 | 104 | return text 105 | } 106 | 107 | Sentry.prototype._setName = function (name) { 108 | this.name = name || id() 109 | return this.name 110 | } 111 | 112 | Sentry.prototype._applyOptions = function (options) { 113 | if (options) { 114 | this.host = options.host 115 | this.port = options.port 116 | 117 | this._expiryOffset = options.expiryOffset || defaultOptions.EXPIRY_OFFSET 118 | this._refreshInterval = options.refreshInterval || defaultOptions.REFRESH_INTERVAL 119 | this._checkInterval = options.checkInterval || defaultOptions.CHECK_INTERVAL 120 | } 121 | } 122 | 123 | Sentry.prototype._keepAlive = function (options) { 124 | options = options || {} 125 | const message = this._newKeepAliveMessage(options.name, options.expiration) 126 | const name = message.name 127 | 128 | this.sentries[name] = message 129 | 130 | if (options.save !== false) { 131 | Persistence.persistHash(redisSentriesKey, name, message) 132 | } 133 | 134 | return message 135 | } 136 | 137 | Sentry.prototype._newKeepAliveMessage = function (name, expiration) { 138 | return { 139 | name: (name || this.name), 140 | expiration: (expiration || this._expiryOffsetFromNow()), 141 | host: this.host, 142 | port: this.port 143 | } 144 | } 145 | 146 | Sentry.prototype._expiryOffsetFromNow = function () { 147 | return Date.now() + this._expiryOffset 148 | } 149 | 150 | // It loads the sentries from redis, and performs two tasks: 151 | // 152 | // * purges sentries that are no longer available 153 | // * expire sentries based on stale messages. 154 | // 155 | Sentry.prototype._loadAndCleanUpSentries = function (callback) { 156 | const self = this 157 | 158 | Persistence.readHashAll(redisSentriesKey, function (replies) { 159 | replies = replies || {} 160 | const repliesKeys = Object.keys(replies) 161 | 162 | self._purgeGoneSentries(replies, repliesKeys) 163 | 164 | repliesKeys.forEach(function (name) { 165 | self.sentries[name] = replies[name] 166 | if (self.isDown(name)) { 167 | self._purgeSentry(name) 168 | } 169 | }) 170 | 171 | if (callback) { callback() } 172 | }) 173 | } 174 | 175 | Sentry.prototype._purgeSentry = function (name) { 176 | Persistence.deleteHash(redisSentriesKey, name) 177 | 178 | const lastMessage = this.sentries[name] 179 | logging.info('#presence - #sentry down:', name, lastMessage.host, lastMessage.port) 180 | delete this.sentries[name] 181 | this.emit('down', name, lastMessage) 182 | } 183 | 184 | // Deletion of a gone sentry key might just happened, so 185 | // we compare existing sentry names to reply names 186 | // and clear whatever we have that no longer exists. 187 | Sentry.prototype._purgeGoneSentries = function (replies, repliesKeys) { 188 | const self = this 189 | const sentriesGone = _.difference(this.sentryNames(), repliesKeys) 190 | 191 | sentriesGone.forEach(function (name) { 192 | self._purgeSentry(name) 193 | }) 194 | } 195 | 196 | // Listening for new pub sub messages from redis. 197 | // As of now, we only care about new sentries going online. 198 | // Everything else gets inferred based on time. 199 | Sentry.prototype._startListening = function () { 200 | const self = this 201 | 202 | if (!this._listener) { 203 | this._listener = function (channel, message) { 204 | if (channel !== redisSentriesKey) { 205 | return 206 | } 207 | self._saveMessage(parseJSON(message)) 208 | } 209 | 210 | Persistence.pubsub().subscribe(redisSentriesKey) 211 | Persistence.pubsub().on('message', this._listener) 212 | } 213 | } 214 | 215 | Sentry.prototype._stopListening = function () { 216 | if (this._listener) { 217 | Persistence.pubsub().unsubscribe(redisSentriesKey) 218 | Persistence.pubsub().removeListener('message', this._listener) 219 | delete this._listener 220 | } 221 | } 222 | 223 | Sentry.prototype._saveMessage = function (message) { 224 | if (message && message.name) { 225 | logging.debug('#presence - sentry.save', message.name, messageExpiration(message)) 226 | this.sentries[message.name] = message 227 | } 228 | } 229 | 230 | Sentry.prototype._refresh = function () { 231 | const interval = Math.floor(this._refreshInterval) 232 | 233 | logging.info('#presence - #sentry keep alive:', this.name) 234 | this._keepAlive() 235 | this._refreshTimer = setTimeout(this._refresh.bind(this), interval) 236 | } 237 | 238 | Sentry.prototype._checkSentries = function () { 239 | const interval = Math.floor(this._checkInterval) 240 | 241 | logging.info('#presence - #sentry checking sentries:', this.name) 242 | this._loadAndCleanUpSentries() 243 | this._checkSentriesTimer = setTimeout(this._checkSentries.bind(this), interval) 244 | } 245 | 246 | Sentry.prototype._stopTimer = function (methodName) { 247 | if (this[methodName]) { 248 | clearTimeout(this[methodName]) 249 | delete this[methodName] 250 | } 251 | } 252 | 253 | module.exports = Sentry 254 | module.exports.channel = redisSentriesKey 255 | -------------------------------------------------------------------------------- /src/core/resources/resource.js: -------------------------------------------------------------------------------- 1 | const MiniEventEmitter = require('miniee') 2 | const logging = require('minilog')('radar:resource') 3 | const Stamper = require('../stamper.js') 4 | const ClientSession = require('../../client/client_session') 5 | /* 6 | 7 | Resources 8 | ========= 9 | 10 | - have a type, one of: 11 | 12 | - have statuses, which are a hash of values. Values never expire by themselves, they are always explicitly set. 13 | - have messages, which are an ordered set of messages 14 | 15 | - can be subscribed to (e.g. pubsub) 16 | 17 | - can be synchronized (e.g. read full set of values, possibly applying some filtering), if it is a status or message 18 | 19 | */ 20 | 21 | function recursiveMerge (target /*, ..sources */) { 22 | const sources = Array.prototype.slice.call(arguments, 1) 23 | 24 | sources.forEach(function (source) { 25 | if (source) { 26 | Object.keys(source).forEach(function (name) { 27 | // Catch 0s and false too 28 | if (target[name] !== undefined) { 29 | // Extend the object if it is an Object 30 | if (target[name] === Object(target[name])) { 31 | target[name] = recursiveMerge(target[name], source[name]) 32 | } 33 | } else { 34 | target[name] = source[name] 35 | } 36 | }) 37 | } 38 | }) 39 | 40 | return target 41 | } 42 | 43 | function Resource (to, server, options, defaultOptions) { 44 | this.to = to 45 | this.subscribers = {} 46 | this.server = server // RadarServer instance 47 | this.options = recursiveMerge({}, options || {}, defaultOptions || {}) 48 | } 49 | 50 | MiniEventEmitter.mixin(Resource) 51 | Resource.prototype.type = 'default' 52 | 53 | // Add a subscriber (ClientSession) 54 | Resource.prototype.subscribe = function (clientSession, message) { 55 | this.subscribers[clientSession.id] = true 56 | 57 | logging.debug('#' + this.type, '- subscribe', this.to, clientSession.id, 58 | this.subscribers, message && message.ack) 59 | 60 | this.ack(clientSession, message && message.ack) 61 | } 62 | 63 | // Remove a subscriber (ClientSession) 64 | Resource.prototype.unsubscribe = function (clientSession, message) { 65 | delete this.subscribers[clientSession.id] 66 | 67 | logging.info('#' + this.type, '- unsubscribe', this.to, clientSession.id, 68 | 'subscribers left:', Object.keys(this.subscribers).length) 69 | 70 | if (!Object.keys(this.subscribers).length) { 71 | logging.info('#' + this.type, '- destroying resource', this.to, 72 | this.subscribers, clientSession.id) 73 | this.server.destroyResource(this.to) 74 | } 75 | 76 | this.ack(clientSession, message && message.ack) 77 | } 78 | 79 | // Send to clients 80 | Resource.prototype.redisIn = function (data) { 81 | const self = this 82 | 83 | Stamper.stamp(data) 84 | 85 | logging.info('#' + this.type, '- incoming from #redis', this.to, data, 'subs:', 86 | Object.keys(this.subscribers).length) 87 | 88 | Object.keys(this.subscribers).forEach(function (clientSessionId) { 89 | const clientSession = self.getClientSession(clientSessionId) 90 | 91 | if (clientSession && clientSession.send) { 92 | data.stamp.clientId = clientSession.id 93 | clientSession.send(data) 94 | } 95 | }) 96 | 97 | this.emit('message:outgoing', data) 98 | 99 | if (Object.keys(this.subscribers).length === 0) { 100 | logging.info('#' + this.type, '- no subscribers, destroying resource', this.to) 101 | this.server.destroyResource(this.to) 102 | } 103 | } 104 | 105 | // Return a socket reference; eio server hash is "clients", not "sockets" 106 | Resource.prototype.socketGet = function (id) { 107 | logging.debug('DEPRECATED: use clientSessionGet instead') 108 | return this.getClientSession(id) 109 | } 110 | 111 | Resource.prototype.getClientSession = function (id) { 112 | return ClientSession.get(id) 113 | } 114 | 115 | Resource.prototype.ack = function (clientSession, sendAck) { 116 | if (clientSession && clientSession.send && sendAck) { 117 | logging.debug('#clientSession - send_ack', clientSession.id, this.to, sendAck) 118 | 119 | clientSession.send({ 120 | op: 'ack', 121 | value: sendAck 122 | }) 123 | } 124 | } 125 | 126 | Resource.prototype.handleMessage = function (clientSession, message) { 127 | switch (message.op) { 128 | case 'subscribe': 129 | case 'unsubscribe': 130 | case 'get': 131 | case 'sync': 132 | case 'set': 133 | case 'publish': 134 | case 'push': 135 | this[message.op](clientSession, message) 136 | this.emit('message:incoming', message) 137 | break 138 | default: 139 | logging.error('#resource - Unknown message.op, ignoring', message, clientSession && clientSession.id) 140 | } 141 | } 142 | 143 | Resource.prototype.destroy = function () { 144 | this.destroyed = true 145 | } 146 | 147 | Resource.setBackend = function (backend) { 148 | // noop 149 | } 150 | 151 | module.exports = Resource 152 | -------------------------------------------------------------------------------- /src/core/resources/status/index.js: -------------------------------------------------------------------------------- 1 | const Resource = require('../resource.js') 2 | let Persistence = require('persistence') 3 | const logger = require('minilog')('radar:status') 4 | 5 | const defaultOptions = { 6 | policy: { 7 | maxPersistence: 12 * 60 * 60 // 12 hours in seconds 8 | } 9 | } 10 | 11 | function Status (to, server, options) { 12 | Resource.call(this, to, server, options, defaultOptions) 13 | } 14 | 15 | Status.prototype = new Resource() 16 | Status.prototype.type = 'status' 17 | 18 | // Get status 19 | Status.prototype.get = function (clientSession) { 20 | const to = this.to 21 | 22 | logger.debug('#status - get', this.to, (clientSession && clientSession.id)) 23 | 24 | this._get(to, function (replies) { 25 | clientSession.send({ 26 | op: 'get', 27 | to: to, 28 | value: replies || {} 29 | }) 30 | }) 31 | } 32 | 33 | Status.prototype._get = function (to, callback) { 34 | Persistence.readHashAll(to, callback) 35 | } 36 | 37 | Status.prototype.set = function (clientSession, message) { 38 | const self = this 39 | 40 | logger.debug('#status - set', this.to, message, (clientSession && clientSession.id)) 41 | 42 | Status.prototype._set(this.to, message, this.options.policy, function () { 43 | self.ack(clientSession, message.ack) 44 | }) 45 | } 46 | 47 | Status.prototype._set = function (scope, message, policy, callback) { 48 | Persistence.persistHash(scope, message.key, message.value) 49 | 50 | if (policy && policy.maxPersistence) { 51 | Persistence.expire(scope, policy.maxPersistence) 52 | } else { 53 | logger.warn('resource created without ttl :', scope) 54 | logger.warn('resource policy was :', policy) 55 | } 56 | 57 | Persistence.publish(scope, message, callback) 58 | } 59 | 60 | Status.prototype.sync = function (clientSession) { 61 | logger.debug('#status - sync', this.to, (clientSession && clientSession.id)) 62 | 63 | this.subscribe(clientSession, false) 64 | this.get(clientSession) 65 | } 66 | 67 | Status.setBackend = function (backend) { 68 | Persistence = backend 69 | } 70 | 71 | module.exports = Status 72 | -------------------------------------------------------------------------------- /src/core/resources/stream/index.js: -------------------------------------------------------------------------------- 1 | const Resource = require('../resource.js') 2 | let Persistence = require('persistence') 3 | const logging = require('minilog')('radar:stream') 4 | const SubscriberState = require('./subscriber_state.js') 5 | 6 | const defaultOptions = { 7 | policy: { 8 | maxPersistence: 7 * 24 * 60 * 60, // 1 week in seconds 9 | maxLength: 100000 10 | } 11 | } 12 | 13 | function Stream (to, server, options) { 14 | Resource.call(this, to, server, options, defaultOptions) 15 | this.list = new Persistence.List(to, this.options.policy.maxPersistence, this.options.policy.maxLength) 16 | this.subscriberState = new SubscriberState() 17 | } 18 | 19 | Stream.prototype = new Resource() 20 | Stream.prototype.type = 'stream' 21 | 22 | Stream.prototype._getSyncError = function (from) { 23 | return { 24 | to: this.to, 25 | error: { 26 | type: 'sync-error', 27 | from: from, 28 | start: this.start, 29 | end: this.end, 30 | size: this.size 31 | } 32 | } 33 | } 34 | 35 | Stream.prototype._subscribe = function (clientSession, message) { 36 | const self = this 37 | const from = message.options && message.options.from 38 | const sub = this.subscriberState.get(clientSession.id) 39 | 40 | if (typeof from === 'undefined' || from < 0) { 41 | return 42 | } 43 | 44 | sub.startSubscribing(from) 45 | this._get(from, function (error, values) { 46 | if (error) { 47 | const syncError = self._getSyncError(from) 48 | syncError.op = 'push' 49 | clientSession.send(syncError) 50 | } else { 51 | values.forEach(function (message) { 52 | message.op = 'push' 53 | message.to = self.to 54 | clientSession.send(message) 55 | sub.sent = message.id 56 | }) 57 | } 58 | sub.finishSubscribing() 59 | }) 60 | } 61 | 62 | Stream.prototype.subscribe = function (clientSession, message) { 63 | Resource.prototype.subscribe.call(this, clientSession, message) 64 | this._subscribe(clientSession, message) 65 | } 66 | 67 | Stream.prototype.get = function (clientSession, message) { 68 | const stream = this 69 | const from = message && message.options && message.options.from 70 | logging.debug('#stream - get', this.to, 'from: ' + from, (clientSession && clientSession.id)) 71 | 72 | this._get(from, function (error, values) { 73 | if (error) { 74 | const syncError = stream._getSyncError(from) 75 | syncError.op = 'get' 76 | syncError.value = [] 77 | clientSession.send(syncError) 78 | } else { 79 | clientSession.send({ 80 | op: 'get', 81 | to: stream.to, 82 | value: values || [] 83 | }) 84 | } 85 | }) 86 | } 87 | 88 | Stream.prototype._get = function (from, callback) { 89 | const self = this 90 | this.list.info(function (error, start, end, size) { 91 | if (error) { return callback(error) } 92 | self.start = start 93 | self.end = end 94 | self.size = size 95 | self.list.read(from, start, end, size, callback) 96 | }) 97 | } 98 | 99 | Stream.prototype.push = function (clientSession, message) { 100 | const self = this 101 | 102 | logging.debug('#stream - push', this.to, message, (clientSession && clientSession.id)) 103 | 104 | const m = { 105 | to: this.to, 106 | op: 'push', 107 | resource: message.resource, 108 | action: message.action, 109 | value: message.value, 110 | userData: message.userData 111 | } 112 | 113 | this.list.push(m, function (error, stamped) { 114 | if (error) { 115 | console.log(error) 116 | logging.error(error) 117 | return 118 | } 119 | 120 | logging.debug('#stream - push complete with id', self.to, stamped, (clientSession && clientSession.id)) 121 | self.ack(clientSession, message.ack) 122 | }) 123 | } 124 | 125 | Stream.prototype.sync = function (clientSession, message) { 126 | logging.debug('#stream - sync', this.to, (clientSession && clientSession.id)) 127 | this.get(clientSession, message) 128 | this.subscribe(clientSession, false) 129 | } 130 | 131 | Stream.prototype.redisIn = function (data) { 132 | const self = this 133 | logging.info('#' + this.type, '- incoming from #redis', this.to, data, 'subs:', Object.keys(this.subscribers).length) 134 | Object.keys(this.subscribers).forEach(function (clientSessionId) { 135 | const clientSession = self.getClientSession(clientSessionId) 136 | if (clientSession && clientSession.send) { 137 | const sub = self.subscriberState.get(clientSession.id) 138 | if (sub && sub.sendable(data)) { 139 | clientSession.send(data) 140 | sub.sent = data.id 141 | } 142 | } 143 | }) 144 | 145 | // Someone released the lock, wake up 146 | this.list.unblock() 147 | } 148 | 149 | Stream.setBackend = function (backend) { Persistence = backend } 150 | 151 | module.exports = Stream 152 | -------------------------------------------------------------------------------- /src/core/resources/stream/subscriber_state.js: -------------------------------------------------------------------------------- 1 | function StreamSubscriber (clientSessionId) { 2 | this.id = clientSessionId 3 | this.sent = null 4 | this.sendEnabled = true 5 | } 6 | 7 | StreamSubscriber.prototype.startSubscribing = function (from) { 8 | this.sent = from 9 | this.sendEnabled = false 10 | } 11 | 12 | StreamSubscriber.prototype.finishSubscribing = function () { 13 | this.sendEnabled = true 14 | } 15 | 16 | StreamSubscriber.prototype.sendable = function (data) { 17 | return (this.sendEnabled && this.sent < data.id) 18 | } 19 | 20 | function SubscriberState () { 21 | this.subscribers = {} 22 | } 23 | 24 | SubscriberState.prototype.get = function (clientSessionId) { 25 | if (!this.subscribers[clientSessionId]) { 26 | this.subscribers[clientSessionId] = new StreamSubscriber(clientSessionId) 27 | } 28 | return this.subscribers[clientSessionId] 29 | } 30 | 31 | SubscriberState.prototype.remove = function (clientSessionId) { 32 | delete this.subscribers[clientSessionId] 33 | } 34 | 35 | module.exports = SubscriberState 36 | -------------------------------------------------------------------------------- /src/core/stamper.js: -------------------------------------------------------------------------------- 1 | const id = require('./id') 2 | const logging = require('minilog')('radar:stamper') 3 | let sentryName 4 | 5 | module.exports = { 6 | setup: function (name) { 7 | sentryName = name 8 | }, 9 | 10 | stamp: function (message, clientId) { 11 | if (!sentryName) { 12 | logging.error('run Stamper.setup() before trying to stamp') 13 | } 14 | 15 | if (message.stamp && clientId) { 16 | message.stamp.clientId = clientId 17 | } else { 18 | message.stamp = { 19 | id: id(), 20 | clientId: clientId, 21 | sentryId: sentryName, 22 | timestamp: new Date().toJSON() 23 | } 24 | } 25 | 26 | return message 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/core/type.js: -------------------------------------------------------------------------------- 1 | const logger = require('minilog')('radar:types') 2 | 3 | let Types = [ 4 | { 5 | name: 'general message', 6 | type: 'MessageList', 7 | expression: /^message:/ 8 | }, 9 | { 10 | name: 'general status', 11 | type: 'Status', 12 | expression: /^status:/ 13 | }, 14 | { 15 | name: 'general presence', 16 | type: 'Presence', 17 | expression: /^presence:/, 18 | policy: { cache: true, maxAgeSeconds: 15 } 19 | }, 20 | { 21 | name: 'general stream', 22 | type: 'Stream', 23 | expression: /^stream:/ 24 | }, 25 | { 26 | name: 'general control', 27 | type: 'Control', 28 | expression: /^control:/ 29 | } 30 | ] 31 | 32 | // Get the type by resource "to" (aka, full scope) 33 | function getByExpression (to) { 34 | if (to) { 35 | const l = Types.length 36 | let definition 37 | let expression 38 | for (let i = 0; i < l; ++i) { 39 | definition = Types[i] 40 | expression = definition.expression || definition.expr 41 | if (!expression) { 42 | logger.error('#type - there is a type definition without an expression.', 43 | i, definition.name) 44 | continue 45 | } 46 | 47 | if ((expression.test && expression.test(to)) || expression === to) { 48 | logger.debug('#type - found', to) 49 | return definition 50 | } 51 | } 52 | } 53 | logger.warn('#type - Unable to find a valid type definition for:' + to) 54 | } 55 | 56 | module.exports = { 57 | getByExpression: getByExpression, 58 | // Deprecated 59 | register: function (name, type) { 60 | logger.debug('#type - register', type) 61 | Types.unshift(type) 62 | }, 63 | add: function (types) { 64 | Types = types.concat(Types) 65 | }, 66 | replace: function (types) { 67 | Types = types 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/middleware/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Runner: require('./runner.js'), 3 | QuotaManager: require('./quota_manager.js'), 4 | QuotaLimiter: require('./quota_limiter.js'), 5 | LegacyAuthManager: require('./legacy_auth_manager.js') 6 | } 7 | -------------------------------------------------------------------------------- /src/middleware/legacy_auth_manager.js: -------------------------------------------------------------------------------- 1 | const logging = require('minilog')('radar:legacy_auth_manager') 2 | 3 | // Legacy auth middleware 4 | // 5 | // This middleware adds support for the legacy authentication process. 6 | // 7 | // It checks for existance of an authProvider, and delegates 8 | // authentication to it. 9 | // 10 | // { 11 | // type: '...', 12 | // name: '...', 13 | // authProvider: new MyAuthProvider() 14 | // } 15 | 16 | const LegacyAuthManager = function () {} 17 | 18 | LegacyAuthManager.prototype.onMessage = function (clientSession, message, messageType, next) { 19 | if (!this.isAuthorized(clientSession, message, messageType)) { 20 | logging.warn('#clientSession.message - unauthorized', message, clientSession.id) 21 | console.log('auth', clientSession.constructor.name) 22 | clientSession.send({ 23 | op: 'err', 24 | value: 'auth', 25 | origin: message 26 | }) 27 | 28 | next(new Error('Unauthorized')) 29 | } else { 30 | next() 31 | } 32 | } 33 | 34 | LegacyAuthManager.prototype.isAuthorized = function (clientSession, message, messageType) { 35 | let isAuthorized = true 36 | const provider = messageType && messageType.authProvider 37 | 38 | if (provider && provider.authorize) { 39 | isAuthorized = provider.authorize(messageType, message, clientSession) 40 | } 41 | 42 | return isAuthorized 43 | } 44 | 45 | module.exports = LegacyAuthManager 46 | -------------------------------------------------------------------------------- /src/middleware/quota_limiter.js: -------------------------------------------------------------------------------- 1 | const logging = require('minilog')('radar:rate_limiter') 2 | const MiniEventEmitter = require('miniee') 3 | 4 | const QuotaLimiter = function (limit) { 5 | this._limit = limit 6 | this._resources = { 7 | id: {}, 8 | to: {} 9 | } 10 | } 11 | 12 | MiniEventEmitter.mixin(QuotaLimiter) 13 | 14 | QuotaLimiter.prototype.add = function (id, to) { 15 | if (!this._isNewResource(id, to)) { 16 | return false 17 | } 18 | 19 | if (this.isAboveLimit(id)) { 20 | this.emit('rate:limit', this._stateForId(id, to)) 21 | logging.warn('rate limiting client: ' + id + ' to: ' + to) 22 | return false 23 | } 24 | 25 | this._add(id, to) 26 | this.emit('rate:add', this._stateForId(id, to)) 27 | 28 | return true 29 | } 30 | 31 | QuotaLimiter.prototype.remove = function (id, to) { 32 | if (this._resources.id[id] && this._resources.to[to]) { 33 | delete this._resources.id[id][to] 34 | delete this._resources.to[to][id] 35 | this.emit('rate:remove', this._stateForId(id, to)) 36 | } 37 | } 38 | 39 | QuotaLimiter.prototype.isAboveLimit = function (id, limit) { 40 | limit = limit || this._limit 41 | return this.count(id) >= limit 42 | } 43 | 44 | QuotaLimiter.prototype.countAll = function () { 45 | const counts = {} 46 | const self = this 47 | 48 | Object.keys(this._resources.id).forEach(function (id) { 49 | counts[id] = self.count(id) 50 | }) 51 | 52 | return counts 53 | } 54 | 55 | QuotaLimiter.prototype.count = function (id) { 56 | const resources = this._getResourcesByType('id', id) 57 | return (resources ? Object.keys(resources).length : 0) 58 | } 59 | 60 | QuotaLimiter.prototype.removeById = function (id) { 61 | this.emit('rate:remove_by_id', this._stateForId(id)) 62 | this._removeByType('id', 'to', id) 63 | } 64 | 65 | QuotaLimiter.prototype.removeByTo = function (to) { 66 | this.emit('rate:remove_by_to', this._stateForId(undefined, to)) 67 | this._removeByType('to', 'id', to) 68 | } 69 | 70 | QuotaLimiter.prototype._removeByType = function (type1, type2, key) { 71 | this._deepRemove(type2, key, this._resources[type1][key], this._getResourcesByType) 72 | delete this._resources[type1][key] 73 | } 74 | 75 | QuotaLimiter.prototype.inspect = function () { 76 | return this._resources 77 | } 78 | 79 | QuotaLimiter.prototype._add = function (id, to) { 80 | this._addByType('id', id, to) 81 | this._addByType('to', to, id) 82 | return true 83 | } 84 | 85 | QuotaLimiter.prototype._addByType = function (type, key1, key2) { 86 | let resources = this._getResourcesByType(type, key1) 87 | 88 | if (!resources) { 89 | resources = this._initResourcesByType(type, key1) 90 | } 91 | 92 | resources[key2] = 1 93 | } 94 | 95 | QuotaLimiter.prototype._getResourcesByType = function (type, key) { 96 | return this._resources[type][key] 97 | } 98 | 99 | QuotaLimiter.prototype._initResourcesByType = function (type, key) { 100 | const resource = this._resources[type][key] = {} 101 | return resource 102 | } 103 | 104 | QuotaLimiter.prototype._isNewResource = function (id, to) { 105 | const resources = this._getResourcesByType('id', id) 106 | 107 | return !(resources && Object.keys(resources).indexOf(to) !== -1) 108 | } 109 | 110 | QuotaLimiter.prototype._deepRemove = function (type, key, results, lookup) { 111 | const self = this 112 | 113 | if (results && Object.keys(results).length > 0) { 114 | Object.keys(results).forEach(function (result) { 115 | const keys = lookup.call(self, type, result) 116 | 117 | if (keys) { delete keys[key] } 118 | }) 119 | } 120 | } 121 | QuotaLimiter.prototype._stateForId = function (id, to) { 122 | return { 123 | id: id, 124 | to: to, 125 | limit: this._limit, 126 | count: this.count(id) 127 | } 128 | } 129 | 130 | module.exports = QuotaLimiter 131 | -------------------------------------------------------------------------------- /src/middleware/quota_manager.js: -------------------------------------------------------------------------------- 1 | const MiniEventEmitter = require('miniee') 2 | const QuotaLimiter = require('./quota_limiter.js') 3 | const ClientSession = require('../client/client_session.js') 4 | const logging = require('minilog')('radar:quota_manager') 5 | 6 | const QuotaManager = function () { 7 | this._limiters = {} 8 | } 9 | 10 | MiniEventEmitter.mixin(QuotaManager) 11 | 12 | QuotaManager.prototype.checkLimits = function (clientSession, message, messageType, next) { 13 | const limiter = this.getLimiter(messageType) 14 | let softLimit 15 | 16 | if (!limiter || (message.op !== 'subscribe' && message.op !== 'sync')) { 17 | next() 18 | } else if (limiter.isAboveLimit(clientSession.id)) { 19 | logging.warn('#clientSession.message - rate_limited', message, clientSession.id) 20 | 21 | clientSession.send({ 22 | op: 'err', 23 | value: 'rate limited', 24 | origin: message 25 | }) 26 | 27 | next(new Error('limit reached')) 28 | } else { 29 | // Log Soft Limit, if available 30 | softLimit = this._getSoftLimit(messageType) 31 | if (softLimit && limiter.count(clientSession.id) === softLimit) { 32 | const client = ClientSession.get(clientSession.id) 33 | this._logLimits(client, softLimit, limiter.count(clientSession.id)) 34 | } 35 | 36 | next() 37 | } 38 | } 39 | 40 | QuotaManager.prototype.updateLimits = function (clientSession, resource, message, messageType, next) { 41 | const limiter = this.getLimiter(messageType) 42 | 43 | if (limiter) { 44 | switch (message.op) { 45 | case 'sync': 46 | case 'subscribe': 47 | limiter.add(clientSession.id, message.to) 48 | break 49 | case 'unsubscribe': 50 | limiter.remove(clientSession.id, message.to) 51 | break 52 | } 53 | } 54 | 55 | next() 56 | } 57 | 58 | QuotaManager.prototype.destroyByClient = function (clientSession, resource, messageType, next) { 59 | const limiter = this.findLimiter(messageType) 60 | 61 | if (limiter) { 62 | limiter.remove(clientSession.id, resource.to) 63 | } 64 | 65 | next() 66 | } 67 | 68 | QuotaManager.prototype.destroyByResource = function (resource, messageType, next) { 69 | const to = resource.to 70 | const limiter = this.findLimiter(messageType) 71 | 72 | if (limiter) { 73 | limiter.removeByTo(to) 74 | } 75 | 76 | next() 77 | } 78 | 79 | QuotaManager.prototype.findLimiter = function (messageType) { 80 | return this._limiters[messageType.name] 81 | } 82 | 83 | QuotaManager.prototype.getLimiter = function (messageType) { 84 | let limiter = this.findLimiter(messageType) 85 | 86 | if (!limiter && this._shouldLimit(messageType)) { 87 | limiter = this._buildLimiter(messageType) 88 | this._limiters[messageType.name] = limiter 89 | this.emit('rate_limiter:add', messageType.name, limiter) 90 | } 91 | 92 | return limiter 93 | } 94 | 95 | QuotaManager.prototype._buildLimiter = function (messageType) { 96 | let limiter 97 | 98 | if (this._shouldLimit(messageType)) { 99 | limiter = new QuotaLimiter(messageType.policy.limit) 100 | } 101 | 102 | return limiter 103 | } 104 | 105 | QuotaManager.prototype._should = function (type, messageType) { 106 | return messageType && messageType.policy && messageType.policy[type] 107 | } 108 | 109 | QuotaManager.prototype._shouldLimit = function (messageType) { 110 | return this._should('limit', messageType) 111 | } 112 | 113 | QuotaManager.prototype._shouldSoftLimit = function (messageType) { 114 | return this._should('softLimit', messageType) 115 | } 116 | 117 | QuotaManager.prototype._getSoftLimit = function (messageType) { 118 | let softLimit 119 | 120 | if (this._shouldSoftLimit(messageType)) { 121 | softLimit = messageType.policy.softLimit 122 | } 123 | 124 | return softLimit 125 | } 126 | 127 | QuotaManager.prototype._logLimits = function (client, expected, actual) { 128 | if (!client) { 129 | logging.error('Attempted to log client limits but no client was provided') 130 | return 131 | } 132 | 133 | logging.warn('#clientSession.message - rate soft limit reached', client.id, { 134 | name: client.name, 135 | actual: actual, 136 | expected: expected, 137 | subscriptions: client.subscriptions, 138 | presences: client.presences 139 | }) 140 | } 141 | 142 | /* Middleware api */ 143 | QuotaManager.prototype.onMessage = QuotaManager.prototype.checkLimits 144 | QuotaManager.prototype.onResource = QuotaManager.prototype.updateLimits 145 | QuotaManager.prototype.onDestroyResource = QuotaManager.prototype.destroyByResource 146 | QuotaManager.prototype.onDestroyClient = QuotaManager.prototype.destroyByClient 147 | 148 | module.exports = QuotaManager 149 | -------------------------------------------------------------------------------- /src/middleware/runner.js: -------------------------------------------------------------------------------- 1 | const async = require('async') 2 | 3 | const Middleware = { 4 | use: function (middleware) { 5 | this._middleware = this._middleware || [] 6 | this._middleware.push(middleware) 7 | }, 8 | 9 | runMiddleware: function () { 10 | const context = arguments[0] 11 | const args = [].slice.call(arguments, 1, -1) 12 | const callback = [].slice.call(arguments, -1)[0] 13 | 14 | if (!this._middleware) { 15 | callback() 16 | return 17 | } 18 | 19 | const process = function (middleware, next) { 20 | if (middleware[context]) { 21 | middleware[context].apply(middleware, args.concat(next)) 22 | } else { 23 | next() 24 | } 25 | } 26 | 27 | async.each(this._middleware, process, callback) 28 | } 29 | } 30 | 31 | module.exports = { 32 | mixin: function (receiver) { 33 | for (const key in Middleware) { 34 | if (Object.prototype.hasOwnProperty.call(Middleware, key)) { 35 | receiver.prototype[key] = Middleware[key] 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/server/service_interface.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-deprecated-api */ 2 | 3 | const { EventEmitter } = require('events') 4 | const { inherits } = require('util') 5 | const httpAttach = require('http-attach') 6 | const log = require('minilog')('radar:service_interface') 7 | const { parse: parseUrl } = require('url') 8 | const RadarMessage = require('radar_message') 9 | const concatStream = require('concat-stream') 10 | const id = require('../core/id') 11 | const { parse: parseContentType } = require('content-type') 12 | 13 | function ServiceInterface (middlewareRunner) { 14 | this._middlewareRunner = middlewareRunner || noopMiddlewareRunner 15 | log.debug('New ServiceInterface') 16 | } 17 | 18 | const noopMiddlewareRunner = { 19 | runMiddleware: function () { 20 | const callback = arguments[arguments.length - 1] 21 | callback() 22 | } 23 | } 24 | 25 | inherits(ServiceInterface, EventEmitter) 26 | 27 | ServiceInterface.prototype.middleware = function (req, res, next) { 28 | if (String(req.url).indexOf('/radar/service') !== 0) { 29 | log.debug('Request not for service interface') 30 | return next() 31 | } 32 | log.debug('Request for service interface') 33 | this._dispatch(req, res) 34 | } 35 | 36 | ServiceInterface.prototype._dispatch = function (req, res) { 37 | req.id = req.id || id() 38 | log.info('Incoming ServiceInterface request', req.method, req.id) 39 | switch (req.method) { 40 | case 'GET': 41 | this._get(req, res) 42 | break 43 | case 'POST': 44 | this._post(req, res) 45 | break 46 | default: { 47 | const err = new Error('not found') 48 | err.statusCode = 404 49 | error(err, res) 50 | } 51 | } 52 | } 53 | 54 | // simple "get" 55 | ServiceInterface.prototype._get = function (req, res) { 56 | const self = this 57 | const qs = parseUrl(req.url, true).query 58 | 59 | if (!qs.to) { 60 | const e = new Error('Missing required parameter to') 61 | e.statusCode = 400 62 | return error(e, res) 63 | } 64 | 65 | const message = RadarMessage.Request.buildGet(qs.to).message 66 | 67 | try { 68 | log.info('POST incoming message', message) 69 | self._processIncomingMessage(message, req, res) 70 | } catch (e) { 71 | e.statusCode = e.statusCode || 500 72 | return error(e, res) 73 | } 74 | } 75 | 76 | const SHOW_STACK_TRACE = !String(process.env.NODE_ENV).match(/prod/i) 77 | function error (err, res) { 78 | err.statusCode = err.statusCode || 400 79 | log.warn(err.statusCode, err.stack) 80 | res.statusCode = err.statusCode 81 | const message = { op: 'err' } 82 | 83 | if (err.statusCode === 401 || err.statusCode === 403) { 84 | message.value = 'auth' 85 | } 86 | 87 | if (SHOW_STACK_TRACE) { 88 | message.stack = err.stack 89 | message.code = err.statusCode 90 | } 91 | res.setHeader('content-type', 'application/json') 92 | res.write(JSON.stringify(message)) 93 | res.end() 94 | } 95 | 96 | ServiceInterface.prototype._post = function (req, res) { 97 | const self = this 98 | let contentType 99 | 100 | try { 101 | contentType = parseContentType(req.headers['content-type']).type 102 | } catch (e) { 103 | log.info('Parsing content-type failed', req.headers['content-type']) 104 | } 105 | 106 | if (!req.headers || contentType !== 'application/json') { 107 | const err = new Error('Content-type must be application/json') 108 | err.statusCode = 415 109 | return error(err, res) 110 | } 111 | 112 | req.pipe(concatStream(function (body) { 113 | let message 114 | try { 115 | message = JSON.parse(body) 116 | } catch (e) { 117 | const err = new Error('Body must be valid JSON') 118 | err.statusCode = 400 119 | return error(err, res) 120 | } 121 | 122 | try { 123 | log.info('POST incoming message', message) 124 | self._processIncomingMessage(message, req, res) 125 | } catch (e) { 126 | e.statusCode = e.statusCode || 500 127 | return error(e, res) 128 | } 129 | })) 130 | } 131 | 132 | function allowedOp (op) { 133 | switch (op) { 134 | case 'get': 135 | case 'set': 136 | return true 137 | default: 138 | return false 139 | } 140 | } 141 | 142 | function ServiceInterfaceClientSession (req, res) { 143 | this.id = req.headers['x-session-id'] || req.id 144 | this._req = req 145 | this._res = res 146 | } 147 | 148 | ServiceInterfaceClientSession.prototype.send = function (msg) { 149 | if (this._res.finished) { 150 | log.warn('ServiceInterfaceClientSession already ended, dropped message', msg) 151 | return 152 | } 153 | 154 | if (this._res.statusCode < 400 && msg.op === 'err') { 155 | if (msg.value === 'auth') { 156 | this._res.statusCode = 403 157 | } else { 158 | this._res.statusCode = 400 159 | } 160 | } 161 | 162 | log.debug('ServiceInterfaceClientSession Send', this._res.statusCode, msg) 163 | this._res.write(JSON.stringify(msg)) 164 | this._res.end() 165 | } 166 | 167 | ServiceInterface.prototype._processIncomingMessage = function (message, req, res) { 168 | const self = this 169 | 170 | if (!allowedOp(message.op)) { 171 | const err = new Error('Only get and set op allowed via ServiceInterface') 172 | err.statusCode = 400 173 | return error(err, res) 174 | } 175 | 176 | this._middlewareRunner.runMiddleware('onServiceInterfaceIncomingMessage', message, req, res, function (err) { 177 | if (err) { return error(err, res) } 178 | 179 | const clientSession = new ServiceInterfaceClientSession(req, res) 180 | 181 | message.ack = message.ack || clientSession.id 182 | log.info('ServiceInterface request', message) 183 | self.emit('request', clientSession, message) 184 | }) 185 | } 186 | 187 | function setup (httpServer, middlewareRunner) { 188 | const serviceInterface = new ServiceInterface(middlewareRunner) 189 | httpAttach(httpServer, function () { 190 | serviceInterface.middleware.apply(serviceInterface, arguments) 191 | }) 192 | 193 | return serviceInterface 194 | } 195 | 196 | module.exports = ServiceInterface 197 | module.exports.setup = setup 198 | -------------------------------------------------------------------------------- /src/server/session_manager.js: -------------------------------------------------------------------------------- 1 | const log = require('minilog')('radar:session_manager') 2 | const ClientSession = require('../client/client_session') 3 | const { EventEmitter } = require('events') 4 | const { inherits } = require('util') 5 | const { observable, observe } = require('mobx') 6 | const _ = require('lodash') 7 | 8 | function SessionManager (opt) { 9 | const self = this 10 | this.sessions = observable.map({}) 11 | observe(self.sessions, function (change) { 12 | self.emit('change', change) 13 | }) 14 | 15 | this.adapters = (opt && opt.adapters) || [] 16 | this.adapters.forEach(function (adapter) { 17 | if (!self.isValidAdapter(adapter)) { 18 | throw new TypeError('Invalid Adapter: ' + adapter) 19 | } 20 | }) 21 | } 22 | inherits(SessionManager, EventEmitter) 23 | 24 | SessionManager.prototype.isValidAdapter = function (adapter) { 25 | return adapter && 26 | typeof adapter.canAdapt === 'function' && 27 | typeof adapter.adapt === 'function' 28 | } 29 | 30 | // (ClientSesion|Any) => ClientSession? 31 | SessionManager.prototype.add = function (obj) { 32 | const self = this 33 | let session 34 | 35 | if (obj instanceof ClientSession) { 36 | session = obj 37 | } else if (this.canAdapt(obj)) { 38 | session = this.adapt(obj) 39 | } else { 40 | throw new TypeError('No adapter found for ' + obj) 41 | } 42 | 43 | if (this.has(session.id)) { 44 | log.info('Attemping to add duplicate session id:', session.id) 45 | return session 46 | } 47 | 48 | this.sessions.set(session.id, session) 49 | session.once('end', function () { 50 | self.sessions.delete(session.id) 51 | self.emit('end', session) 52 | }) 53 | return session 54 | } 55 | 56 | SessionManager.prototype.has = function (id) { 57 | return this.sessions.has(id) 58 | } 59 | 60 | SessionManager.prototype.length = function () { 61 | return this.sessions.size 62 | } 63 | 64 | SessionManager.prototype.get = function (id) { 65 | return this.sessions.get(id) 66 | } 67 | 68 | // (Any) => Boolean 69 | SessionManager.prototype.canAdapt = function (obj) { 70 | return this.adapters.some(function (adapter) { 71 | return adapter.canAdapt(obj) 72 | }) 73 | } 74 | 75 | // (Any) => ClientSession? 76 | SessionManager.prototype.adapt = function (obj) { 77 | const adapter = _.find(this.adapters, function (adapter) { 78 | return adapter.canAdapt(obj) 79 | }) 80 | log.info('Adapting ClientSession with ' + nameOf(adapter)) 81 | const adapted = adapter && adapter.adapt(obj) 82 | return adapted || null 83 | } 84 | 85 | function nameOf (obj) { 86 | if (obj && obj.name) { 87 | return obj.name 88 | } 89 | if (obj && obj.constructor && obj.constructor.name) { 90 | return obj.constructor.name 91 | } 92 | return String(obj) 93 | } 94 | 95 | module.exports = SessionManager 96 | -------------------------------------------------------------------------------- /test/client.auth.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, before, after, afterEach */ 2 | const assert = require('assert') 3 | const common = require('./common.js') 4 | const Persistence = require('persistence') 5 | 6 | describe('auth test', function () { 7 | let radar, client 8 | before(function (done) { 9 | radar = common.spawnRadar() 10 | radar.sendCommand('start', common.configuration, function () { 11 | client = common.getClient('client_auth', 111, 0, 12 | { name: 'tester0' }, done) 13 | }) 14 | }) 15 | 16 | afterEach(function (done) { 17 | client.message('test').removeAllListeners() 18 | client.removeAllListeners('err') 19 | common.startPersistence(done) 20 | }) 21 | 22 | after(function (done) { 23 | client.dealloc('test') 24 | common.stopRadar(radar, done) 25 | }) 26 | 27 | describe('if type is disabled', function () { 28 | it('subscribe fails and emits err', function (done) { 29 | client.on('err', function (message) { 30 | assert.ok(message.origin) 31 | assert.strictEqual(message.origin.op, 'subscribe') 32 | assert.strictEqual(message.origin.to, 'message:/client_auth/disabled') 33 | setTimeout(done, 50) 34 | }) 35 | 36 | // Type client_auth/disabled is disabled in tests/lib/radar.js 37 | client.message('disabled').subscribe(function () { 38 | assert.ok(false) 39 | }) 40 | }) 41 | 42 | it('publish fails, emits err and is not persisted', function (done) { 43 | // Cache policy true for this type 44 | client.on('err', function (message) { 45 | assert.ok(message.origin) 46 | assert.strictEqual(message.origin.op, 'publish') 47 | assert.strictEqual(message.origin.to, 'message:/client_auth/disabled') 48 | Persistence.readOrderedWithScores('message:/client_auth/disabled', function (messages) { 49 | assert.deepStrictEqual([], messages) 50 | done() 51 | }) 52 | }) 53 | 54 | // Type client_auth/disabled is disabled in tests/lib/radar.js 55 | client.message('disabled').publish('xyz') 56 | }) 57 | }) 58 | 59 | describe('if type is not disabled', function () { 60 | it('should work', function (done) { 61 | const originalMessage = { hello: 'world', timestamp: Date.now() } 62 | 63 | client.message('enabled').on(function (message) { 64 | assert.deepStrictEqual(message.value, originalMessage) 65 | assert.strictEqual(message.to, 'message:/client_auth/enabled') 66 | done() 67 | }) 68 | 69 | client.on('err', function (message) { 70 | assert.ok(false) 71 | }) 72 | 73 | // Messages of the form 'disabled' are disabled 74 | client.message('enabled').subscribe().publish(originalMessage) 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /test/client.connect.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, afterEach, before, after */ 2 | const common = require('./common.js') 3 | const assert = require('assert') 4 | const Tracker = require('callback_tracker') 5 | let radar 6 | let client 7 | 8 | describe('Once radar server is running', function () { 9 | before(function (done) { 10 | const track = Tracker.create('before', done) 11 | 12 | radar = common.spawnRadar() 13 | radar.sendCommand('start', common.configuration, function () { 14 | client = common.getClient('dev', 123, 0, {}, track('client 1 ready')) 15 | }) 16 | }) 17 | 18 | afterEach(function () { 19 | client.dealloc('test') 20 | }) 21 | 22 | after(function (done) { 23 | common.stopRadar(radar, done) 24 | }) 25 | 26 | it('a client can nameSync successfully with ack', function (done) { 27 | const association = { id: 1, name: 'test_name' } 28 | const options = { association: association, clientVersion: '1.0.0' } 29 | 30 | client.control('test').nameSync(options, function (msg) { 31 | assert.strictEqual('nameSync', msg.op) 32 | assert.strictEqual('control:/dev/test', msg.to) 33 | done() 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/client.presence.sentry.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach, afterEach, before, after */ 2 | const common = require('./common.js') 3 | const assert = require('assert') 4 | const Persistence = require('../src/core').Persistence 5 | const Tracker = require('callback_tracker') 6 | const PresenceManager = require('../src/core/resources/presence/presence_manager.js') 7 | const assertHelper = require('./lib/assert_helper.js') 8 | const PresenceMessage = assertHelper.PresenceMessage 9 | const Sentry = require('../src/core/resources/presence/sentry.js') 10 | let radar 11 | let client 12 | 13 | describe('given a client and a server,', function () { 14 | let p 15 | const sentry = new Sentry('test-sentry', assertHelper.SentryDefaults) 16 | const presenceManager = new PresenceManager('presence:/dev/test', {}, sentry) 17 | const publishClientOnline = function (client) { 18 | presenceManager.addClient(client.clientId, client.userId, client.userType, client.userData) 19 | } 20 | 21 | before(function (done) { 22 | common.startPersistence(function () { 23 | radar = common.spawnRadar() 24 | radar.sendCommand('start', common.configuration, done) 25 | }) 26 | }) 27 | 28 | after(function (done) { 29 | radar.sendCommand('stop', {}, function () { 30 | radar.kill() 31 | common.endPersistence(done) 32 | }) 33 | }) 34 | 35 | beforeEach(function (done) { 36 | p = new PresenceMessage('dev', 'test') 37 | p.client = { userId: 100, clientId: 'abc', userData: { name: 'tester' }, userType: 2 } 38 | 39 | const track = Tracker.create('beforeEach', done) 40 | // Set ourselves alive 41 | sentry.start(assertHelper.SentryDefaults, function () { 42 | client = common.getClient('dev', 123, 0, { name: 'tester' }, track('client 1 ready')) 43 | }) 44 | }) 45 | 46 | afterEach(function (done) { 47 | p.teardown() 48 | client.presence('test').set('offline').removeAllListeners() 49 | client.dealloc('test') 50 | Persistence.delWildCard('*', done) 51 | }) 52 | 53 | describe('when listening to a presence,', function () { 54 | beforeEach(function (done) { 55 | client.presence('test').on(p.notify).subscribe(function () { 56 | done() 57 | }) 58 | }) 59 | 60 | describe('for incoming online messages,', function () { 61 | it('should emit offlines if sentry times out', function (done) { 62 | this.timeout(18000) 63 | 64 | const validate = function () { 65 | const ts = p.times 66 | p.assert_message_sequence([ 67 | 'online', 68 | 'client_online', 69 | 'client_implicit_offline', 70 | 'offline' 71 | ]) 72 | 73 | // sentry expiry = 4000 74 | assert.ok((ts[2] - ts[1]) >= 3000, 'sentry expiry was ' + (ts[2] - ts[1])) 75 | assert.ok((ts[2] - ts[1]) < 6000, 'sentry expiry was ' + (ts[2] - ts[1])) 76 | // user expiry = 1000 77 | assert.ok((ts[3] - ts[2]) >= 900, 'user expiry was ' + (ts[3] - ts[2])) 78 | assert.ok((ts[3] - ts[2]) < 2000, 'user expiry was ' + (ts[3] - ts[2])) 79 | done() 80 | } 81 | 82 | publishClientOnline(p.client) 83 | setTimeout(function () { 84 | sentry.stop() 85 | p.on(4, validate) 86 | }, 1000) 87 | }) 88 | 89 | it('should still be online if sentry is alive', function (done) { 90 | this.timeout(6000) 91 | const validate = function () { 92 | p.assert_message_sequence(['online', 'client_online']) 93 | done() 94 | } 95 | 96 | publishClientOnline(p.client) 97 | setTimeout(function () { 98 | // Renew sentry 99 | sentry._keepAlive() 100 | }, 2000) 101 | 102 | p.fail_on_more_than(2) 103 | p.once(2, function () { 104 | setTimeout(validate, 5000) 105 | }) 106 | }) 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /test/client.rate_limit.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach, afterEach, before, after */ 2 | const common = require('./common.js') 3 | const assert = require('assert') 4 | const Tracker = require('callback_tracker') 5 | const { PresenceMessage } = require('./lib/assert_helper.js') 6 | let radar 7 | let client 8 | 9 | describe('The Server', function () { 10 | let p 11 | before(function (done) { 12 | radar = common.spawnRadar() 13 | radar.sendCommand('start', common.configuration, done) 14 | }) 15 | 16 | after(function (done) { 17 | common.stopRadar(radar, done) 18 | }) 19 | 20 | beforeEach(function (done) { 21 | p = new PresenceMessage('dev', 'limited1') 22 | const track = Tracker.create('beforeEach', done) 23 | client = common.getClient('dev', 123, 0, { name: 'tester' }, track('client 1 ready')) 24 | }) 25 | 26 | afterEach(function () { 27 | p.teardown() 28 | 29 | client.presence('limited1').set('offline').removeAllListeners() 30 | client.dealloc('test') 31 | }) 32 | 33 | describe('given a limited presence type', function () { 34 | it('should not allow subscription after a certain limit', function (done) { 35 | let success = false 36 | 37 | client.on('err', function (message) { 38 | assert.strictEqual(message.op, 'err') 39 | assert.strictEqual(message.value, 'rate limited') 40 | success = true 41 | }) 42 | 43 | client.presence('limited1').on(p.notify).subscribe(function (message) { 44 | p.for_client(client).assert_ack_for_subscribe(message) 45 | client.presence('limited2').subscribe() 46 | }) 47 | 48 | setTimeout(function () { 49 | assert(success, 'Client did not receive an err message') 50 | done() 51 | }, 1000) 52 | }) 53 | 54 | it('should handle decrement on unsubscribe ', function (done) { 55 | client.presence('limited1').subscribe(function () { 56 | client.presence('limited2').subscribe() 57 | // Already received the err 58 | 59 | client.presence('limited1').unsubscribe(function () { 60 | client.presence('limited1').subscribe(function () { done() }) 61 | }) 62 | }) 63 | }) 64 | 65 | it.skip('should reset rate on client disconnect', function (done) { 66 | client.presence('limited1').subscribe(function () { 67 | client.presence('limited2').subscribe() 68 | // Already received the err 69 | client.dealloc('test') 70 | // we need to find a way to test client disconnect and reconnect 71 | done() 72 | }) 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /test/client.reconnect.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach, afterEach, before, after */ 2 | 3 | const common = require('./common.js') 4 | const assert = require('assert') 5 | const Tracker = require('callback_tracker') 6 | const { Backoff } = require('radar_client') 7 | let radar 8 | 9 | describe('When radar server restarts', function () { 10 | let client, client2 11 | 12 | before(function (done) { 13 | Backoff.durations = [100, 200, 400] 14 | Backoff.fallback = 200 15 | Backoff.maxSplay = 100 16 | 17 | radar = common.spawnRadar() 18 | radar.sendCommand('start', common.configuration, done) 19 | }) 20 | 21 | beforeEach(function (done) { 22 | const track = Tracker.create('beforeEach reconnect', done) 23 | client = common.getClient('test', 123, 0, { name: 'tester' }, track('client 1 ready')) 24 | client2 = common.getClient('test', 246, 0, { name: 'tester2' }, track('client 2 ready')) 25 | }) 26 | 27 | afterEach(function () { 28 | client.presence('restore').removeAllListeners() 29 | client.message('restore').removeAllListeners() 30 | client.status('restore').removeAllListeners() 31 | 32 | client.message('foo').removeAllListeners() 33 | 34 | client.dealloc('test') 35 | client2.dealloc('test') 36 | }) 37 | 38 | after(function (done) { 39 | common.stopRadar(radar, done) 40 | }) 41 | 42 | it('reestablishes presence', function (done) { 43 | this.timeout(8000) 44 | const verifySubscriptions = function () { 45 | setTimeout(function () { 46 | client2.presence('restore').get(function (message) { 47 | assert.strictEqual('get', message.op) 48 | assert.strictEqual('presence:/test/restore', message.to) 49 | assert.deepStrictEqual({ 123: 0 }, message.value) 50 | done() 51 | }) 52 | }, 1000) // let's wait a little 53 | } 54 | 55 | client.presence('restore').set('online', function () { 56 | common.restartRadar(radar, common.configuration, [client, client2], verifySubscriptions) 57 | }) 58 | }) 59 | 60 | it('reconnects existing clients', function (done) { 61 | this.timeout(8000) 62 | const clientEvents = [] 63 | const client2Events = [] 64 | 65 | const states = ['disconnected', 'connected', 'ready'] 66 | states.forEach(function (state) { 67 | client.once(state, function () { clientEvents.push(state) }) 68 | client2.once(state, function () { client2Events.push(state) }) 69 | }) 70 | 71 | common.restartRadar(radar, common.configuration, [client, client2], function () { 72 | assert.deepStrictEqual(clientEvents, ['disconnected', 'connected', 'ready']) 73 | assert.deepStrictEqual(client2Events, ['disconnected', 'connected', 'ready']) 74 | assert.strictEqual('activated', client.currentState()) 75 | assert.strictEqual('activated', client2.currentState()) 76 | done() 77 | }) 78 | }) 79 | 80 | it('resubscribes to subscriptions', function (done) { 81 | this.timeout(8000) 82 | const verifySubscriptions = function () { 83 | const tracker = Tracker.create('resources updated', done) 84 | 85 | client.message('restore').on(tracker('message updated', function (message) { 86 | assert.strictEqual(message.to, 'message:/test/restore') 87 | assert.strictEqual(message.op, 'publish') 88 | assert.strictEqual(message.value, 'hello') 89 | })).publish('hello') 90 | 91 | client.status('restore').on(tracker('status updated', function (message) { 92 | assert.strictEqual(message.to, 'status:/test/restore') 93 | assert.strictEqual(message.op, 'set') 94 | assert.strictEqual(message.value, 'hello') 95 | })).set('hello') 96 | 97 | const presenceDone = tracker('presence updated') 98 | client.presence('restore').on(function (message) { 99 | if (message.op === 'online') { 100 | assert.strictEqual(message.to, 'presence:/test/restore') 101 | presenceDone() 102 | } 103 | }).set('online') 104 | } 105 | 106 | const tracker = Tracker.create('subscriptions done', function () { 107 | common.restartRadar(radar, common.configuration, [client], verifySubscriptions) 108 | }) 109 | client.message('restore').subscribe(tracker('message subscribed')) 110 | client.presence('restore').subscribe(tracker('presence subscribed')) 111 | client.status('restore').subscribe(tracker('status subscribed')) 112 | }) 113 | 114 | it('must not repeat synced chat (messagelist) messages, with two clients', function (done) { 115 | this.timeout(8000) 116 | const messages = [] 117 | const verifySubscriptions = function () { 118 | assert.strictEqual(messages.length, 2) 119 | assert.ok(messages.some(function (m) { return m.value === 'a1' })) 120 | assert.ok(messages.some(function (m) { return m.value === 'a2' })) 121 | done() 122 | } 123 | 124 | client.alloc('test', function () { 125 | client2.alloc('test', function () { 126 | client.message('foo').on(function (msg) { 127 | messages.push(msg) 128 | console.log(messages) 129 | if (messages.length > 1) { 130 | // When we have enough, wait a while and check 131 | setTimeout(verifySubscriptions, 100) 132 | } 133 | }).sync() 134 | 135 | client2.message('foo').publish('a1', function () { 136 | common.restartRadar(radar, common.configuration, [client, client2], function () { 137 | client.message('foo').publish('a2') 138 | }) 139 | }) 140 | }) 141 | }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /test/client.socket_client_session_adapter.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach */ 2 | /* eslint-disable no-unused-expressions */ 3 | 4 | const SocketClientSessionAdapter = require('../src/client/socket_client_session_adapter') 5 | const ClientSession = require('../src/client/client_session') 6 | const chai = require('chai') 7 | chai.use(require('sinon-chai')) 8 | chai.use(require('chai-interface')) 9 | const expect = chai.expect 10 | const sinon = require('sinon') 11 | 12 | describe('SocketClientSessionAdapter', function () { 13 | let socketClientSessionAdapter 14 | beforeEach(function () { 15 | socketClientSessionAdapter = new SocketClientSessionAdapter(ClientSession) 16 | }) 17 | 18 | it('can be instantiated', function () { 19 | expect(socketClientSessionAdapter) 20 | .to.be.instanceof(SocketClientSessionAdapter) 21 | }) 22 | 23 | it('has interface', function () { 24 | expect(socketClientSessionAdapter).to.have.interface({ 25 | canAdapt: Function, 26 | adapt: Function 27 | }) 28 | }) 29 | 30 | it('takes ClientSession constructor in own constructor', function () { 31 | function Foo () {} 32 | socketClientSessionAdapter = new SocketClientSessionAdapter(Foo) 33 | expect(socketClientSessionAdapter.ClientSession) 34 | .to.equal(Foo) 35 | }) 36 | 37 | describe('#canAdapt', function () { 38 | describe('given a socket-like object', function () { 39 | const obj = { 40 | id: 5, 41 | send: function () {}, 42 | on: function () {}, 43 | once: function () {}, 44 | removeListener: function () {} 45 | } 46 | it('returns true', function () { 47 | expect(socketClientSessionAdapter.canAdapt(obj)) 48 | .to.be.true 49 | }) 50 | }) 51 | describe('given non-socket-like object', function () { 52 | const obj = { foo: 'bar' } 53 | it('returns false', function () { 54 | expect(socketClientSessionAdapter.canAdapt(obj)) 55 | .to.be.false 56 | }) 57 | }) 58 | describe('given null', function () { 59 | it('returns false', function () { 60 | expect(socketClientSessionAdapter.canAdapt(null)) 61 | .to.be.false 62 | }) 63 | }) 64 | }) 65 | 66 | describe('#adapt', function () { 67 | describe('given a socket-like object', function () { 68 | const socket = { 69 | id: 'foo' 70 | } 71 | let ctor 72 | 73 | beforeEach(function () { 74 | ctor = sinon.stub() 75 | socketClientSessionAdapter.ClientSession = ctor 76 | }) 77 | 78 | it('news ClientSession with id and transport', function () { 79 | socketClientSessionAdapter.adapt(socket) 80 | expect(ctor) 81 | .to.have.been.calledWith(undefined, 'foo', undefined, undefined, socket) 82 | expect(ctor) 83 | .to.have.been.calledWithNew 84 | }) 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const path = require('path') 3 | const logging = require('minilog')('common') 4 | const formatter = require('./lib/formatter') 5 | const Persistence = require('persistence') 6 | const { server: RadarServer } = require('../index') 7 | const configuration = require('../configurator').load({ persistence: true }) 8 | const Sentry = require('../src/core/resources/presence/sentry') 9 | const { constructor: Client } = require('radar_client') 10 | const { fork } = require('child_process') 11 | const Tracker = require('callback_tracker') 12 | 13 | Sentry.expiry = 4000 14 | if (process.env.verbose) { 15 | const Minilog = require('minilog') 16 | // Configure log output 17 | Minilog.pipe(Minilog.suggest.deny(/.*/, (process.env.radar_log ? process.env.radar_log : 'debug'))) 18 | .pipe(formatter) 19 | .pipe(Minilog.backends.nodeConsole.formatColor) 20 | .pipe(process.stdout) 21 | 22 | require('radar_client')._log.pipe(Minilog.suggest.deny(/.*/, (process.env.radar_log ? process.env.radar_log : 'debug'))) 23 | .pipe(formatter) 24 | .pipe(Minilog.backends.nodeConsole.formatColor) 25 | .pipe(process.stdout) 26 | } 27 | 28 | http.globalAgent.maxSockets = 10000 29 | 30 | module.exports = { 31 | spawnRadar: function () { 32 | function getListener (action, callbackFn) { 33 | const listener = function (message) { 34 | message = JSON.parse(message) 35 | logging.debug('message received', message, action) 36 | if (message.action === action) { 37 | if (callbackFn) callbackFn(message.error) 38 | } 39 | } 40 | return listener 41 | } 42 | 43 | const radarProcess = fork(path.join(__dirname, '/lib/radar.js')) 44 | radarProcess.sendCommand = function (command, arg, callbackFn) { 45 | const listener = getListener(command, function (error) { 46 | logging.debug('removing listener', command) 47 | radarProcess.removeListener('message', listener) 48 | if (callbackFn) callbackFn(error) 49 | }) 50 | 51 | radarProcess.on('message', listener) 52 | radarProcess.send(JSON.stringify({ 53 | action: command, 54 | arg: configuration 55 | })) 56 | } 57 | 58 | process.on('exit', function () { 59 | if (radarProcess.running) { 60 | radarProcess.kill() 61 | } 62 | }) 63 | 64 | radarProcess.running = true 65 | radarProcess.port = configuration.port 66 | return radarProcess 67 | }, 68 | 69 | stopRadar: function (radar, done) { 70 | radar.sendCommand('stop', {}, function () { 71 | radar.kill() 72 | radar.running = false 73 | done() 74 | }) 75 | }, 76 | 77 | restartRadar: function (radar, configuration, clients, callbackFn) { 78 | const tracker = Tracker.create('server restart, given clients ready', function () { 79 | if (callbackFn) setTimeout(callbackFn, 5) 80 | }) 81 | 82 | for (let i = 0; i < clients.length; i++) { 83 | clients[i].once('ready', tracker('client ' + i + ' ready')) 84 | } 85 | 86 | const serverRestart = tracker('server restart') 87 | 88 | radar.sendCommand('stop', {}, function () { 89 | radar.sendCommand('start', configuration, serverRestart) 90 | }) 91 | }, 92 | 93 | startPersistence: function (done) { 94 | Persistence.setConfig(configuration.persistence) 95 | Persistence.connect(function () { 96 | Persistence.delWildCard('*', done) 97 | }) 98 | }, 99 | endPersistence: function (done) { 100 | Persistence.delWildCard('*', function () { 101 | Persistence.disconnect(done) 102 | }) 103 | }, 104 | getClient: function (account, userId, userType, userData, done) { 105 | const client = new Client().configure({ 106 | userId: userId, 107 | userType: userType, 108 | accountName: account, 109 | port: configuration.port, 110 | upgrade: false, 111 | userData: userData 112 | }).once('ready', done).alloc('test') 113 | return client 114 | }, 115 | configuration: configuration, 116 | 117 | // Create an in-process radar server, not a child process. 118 | createRadarServer: function (done) { 119 | const notFound = function p404 (req, res) {} 120 | const httpServer = http.createServer(notFound) 121 | 122 | const radarServer = new RadarServer() 123 | radarServer.attach(httpServer, configuration) 124 | 125 | if (done) { 126 | setTimeout(done, 200) 127 | } 128 | 129 | return radarServer 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/configurator.test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | const assert = require('assert') 4 | const noArgs = ['', ''] 5 | const noEnv = {} 6 | const Configurator = require('../configurator') 7 | 8 | // Helper function. It tests multiple features of a given configuration option. 9 | function describeOptionTest (configurator, name, options) { 10 | describe('option: ' + name, function () { 11 | if (options.default) { 12 | it('default must be ' + options.default, function () { 13 | const config = configurator.load({}) 14 | assert.strictEqual(config[name].toString(), options.default.toString()) 15 | }) 16 | } 17 | 18 | it('config: ' + name, function () { 19 | const configOptions = {} 20 | configOptions[name] = options.expected 21 | const config = configurator.load({ config: configOptions, argv: noArgs, env: noEnv }) 22 | assert.strictEqual(config[name], options.expected) 23 | }) 24 | 25 | it('env: ' + options.env, function () { 26 | const envOptions = {} 27 | envOptions[options.env] = options.expected 28 | const config = configurator.load({ env: envOptions }) 29 | assert.strictEqual(config[name], options.expected) 30 | }) 31 | 32 | if (options.short) { 33 | it('short arg: ' + options.short, function () { 34 | const config = configurator.load({ argv: ['', '', options.short, options.expected] }) 35 | assert.strictEqual(config[name], options.expected) 36 | }) 37 | } 38 | 39 | if (options.long) { 40 | it('long arg: ' + options.long, function () { 41 | const config = configurator.load({ argv: ['', '', options.long, options.expected] }) 42 | assert.strictEqual(config[name], options.expected) 43 | }) 44 | } 45 | }) 46 | } 47 | 48 | describe('the Configurator', function () { 49 | it('has a default configuration', function () { 50 | const config = new Configurator().load() 51 | assert.notStrictEqual(8000, config.port) 52 | }) 53 | 54 | describe('while dealing with env vars', function () { 55 | it('env vars should win over default configuration', function () { 56 | const config = new Configurator().load({ 57 | config: { port: 8000 }, 58 | argv: noArgs, 59 | env: { RADAR_PORT: 8001 } 60 | }) 61 | 62 | assert.strictEqual(8001, config.port) 63 | }) 64 | 65 | it('should only overwrite the right keys', function () { 66 | const config = Configurator.load({ 67 | config: { port: 8004 }, 68 | env: { 69 | RADAR_SENTINEL_MASTER_NAME: 'mymaster', 70 | RADAR_SENTINEL_URLS: 'sentinel://localhost:7777' 71 | } 72 | }) 73 | assert.strictEqual(8004, config.port) 74 | assert.strictEqual('mymaster', config.sentinelMasterName) 75 | }) 76 | }) 77 | 78 | describe('default settings', function () { 79 | const configurator = new Configurator() 80 | 81 | describeOptionTest(configurator, 'port', { 82 | default: 8000, 83 | expected: 8004, 84 | short: '-p', 85 | long: '--port', 86 | env: 'RADAR_PORT' 87 | }) 88 | 89 | describeOptionTest(configurator, 'redisUrl', { 90 | default: 'redis://localhost:6379', 91 | expected: 'redis://localhost:9000', 92 | short: '-r', 93 | long: '--redis_url', 94 | env: 'RADAR_REDIS_URL' 95 | }) 96 | 97 | describeOptionTest(configurator, 'sentinelMasterName', { 98 | expected: 'mymaster', 99 | long: '--sentinel_master_name', 100 | env: 'RADAR_SENTINEL_MASTER_NAME' 101 | }) 102 | 103 | describeOptionTest(configurator, 'sentinelUrls', { 104 | expected: 'sentinel://localhost:1000', 105 | long: '--sentinel_urls', 106 | env: 'RADAR_SENTINEL_URLS' 107 | }) 108 | }) 109 | 110 | describe('custom setting', function () { 111 | const newOption = { 112 | name: 'testOption', 113 | description: 'test option', 114 | env: 'RADAR_TEST', 115 | abbr: 'e', 116 | full: 'exp', 117 | default: 'testDefault' 118 | } 119 | const configurator = new Configurator([newOption]) 120 | 121 | describeOptionTest(configurator, 'testOption', { 122 | default: 'testDefault', 123 | expected: 'expected', 124 | long: '--exp', 125 | short: '-e', 126 | env: 'RADAR_TEST' 127 | }) 128 | }) 129 | }) 130 | -------------------------------------------------------------------------------- /test/core.id.unit.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach */ 2 | /* eslint-disable no-unused-expressions */ 3 | 4 | const chai = require('chai') 5 | const expect = chai.expect 6 | const _ = require('lodash') 7 | const sinon = require('sinon') 8 | chai.use(require('sinon-chai')) 9 | 10 | describe('id', function () { 11 | const id = require('../src/core/id') 12 | 13 | beforeEach(function () { 14 | id.setGenerator(id.defaultGenerator) 15 | }) 16 | 17 | it('can generate unique string ids', function () { 18 | const ids = [] 19 | ids[0] = id() 20 | ids[1] = id() 21 | ids[2] = id() 22 | expect(ids[0]).to.be.a('string') 23 | expect(ids[1]).to.be.a('string') 24 | expect(ids[2]).to.be.a('string') 25 | expect(_.uniq(ids)).to.deep.equal(ids) 26 | }) 27 | 28 | describe('.setGenerator', function () { 29 | it('can override the function used to generate ids', function () { 30 | const generator = sinon.stub().returns('abc') 31 | id.setGenerator(generator) 32 | const out = id() 33 | expect(generator).to.have.been.called 34 | expect(out).to.equal('abc') 35 | }) 36 | }) 37 | describe('.defaultGenerator', function () { 38 | it('is the default generator function', function () { 39 | expect(id.defaultGenerator).to.be.a('function') 40 | expect(id.defaultGenerator()).to.be.a('string') 41 | }) 42 | it('is read-only', function () { 43 | expect(function () { 44 | 'use strict' 45 | id.defaultGenerator = function () {} 46 | }).to.throw() 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /test/integration/service_interface.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach, before, after */ 2 | const { expect } = require('chai') 3 | const fetch = require('node-fetch') 4 | const qs = require('querystring') 5 | 6 | const common = require('../common.js') 7 | 8 | describe('Given a Radar server', function () { 9 | let endpoint 10 | let options 11 | let radar 12 | before(function (done) { 13 | radar = common.spawnRadar() 14 | radar.sendCommand('start', common.configuration, function () { 15 | done() 16 | }) 17 | }) 18 | after(function (done) { 19 | common.stopRadar(radar, done) 20 | }) 21 | process.on('exit', function () { 22 | if (radar) { common.stopRadar(radar) } 23 | }) 24 | 25 | beforeEach(function () { 26 | endpoint = 'http://localhost:' + radar.port + '/radar/service' 27 | options = { 28 | method: 'POST', 29 | headers: { 30 | 'Content-Type': 'application/json' 31 | }, 32 | body: JSON.stringify({ op: 'get', to: 'sd' }) 33 | } 34 | }) 35 | 36 | describe('given invalid content-type', function () { 37 | beforeEach(function () { 38 | delete options.headers['Content-Type'] 39 | }) 40 | it('returns 415', function () { 41 | return fetch(endpoint, options).then(function (res) { 42 | expect(res.status).to.equal(415) 43 | }) 44 | }) 45 | }) 46 | 47 | describe('POST radar message', function () { 48 | describe('given invalid json body', function () { 49 | beforeEach(function () { 50 | options.body = JSON.stringify({ 51 | op: 'get', 52 | to: 'status:/account/blah' 53 | }).substring(15) 54 | }) 55 | it('returns error', function () { 56 | return fetch(endpoint, options).then(function (res) { 57 | expect(res.status).to.equal(400) 58 | return res.text().then(console.log) 59 | }) 60 | }) 61 | }) 62 | 63 | describe('Presence get', function () { 64 | describe('given no clients online on a Presence resource', function () { 65 | it('gets v1', function () { 66 | options.body = JSON.stringify({ 67 | op: 'get', 68 | to: 'presence:/account/ticket/1' 69 | }) 70 | 71 | return fetch(endpoint, options).then(function (res) { 72 | expect(res.status).to.equal(200) 73 | 74 | return res.json() 75 | }).then(function (body) { 76 | expect(body).to.deep.equal({ 77 | op: 'get', 78 | to: 'presence:/account/ticket/1', 79 | value: {} 80 | }) 81 | }) 82 | }) 83 | it('gets v2', function () { 84 | options.body = JSON.stringify({ 85 | op: 'get', 86 | to: 'presence:/account/ticket/1', 87 | options: { version: 2 } 88 | }) 89 | 90 | return fetch(endpoint, options).then(function (res) { 91 | expect(res.status).to.equal(200) 92 | 93 | return res.json() 94 | }).then(function (body) { 95 | expect(body).to.deep.equal({ 96 | op: 'get', 97 | to: 'presence:/account/ticket/1', 98 | value: {} 99 | }) 100 | }) 101 | }) 102 | }) 103 | describe('given a client online on a presence resource', function () { 104 | let client 105 | const scope = 'test_presence_with_clients' 106 | const USER_TYPE_AGENT = 2 107 | before(function (done) { 108 | client = common.getClient('account', 123, USER_TYPE_AGENT, {}, function () { 109 | client.presence(scope).set('online', function (ack) { 110 | done() 111 | }) 112 | }) 113 | }) 114 | 115 | after(function (done) { 116 | client.presence(scope).set('offline', function (ack) { 117 | client.dealloc('test') 118 | done() 119 | }).removeAllListeners() 120 | }) 121 | 122 | it('gets v1', function () { 123 | options.body = JSON.stringify({ 124 | op: 'get', 125 | to: 'presence:/account/' + scope 126 | }) 127 | 128 | return fetch(endpoint, options).then(function (res) { 129 | expect(res.status).to.equal(200) 130 | return res.json() 131 | }).then(function (body) { 132 | expect(body).to.deep.equal({ 133 | op: 'get', 134 | to: 'presence:/account/' + scope, 135 | value: { 136 | 123: USER_TYPE_AGENT 137 | } 138 | }) 139 | }) 140 | }) 141 | 142 | it('gets v2', function () { 143 | options.body = JSON.stringify({ 144 | op: 'get', 145 | to: 'presence:/account/' + scope, 146 | options: { version: 2 } 147 | }) 148 | 149 | return fetch(endpoint, options).then(function (res) { 150 | expect(res.status).to.equal(200) 151 | return res.json() 152 | }).then(function (body) { 153 | const expected = { 154 | op: 'get', 155 | to: 'presence:/account/' + scope, 156 | value: { 157 | 123: { 158 | clients: {}, 159 | userType: USER_TYPE_AGENT 160 | } 161 | } 162 | } 163 | expected.value[123].clients[client.currentClientId()] = {} 164 | 165 | expect(body).to.deep.equal(expected) 166 | }) 167 | }) 168 | }) 169 | }) 170 | 171 | it('Status get', function () { 172 | options.body = JSON.stringify({ 173 | op: 'get', 174 | to: 'status:/account/ticket/1' 175 | }) 176 | 177 | return fetch(endpoint, options).then(function (res) { 178 | expect(res.status).to.equal(200) 179 | return res.json() 180 | }).then(function (body) { 181 | expect(body).to.deep.equal({ 182 | op: 'get', 183 | to: 'status:/account/ticket/1', 184 | value: {} 185 | }) 186 | }) 187 | }) 188 | 189 | describe('when setting and then getting a Status', function () { 190 | it('expects the same value back', function () { 191 | const value = Date.now() 192 | 193 | const set = Object.create(options) 194 | set.body = JSON.stringify({ 195 | op: 'set', 196 | to: 'status:/account/name', 197 | key: 123, 198 | value: value 199 | }) 200 | 201 | const get = Object.create(options) 202 | get.body = JSON.stringify({ 203 | op: 'get', 204 | to: 'status:/account/name' 205 | }) 206 | 207 | return fetch(endpoint, set).then(function (res) { 208 | expect(res.status).to.equal(200) 209 | 210 | return fetch(endpoint, get) 211 | }).then(function (res) { 212 | expect(res.status).to.equal(200) 213 | return res.json() 214 | }).then(function (body) { 215 | expect(body).to.deep.equal({ 216 | op: 'get', 217 | to: 'status:/account/name', 218 | value: { 219 | 123: value 220 | } 221 | }) 222 | }) 223 | }) 224 | }) 225 | }) 226 | 227 | describe('GET with querystring', function () { 228 | beforeEach(function () { 229 | options.method = 'GET' 230 | delete options.body 231 | }) 232 | it('Presence get', function () { 233 | const url = endpoint + '?' + qs.stringify({ 234 | to: 'presence:/account/ticket/1' 235 | }) 236 | return fetch(url, options).then(function (res) { 237 | expect(res.status).to.equal(200) 238 | return res.json() 239 | }).then(function (body) { 240 | expect(body).to.deep.equal({ 241 | op: 'get', 242 | to: 'presence:/account/ticket/1', 243 | value: {} 244 | }) 245 | }) 246 | }) 247 | it('Status get', function () { 248 | const url = endpoint + '?' + qs.stringify({ 249 | to: 'status:/account/ticket/1' 250 | }) 251 | return fetch(url, options).then(function (res) { 252 | expect(res.status).to.equal(200) 253 | return res.json() 254 | }).then(function (body) { 255 | expect(body).to.deep.equal({ 256 | op: 'get', 257 | to: 'status:/account/ticket/1', 258 | value: {} 259 | }) 260 | }) 261 | }) 262 | }) 263 | }) 264 | -------------------------------------------------------------------------------- /test/lib/formatter.js: -------------------------------------------------------------------------------- 1 | const Minilog = require('minilog') 2 | 3 | const formatter = new Minilog.Transform() 4 | formatter.nameLength = 22 5 | formatter.write = function (name, level, args) { 6 | let i 7 | if (this.nameLength < name.length) { 8 | this.nameLength = name.length 9 | } 10 | for (i = name.length; i < this.nameLength; i++) { 11 | name = name.concat(' ') 12 | } 13 | const result = [].concat(args) 14 | for (i = 0; i < result.length; i++) { 15 | if (result[i] && typeof result[i] === 'object') { 16 | // Buffers in Node.js look bad when stringified 17 | if (result[i].constructor && result[i].constructor.isBuffer) { 18 | result[i] = result[i].toString() 19 | } else { 20 | try { 21 | result[i] = JSON.stringify(result[i]) 22 | } catch (stringifyError) { 23 | // Happens when an object has a circular structure 24 | // Do not throw an error, when printing, the toString() method of the object will be used 25 | } 26 | } 27 | } else { 28 | result[i] = result[i] // eslint-disable-line 29 | } 30 | } 31 | 32 | this.emit('item', name, level, [result.join(' ') + '\n']) 33 | } 34 | 35 | module.exports = formatter 36 | -------------------------------------------------------------------------------- /test/lib/radar.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const Radar = require('../../index.js') 3 | const Middleware = require('../../src/middleware') 4 | const { QuotaManager, LegacyAuthManager } = Middleware 5 | const Persistence = require('persistence') 6 | const { Type } = require('../../src/core') 7 | const Minilog = require('minilog') 8 | const logger = Minilog('lib_radar') 9 | const formatter = require('./formatter.js') 10 | const assertHelper = require('./assert_helper.js') 11 | let serverStarted = false 12 | let radar 13 | let httpServer 14 | 15 | if (process.env.verbose) { 16 | let minilogPipe = Minilog 17 | 18 | // Configure log output 19 | if (process.env.radar_log) { 20 | minilogPipe = minilogPipe.pipe(Minilog.suggest.deny(/.*/, process.env.radar_log)) 21 | } 22 | 23 | minilogPipe.pipe(formatter) 24 | .pipe(Minilog.backends.nodeConsole.formatColor) 25 | .pipe(process.stdout) 26 | } 27 | 28 | function p404 (req, res) { 29 | res.statusCode = 404 30 | res.end('404 Not Found') 31 | } 32 | 33 | Type.add([ 34 | { // For client.auth.test 35 | name: 'client_auth', 36 | expression: /^message:\/client_auth\/disabled$/, 37 | type: 'MessageList', 38 | policy: { cache: true, maxAgeSeconds: 30 }, 39 | authProvider: { 40 | authorize: function () { return false } 41 | } 42 | }, 43 | { 44 | name: 'client_auth', 45 | expression: /^message:\/client_auth\/enabled$/, 46 | type: 'MessageList', 47 | authProvider: { 48 | authorize: function () { return true } 49 | } 50 | }, 51 | { // For client.message.test 52 | name: 'cached_chat', 53 | expression: /^message:\/dev\/cached_chat\/(.+)/, 54 | type: 'MessageList', 55 | policy: { cache: true, maxAgeSeconds: 30 } 56 | }, 57 | { // For client.presence.test 58 | name: 'short_expiry', 59 | expression: /^presence:\/dev\/test/, 60 | type: 'Presence', 61 | policy: { userExpirySeconds: 1 } 62 | }, 63 | { 64 | name: 'short_stream', 65 | expression: /^stream:\/dev\/short_stream\/(.+)/, 66 | type: 'Stream', 67 | policy: { maxLength: 2 } 68 | }, 69 | { 70 | name: 'uncached_stream', 71 | expression: /^stream:\/dev\/uncached_stream\/(.+)/, 72 | type: 'Stream', 73 | policy: { maxLength: 0 } 74 | }, 75 | { 76 | name: 'general control', 77 | type: 'Control', 78 | expression: /^control:/ 79 | }, 80 | { // For client.presence.test 81 | name: 'limited', 82 | expression: /^presence:\/dev\/limited/, 83 | type: 'Presence', 84 | policy: { 85 | limit: 1 86 | } 87 | } 88 | ]) 89 | 90 | const Service = {} 91 | 92 | Service.start = function (configuration, callbackFn) { 93 | logger.debug('creating radar', configuration) 94 | httpServer = http.createServer(p404) 95 | 96 | // Add sentry defaults for testing. 97 | configuration.sentry = assertHelper.SentryDefaults 98 | const RadarServer = Radar.server 99 | radar = new RadarServer() 100 | 101 | radar.use(new QuotaManager()) 102 | radar.use(new LegacyAuthManager()) 103 | 104 | radar.ready.then(function () { 105 | httpServer.listen(configuration.port, function () { 106 | logger.debug('httpServer listening on', configuration.port) 107 | serverStarted = true 108 | Persistence.delWildCard('*', function () { 109 | logger.info('Persistence cleared') 110 | callbackFn() 111 | }) 112 | }) 113 | }) 114 | 115 | radar.attach(httpServer, configuration) 116 | } 117 | 118 | Service.stop = function (arg, callbackFn) { 119 | let serverTimeout 120 | logger.info('stop') 121 | 122 | httpServer.on('close', function () { 123 | logger.info('httpServer closed') 124 | if (serverStarted) { 125 | clearTimeout(serverTimeout) 126 | logger.info('Calling callbackFn, close event') 127 | serverStarted = false 128 | callbackFn() 129 | } 130 | }) 131 | 132 | Persistence.delWildCard('*', function () { 133 | radar.terminate(function () { 134 | logger.info('radar terminated') 135 | if (!serverStarted) { 136 | logger.info('httpServer terminated') 137 | callbackFn() 138 | } else { 139 | logger.info('closing httpServer') 140 | logger.info('connections left', httpServer._connections) 141 | httpServer.close() 142 | serverTimeout = setTimeout(function () { 143 | // Failsafe, because server.close does not always 144 | // throw the close event within time. 145 | if (serverStarted) { 146 | serverStarted = false 147 | logger.info('Calling callbackFn, timeout') 148 | callbackFn() 149 | } 150 | }, 200) 151 | } 152 | }) 153 | }) 154 | } 155 | 156 | process.on('message', function (message) { 157 | const command = JSON.parse(message) 158 | 159 | const complete = function (error) { 160 | logger.debug('complete: ', error, command.action) 161 | process.send(JSON.stringify({ 162 | action: command.action, 163 | error: error 164 | })) 165 | } 166 | 167 | if (Service[command.action]) { 168 | Service[command.action](command.arg, complete) 169 | } else { 170 | complete('NotFound') 171 | } 172 | }) 173 | -------------------------------------------------------------------------------- /test/message_list.unit.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach, before, after */ 2 | const assert = require('assert') 3 | const Common = require('./common') 4 | const MessageList = require('../src/core/resources/message_list') 5 | const Persistence = require('persistence') 6 | 7 | describe('For a message list', function () { 8 | let messageList 9 | const Radar = { 10 | broadcast: function () {} 11 | } 12 | const FakePersistence = { 13 | read: function () {}, 14 | persist: function () {}, 15 | publish: function () {}, 16 | expire: function () {} 17 | } 18 | 19 | before(function () { 20 | MessageList.setBackend(FakePersistence) 21 | }) 22 | 23 | after(function () { 24 | MessageList.setBackend(Persistence) 25 | }) 26 | 27 | beforeEach(function () { 28 | messageList = new MessageList('aaa', Radar, {}) 29 | FakePersistence.publish = function () {} 30 | FakePersistence.readOrderedWithScores = function () {} 31 | FakePersistence.persistOrdered = function () {} 32 | FakePersistence.expire = function () {} 33 | }) 34 | 35 | describe('publishing', function () { 36 | it('causes a publish to persistence', function () { 37 | let publishCalled = false 38 | FakePersistence.publish = function (key, message) { 39 | assert.strictEqual('hello world', message) 40 | publishCalled = true 41 | } 42 | 43 | messageList.publish({}, 'hello world') 44 | assert.ok(publishCalled) 45 | }) 46 | 47 | describe('if cache is set to true', function () { 48 | it('causes a broadcast and a write', function () { 49 | let publishCalled = false 50 | let persistCalled = false 51 | FakePersistence.publish = function (key, message) { 52 | assert.strictEqual('hello world', message) 53 | publishCalled = true 54 | } 55 | FakePersistence.persistOrdered = function () { 56 | persistCalled = true 57 | } 58 | const messageList = new MessageList('aab', Radar, { policy: { cache: true } }) 59 | messageList.publish({}, 'hello world') 60 | assert.ok(publishCalled) 61 | assert.ok(persistCalled) 62 | }) 63 | 64 | it('sets expiry if maxPersistence is provided', function () { 65 | let expiryTime 66 | FakePersistence.expire = function (name, expiry) { 67 | expiryTime = expiry 68 | } 69 | const messageList = new MessageList('aab', Radar, { policy: { cache: true, maxPersistence: 24 * 60 * 60 } }) 70 | messageList.publish({}, 'hello world') 71 | assert.strictEqual(expiryTime, 24 * 60 * 60) 72 | }) 73 | 74 | it('sets expiry to default maxPersistence if none provided', function () { 75 | let expiryTime 76 | FakePersistence.expire = function (name, expiry) { 77 | expiryTime = expiry 78 | } 79 | const messageList = new MessageList('aab', Radar, { policy: { cache: true } }) 80 | messageList.publish({}, 'hello world') 81 | assert.strictEqual(expiryTime, 14 * 24 * 60 * 60) 82 | }) 83 | }) 84 | }) 85 | 86 | describe('syncing', function () { 87 | it('causes a read', function (done) { 88 | const messageList = new MessageList('aab', Radar, { policy: { cache: true } }) 89 | FakePersistence.readOrderedWithScores = function (key, value, callbackFn) { 90 | assert.strictEqual('aab', key) 91 | callbackFn([1, 2]) 92 | } 93 | 94 | messageList.sync({ 95 | send: function (msg) { 96 | // Check message 97 | assert.strictEqual('sync', msg.op) 98 | assert.strictEqual('aab', msg.to) 99 | assert.deepStrictEqual([1, 2], msg.value) 100 | done() 101 | } 102 | }, {}) 103 | }) 104 | 105 | it('renews expiry for maxPersistence', function (done) { 106 | let expiryTime 107 | const messageList = new MessageList('aab', Radar, { policy: { cache: true, maxPersistence: 24 * 60 * 60 } }) 108 | FakePersistence.readOrderedWithScores = function (key, value, callbackFn) { 109 | assert.strictEqual('aab', key) 110 | callbackFn([1, 2]) 111 | } 112 | FakePersistence.expire = function (name, expiry) { 113 | expiryTime = expiry 114 | } 115 | 116 | messageList.sync({ 117 | send: function () { 118 | assert.strictEqual(expiryTime, 24 * 60 * 60) 119 | done() 120 | } 121 | }, {}) 122 | }) 123 | }) 124 | 125 | describe('unsubscribing', function () { 126 | it('renews expiry for maxPersistence', function (done) { 127 | const messageList = new MessageList('aab', Radar, { policy: { cache: true, maxPersistence: 24 * 60 * 60 } }) 128 | messageList.server = { destroyResource: function () {} } 129 | FakePersistence.expire = function (name, expiry) { 130 | assert.strictEqual(expiry, 24 * 60 * 60) 131 | setTimeout(done, 1) 132 | } 133 | 134 | messageList.unsubscribe({ 135 | send: function () {} 136 | }, {}) 137 | }) 138 | }) 139 | 140 | it('default maxPersistence is 14 days', function () { 141 | assert.strictEqual(messageList.options.policy.maxPersistence, 14 * 24 * 60 * 60) 142 | }) 143 | 144 | it('default caching is false', function () { 145 | assert.ok(!messageList.options.policy.cache) 146 | }) 147 | }) 148 | 149 | describe('a message list resource', function () { 150 | describe('emitting messages', function () { 151 | let radarServer 152 | 153 | beforeEach(function (done) { 154 | radarServer = Common.createRadarServer(done) 155 | }) 156 | 157 | it('should emit incomming messages', function (done) { 158 | const subscribeMessage = { op: 'subscribe', to: 'message:/z1/test/ticket/1' } 159 | 160 | radarServer.on('resource:new', function (resource) { 161 | resource.on('message:incoming', function (message) { 162 | assert.strictEqual(message.to, subscribeMessage.to) 163 | done() 164 | }) 165 | }) 166 | 167 | setTimeout(function () { 168 | radarServer._processMessage({}, subscribeMessage) 169 | }, 100) 170 | }) 171 | 172 | it('should emit outgoing messages', function (done) { 173 | const subscribeMessage = { op: 'subscribe', to: 'message:/z1/test/ticket/1' } 174 | const publishMessage = { op: 'publish', to: 'message:/z1/test/ticket/1', value: { type: 'activity', user_id: 123456789, state: 4 } } 175 | const socketOne = { id: 1, send: function (m) {} } 176 | const socketTwo = { id: 2, send: function (m) {} } 177 | 178 | radarServer.on('resource:new', function (resource) { 179 | resource.on('message:outgoing', function (message) { 180 | done() 181 | }) 182 | }) 183 | 184 | setTimeout(function () { 185 | radarServer._processMessage(socketOne, subscribeMessage) 186 | radarServer._processMessage(socketTwo, publishMessage) 187 | }, 100) 188 | }) 189 | }) 190 | }) 191 | -------------------------------------------------------------------------------- /test/presence.manager.unit.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | const chai = require('chai') 3 | const expect = chai.expect 4 | 5 | describe('PresenceManager', function () { 6 | const PresenceManager = require('../src/core/resources/presence/presence_manager') 7 | 8 | describe('#disconnectRemoteClient', function () { 9 | it('sends implicit offline message without updating redis', function (done) { 10 | const scope = 'presence:/foo/bar' 11 | const sentry = { on: function () {} } 12 | const clientSessionId = 'abcdef' 13 | 14 | const presenceManager = new PresenceManager(scope, {}, sentry) 15 | presenceManager.processRedisEntry = function (message, callbackFn) { 16 | expect(message).to.deep.equal({ 17 | userId: undefined, 18 | userType: undefined, 19 | clientId: clientSessionId, 20 | online: false, 21 | explicit: false 22 | }) 23 | callbackFn() 24 | } 25 | presenceManager.disconnectRemoteClient(clientSessionId, function (err) { 26 | done(err) 27 | }) 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /test/quota_limiter.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach */ 2 | const assert = require('assert') 3 | const { QuotaLimiter } = require('../src/middleware') 4 | const limit = 1 5 | const clientId = '1' 6 | const clientId2 = '2' 7 | const toPrefix = 'presence://thing/' 8 | const to = toPrefix + '1' 9 | let quotaLimiter 10 | 11 | describe('QuotaLimiter', function () { 12 | beforeEach(function () { 13 | quotaLimiter = new QuotaLimiter(limit) 14 | }) 15 | 16 | it('add', function () { 17 | assert.strictEqual(quotaLimiter.count(clientId), 0) 18 | quotaLimiter.add(clientId, to) 19 | assert.strictEqual(quotaLimiter.count(clientId), 1) 20 | }) 21 | 22 | it('remove', function () { 23 | quotaLimiter.add(clientId, to) 24 | quotaLimiter.remove(clientId, to) 25 | assert.strictEqual(quotaLimiter.count(clientId), 0) 26 | }) 27 | 28 | it('remove before adding', function () { 29 | quotaLimiter.remove(clientId, to) 30 | assert.strictEqual(quotaLimiter.count(clientId), 0) 31 | }) 32 | 33 | it('isAboveLimit', function () { 34 | quotaLimiter.add(clientId, to) 35 | assert(quotaLimiter.isAboveLimit(clientId)) 36 | }) 37 | 38 | it('duplicates should not count', function () { 39 | quotaLimiter.add(clientId, to) 40 | assert(!quotaLimiter.add(clientId, to)) 41 | assert.strictEqual(quotaLimiter.count(clientId), 1) 42 | }) 43 | 44 | it('removing by id', function () { 45 | quotaLimiter.add(clientId, to) 46 | quotaLimiter.add(clientId2, to) 47 | 48 | assert.strictEqual(quotaLimiter.count(clientId), 1) 49 | assert.strictEqual(quotaLimiter.count(clientId2), 1) 50 | 51 | quotaLimiter.removeById(clientId) 52 | 53 | assert.strictEqual(quotaLimiter.count(clientId), 0) 54 | assert.strictEqual(quotaLimiter.count(clientId2), 1) 55 | }) 56 | 57 | it('removing by to', function () { 58 | quotaLimiter.add(clientId, to) 59 | quotaLimiter.add(clientId2, to) 60 | 61 | assert.strictEqual(quotaLimiter.count(clientId), 1) 62 | assert.strictEqual(quotaLimiter.count(clientId2), 1) 63 | 64 | quotaLimiter.removeByTo(to) 65 | 66 | assert.strictEqual(quotaLimiter.count(clientId), 0) 67 | assert.strictEqual(quotaLimiter.count(clientId2), 0) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /test/quota_manager.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach */ 2 | const assert = require('assert') 3 | const { QuotaManager } = require('../src/middleware') 4 | let quotaManager 5 | const client = { 6 | id: 1, 7 | send: function () {} 8 | } 9 | const resource = { 10 | to: 'scope' 11 | } 12 | const limitedType = { 13 | name: 'limited', 14 | policy: { limit: 1 } 15 | } 16 | const syncMessage = { 17 | op: 'sync', 18 | to: 'scope' 19 | } 20 | const unsubscribeMessage = { 21 | op: 'unsubscribe', 22 | to: 'scope' 23 | } 24 | const subscribeMessage = { 25 | op: 'subscribe', 26 | to: 'scope' 27 | } 28 | const otherMessage = { 29 | op: 'other', 30 | to: 'scope' 31 | } 32 | 33 | const assertLimitCount = function (manager, type, client, count, done) { 34 | const limiter = manager.getLimiter(type) 35 | assert(limiter) 36 | assert.strictEqual(limiter.count(client.id), count) 37 | if (done) { done() } 38 | } 39 | 40 | describe('QuotaManager', function () { 41 | beforeEach(function () { 42 | quotaManager = new QuotaManager() 43 | }) 44 | 45 | describe('when counting (updateLimits)', function () { 46 | it('should count on limited types', function (done) { 47 | quotaManager.updateLimits(client, resource, subscribeMessage, limitedType, function () { 48 | assertLimitCount(quotaManager, limitedType, client, 1, done) 49 | }) 50 | }) 51 | 52 | it('should add subscribe ops', function (done) { 53 | quotaManager.updateLimits(client, resource, subscribeMessage, limitedType, function () { 54 | assertLimitCount(quotaManager, limitedType, client, 1, done) 55 | }) 56 | }) 57 | 58 | it('should add sync ops', function (done) { 59 | quotaManager.updateLimits(client, resource, syncMessage, limitedType, function () { 60 | assertLimitCount(quotaManager, limitedType, client, 1, done) 61 | }) 62 | }) 63 | 64 | it('should decrement on unsubscribe ops', function (done) { 65 | quotaManager.updateLimits(client, resource, subscribeMessage, limitedType, function () { 66 | assertLimitCount(quotaManager, limitedType, client, 1) 67 | quotaManager.updateLimits(client, resource, unsubscribeMessage, limitedType, function () { 68 | assertLimitCount(quotaManager, limitedType, client, 0, done) 69 | }) 70 | }) 71 | }) 72 | it('should should skip unknown ops', function (done) { 73 | quotaManager.updateLimits(client, resource, otherMessage, limitedType, function () { 74 | assertLimitCount(quotaManager, limitedType, client, 0, done) 75 | }) 76 | }) 77 | }) 78 | 79 | describe('when limiting (checkLimits)', function () { 80 | it('should limit subscribe ops', function (done) { 81 | quotaManager.updateLimits(client, resource, subscribeMessage, limitedType, function (err) { 82 | assert(err === undefined) 83 | 84 | const otherSubscribeMessage = { to: 'scope2', op: 'subscribe' } 85 | quotaManager.checkLimits(client, otherSubscribeMessage, limitedType, function (err) { 86 | assert(err) 87 | done() 88 | }) 89 | }) 90 | }) 91 | 92 | it('should limit sync ops', function (done) { 93 | quotaManager.updateLimits(client, resource, syncMessage, limitedType, function (err) { 94 | assert(err === undefined) 95 | 96 | const otherSubscribeMessage = { to: 'scope2', op: 'subscribe' } 97 | quotaManager.checkLimits(client, otherSubscribeMessage, limitedType, function (err) { 98 | assert(err) 99 | done() 100 | }) 101 | }) 102 | }) 103 | 104 | it('should not limit unknown ops', function (done) { 105 | quotaManager.updateLimits(client, resource, otherMessage, limitedType, function (err) { 106 | assert(err === undefined) 107 | 108 | const otherExtraMessage = { to: 'scope2', op: 'subscribe' } 109 | quotaManager.checkLimits(client, otherExtraMessage, limitedType, function (err) { 110 | assert(err === undefined) 111 | done() 112 | }) 113 | }) 114 | }) 115 | }) 116 | 117 | describe('when a resource gets destroyed', function () { 118 | it('should clean up', function (done) { 119 | let limiter 120 | 121 | quotaManager.updateLimits(client, resource, subscribeMessage, limitedType, function () { 122 | limiter = quotaManager.getLimiter(limitedType) 123 | assert.strictEqual(limiter.count(client.id), 1) 124 | 125 | quotaManager.destroyByResource(resource, limitedType, function () { 126 | limiter = quotaManager.getLimiter(limitedType) 127 | assert.strictEqual(limiter.count(client.id), 0) 128 | done() 129 | }) 130 | }) 131 | }) 132 | }) 133 | 134 | describe('when a client gets destroyed', function () { 135 | it('should clean up', function (done) { 136 | let limiter 137 | 138 | quotaManager.updateLimits(client, resource, subscribeMessage, limitedType, function () { 139 | limiter = quotaManager.getLimiter(limitedType) 140 | assert.strictEqual(limiter.count(client.id), 1) 141 | 142 | quotaManager.destroyByClient(client, resource, limitedType, function () { 143 | limiter = quotaManager.getLimiter(limitedType) 144 | assert.strictEqual(limiter.count(client.id), 0) 145 | done() 146 | }) 147 | }) 148 | }) 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /test/radar_api.test.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const assert = require('assert') 3 | const Api = require('../api/api.js') 4 | const ClientScope = require('../api/lib/client') 5 | const Persistence = require('../src/core').Persistence 6 | const PresenceManager = require('../src/core').PresenceManager 7 | const Presence = require('../src/core').Presence 8 | const Type = require('../src/core').Type 9 | const Status = require('../src/core').Status 10 | const MessageList = require('../src/core').MessageList 11 | const Common = require('./common.js') 12 | let frontend 13 | 14 | const originalSentry = Presence.sentry 15 | 16 | const Client = new ClientScope({ 17 | secure: false, 18 | host: 'localhost', 19 | port: 8123 20 | }) 21 | 22 | exports['Radar api tests'] = { 23 | before: function (done) { 24 | Common.startPersistence(function () { 25 | // Create frontend server 26 | frontend = http.createServer(function (req, res) { res.end('404 error') }) 27 | Api.attach(frontend) 28 | 29 | frontend.listen(8123, done) 30 | }) 31 | }, 32 | 33 | beforeEach: function (done) { 34 | Persistence.delWildCard('*', done) 35 | }, 36 | 37 | after: function (done) { 38 | frontend.close() 39 | Common.endPersistence(done) 40 | }, 41 | 42 | // GET /radar/status?accountName=test&scope=ticket/1 43 | 'can get a status scope': function (done) { 44 | const to = 'status:/test/ticket/1' 45 | const opts = Type.getByExpression(to) 46 | const status = new Status(to, {}, opts) 47 | 48 | status.set({}, { 49 | key: 'foo', 50 | value: 'bar' 51 | }) 52 | 53 | Client.get('/node/radar/status') 54 | .data({ accountName: 'test', scope: 'ticket/1' }) 55 | .end(function (err, response) { 56 | if (err) { return done(err) } 57 | assert.deepStrictEqual({ foo: 'bar' }, response) 58 | Persistence.ttl('status:/test/ticket/1', function (err, reply) { 59 | if (err) { return done(err) } 60 | assert.ok((parseInt(reply, 10) > 0)) 61 | done() 62 | }) 63 | }) 64 | }, 65 | 66 | // POST /radar/status { accountName: 'test', scope: 'ticket/1' } 67 | 'can set a status scope': function (done) { 68 | Client.post('/node/radar/status') 69 | .data({ accountName: 'test', scope: 'ticket/2', key: 'foo', value: 'bar' }) 70 | .end(function (err, response) { 71 | if (err) { return done(err) } 72 | assert.deepStrictEqual({}, response) 73 | 74 | Client.get('/node/radar/status') 75 | .data({ accountName: 'test', scope: 'ticket/2' }) 76 | .end(function (err, response) { 77 | if (err) { return done(err) } 78 | assert.deepStrictEqual({ foo: 'bar' }, response) 79 | Persistence.ttl('status:/test/ticket/2', function (err, reply) { 80 | if (err) { return done(err) } 81 | assert.ok((parseInt(reply, 10) > 0)) 82 | done() 83 | }) 84 | }) 85 | }) 86 | }, 87 | 88 | // GET /radar/message?accountName=test&scope=chat/1 89 | 'can get a message scope': function (done) { 90 | const messageType = { 91 | expr: /^message:\/setStatus\/(.+)$/, 92 | type: 'message', 93 | authProvider: false, 94 | policy: { cache: true, maxAgeSeconds: 30 } 95 | } 96 | 97 | Type.register('message', messageType) 98 | 99 | const to = 'message:/setStatus/chat/1' 100 | const opts = Type.getByExpression(to) 101 | const msgList = new MessageList(to, {}, opts) 102 | 103 | msgList.publish({}, { 104 | key: 'foo', 105 | value: 'bar' 106 | }) 107 | 108 | Client.get('/node/radar/message') 109 | .data({ accountName: 'setStatus', scope: 'chat/1' }) 110 | .end(function (error, response) { 111 | if (error) { return done(error) } 112 | assert.deepStrictEqual({ key: 'foo', value: 'bar' }, JSON.parse(response[0])) 113 | done() 114 | }) 115 | }, 116 | 117 | // POST /radar/message { accountName:'test', scope:'ticket/1' } 118 | 'can set a message scope': function (done) { 119 | const messageType = { 120 | expr: /^message:\/setStatus\/(.+)$/, 121 | type: 'MessageList', 122 | authProvider: false, 123 | policy: { 124 | cache: true, 125 | maxAgeSeconds: 300 126 | } 127 | } 128 | 129 | Type.register('message', messageType) 130 | 131 | Client.post('/node/radar/message') 132 | .data({ accountName: 'setStatus', scope: 'chat/2', value: 'hello' }) 133 | .end(function (error, response) { 134 | if (error) { return done(error) } 135 | assert.deepStrictEqual({}, response) 136 | 137 | Client.get('/node/radar/message') 138 | .data({ accountName: 'setStatus', scope: 'chat/2' }) 139 | .end(function (error, response) { 140 | if (error) { return done(error) } 141 | assert.deepStrictEqual('hello', JSON.parse(response[0]).value) 142 | Persistence.ttl('message:/setStatus/chat/2', function (err, reply) { 143 | if (err) { return done(err) } 144 | assert.ok((parseInt(reply, 10) > 0)) 145 | done() 146 | }) 147 | }) 148 | }) 149 | }, 150 | 151 | 'given a fake PresenceMonitor': { 152 | before: function (done) { 153 | function FakePersistence () {} 154 | 155 | const messages = { 156 | 'presence:/test/ticket/1': { 157 | '1.1000': { 158 | userId: 1, 159 | userType: 0, 160 | clientId: 1000, 161 | online: true, 162 | sentry: 'server1' 163 | } 164 | }, 165 | 'presence:/test/ticket/2': { 166 | 2.1001: { 167 | userId: 2, 168 | userType: 4, 169 | clientId: 1001, 170 | online: true, 171 | sentry: 'server1' 172 | } 173 | } 174 | } 175 | 176 | FakePersistence.readHashAll = function (scope, callbackFn) { 177 | callbackFn(messages[scope]) 178 | } 179 | 180 | FakePersistence.deleteHash = function (scope, callbackFn) {} 181 | 182 | PresenceManager.setBackend(FakePersistence) 183 | const fakeSentry = { 184 | name: 'server1', 185 | isDown: function () { 186 | return false 187 | }, 188 | on: function () {} 189 | } 190 | 191 | Presence.sentry = fakeSentry 192 | done() 193 | }, 194 | 195 | after: function (done) { 196 | PresenceManager.setBackend(Persistence) 197 | Presence.sentry = originalSentry 198 | done() 199 | }, 200 | 201 | // GET /radar/presence?accountName=support&scope=ticket/1 202 | 'can get a presence scope using api v1': function (done) { 203 | Client.get('/node/radar/presence') 204 | .data({ accountName: 'test', scope: 'ticket/1' }) 205 | .end(function (error, response) { 206 | if (error) { return done(error) } 207 | assert.deepStrictEqual({ 1: 0 }, response) 208 | done() 209 | }) 210 | }, 211 | 212 | 'can get multiple presence scopes using api v1': function (done) { 213 | Client.get('/node/radar/presence') 214 | .data({ accountName: 'test', scopes: 'ticket/1,ticket/2' }) 215 | .end(function (error, response) { 216 | if (error) { return done(error) } 217 | assert.deepStrictEqual({ 'ticket/1': { 1: 0 }, 'ticket/2': { 2: 4 } }, response) 218 | done() 219 | }) 220 | }, 221 | 222 | 'can get a presence scope with client ids using api v2': function (done) { 223 | Client.get('/node/radar/presence') 224 | .data({ accountName: 'test', scope: 'ticket/1', version: 2 }) 225 | .end(function (error, response) { 226 | if (error) { return done(error) } 227 | assert.deepStrictEqual({ 1: { clients: { 1000: {} }, userType: 0 } }, response) 228 | done() 229 | }) 230 | }, 231 | 232 | 'can get multiple presence scopes using api v2': function (done) { 233 | Client.get('/node/radar/presence') 234 | .data({ accountName: 'test', scopes: 'ticket/1,ticket/2', version: 2 }) 235 | .end(function (error, response) { 236 | if (error) { return done(error) } 237 | assert.deepStrictEqual({ 'ticket/1': { 1: { clients: { 1000: {} }, userType: 0 } }, 'ticket/2': { 2: { clients: { 1001: {} }, userType: 4 } } }, response) 238 | done() 239 | }) 240 | } 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /test/sentry.unit.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach, afterEach, before */ 2 | const assert = require('assert') 3 | const Sentry = require('../src/core/resources/presence/sentry.js') 4 | const Persistence = require('persistence') 5 | const configuration = require('../configurator.js').load({ persistence: true }) 6 | const _ = require('lodash') 7 | let currentSentry = 0 8 | let sentry 9 | let sentryOne 10 | let sentryTwo 11 | let sentryThree 12 | let sentries = [] 13 | const defaults = { 14 | expiryOffset: 200, 15 | refreshInterval: 100, 16 | checkInterval: 200 17 | } 18 | 19 | function newSentry (options) { 20 | const name = 'sentry-' + currentSentry++ 21 | 22 | return new Sentry(name, _.extend(defaults, (options || {}))) 23 | } 24 | 25 | function assertSentriesKnowEachOther (sentries, done) { 26 | const sentryNames = sentries.map(function (s) { return s.name }) 27 | 28 | sentries.forEach(function (sentry) { 29 | _.map(sentry.sentries, function (message) { 30 | assert(_.indexOf(sentryNames, message.name) >= -1) 31 | }) 32 | }) 33 | 34 | if (done) { done() } 35 | } 36 | 37 | describe('a Server Entry (Sentry)', function () { 38 | before(function (done) { 39 | Persistence.setConfig(configuration.persistence) 40 | Persistence.connect(done) 41 | }) 42 | 43 | afterEach(function (done) { 44 | Persistence.redis().del('sentry:/radar', function () { 45 | if (sentry) { sentry.stop(done) } else { done() } 46 | sentries.forEach(function (s) { s.stop() }) 47 | }) 48 | }) 49 | 50 | it('start with callbackFn', function (done) { 51 | sentry = newSentry() 52 | sentry.start(defaults, done) 53 | }) 54 | 55 | describe('isDown', function () { 56 | it('initially, it should be down', function () { 57 | sentry = newSentry() 58 | assert.strictEqual(sentry.isDown(sentry.name), true) 59 | }) 60 | 61 | it('after start, it should be up', function () { 62 | sentry = newSentry() 63 | sentry.start(defaults, function () { 64 | assert.strictEqual(sentry.isDown(sentry.name), false) 65 | }) 66 | }) 67 | }) 68 | 69 | describe('keep alive', function () { 70 | it('should generate a valid sentry message', function () { 71 | sentry = newSentry() 72 | 73 | sentry.start(defaults, function () { 74 | assert.strictEqual(Object.keys(sentry.sentries).length, 1) 75 | assert.strictEqual(sentry.sentries[Object.keys(sentry.sentries)[0]].name, sentry.name) 76 | }) 77 | }) 78 | }) 79 | 80 | describe('check & clean up', function () { 81 | beforeEach(function (done) { 82 | sentryOne = newSentry() 83 | sentryTwo = newSentry() 84 | sentries = [sentryOne, sentryTwo] 85 | 86 | sentryOne.start(defaults, function () { 87 | sentryTwo.start(defaults, done) 88 | }) 89 | }) 90 | 91 | it('after start, the sentries should know about each other', function (done) { 92 | const checkSentriesKnowEachOther = function () { 93 | sentries.forEach(function (s) { 94 | assert.strictEqual(Object.keys(s.sentries).length, 2) 95 | }) 96 | done() 97 | } 98 | 99 | setTimeout(checkSentriesKnowEachOther, 200) 100 | }) 101 | 102 | it('after a down, and after check, sentries should clean up', function (done) { 103 | const checkForSentryTwoGone = function () { 104 | setTimeout(function () { 105 | assert.strictEqual(sentryOne.sentries[sentryTwo.name], undefined) 106 | done() 107 | }, 500) 108 | } 109 | 110 | sentryTwo.stop(checkForSentryTwoGone) 111 | }) 112 | }) 113 | 114 | describe('complex scenario, with more than two sentries, when one dies', function () { 115 | it('all remaining sentries should do proper cleanup', function (done) { 116 | sentryOne = newSentry({ checkInterval: 10 }) 117 | sentryTwo = newSentry({ checkInterval: 20 }) // It's important that sentryTwo is slower. 118 | sentryThree = newSentry() 119 | sentries = [sentryOne, sentryTwo, sentryThree] 120 | 121 | const stopAndAssert = function () { 122 | // stop one 123 | sentryThree.stop(function () { 124 | setTimeout(function () { 125 | // assert existing sentries no longer know sentryThree. 126 | assert.strictEqual(sentryOne.sentries[sentryThree.name], undefined) 127 | assert.strictEqual(sentryTwo.sentries[sentryThree.name], undefined) 128 | done() 129 | }, 300) 130 | }) 131 | } 132 | 133 | // start everything... 134 | sentryOne.start(defaults, function () { 135 | sentryTwo.start(defaults, function () { 136 | sentryThree.start(defaults, function () { 137 | // assert ideal state, every one know each other 138 | assertSentriesKnowEachOther(sentries, stopAndAssert) 139 | }) 140 | }) 141 | }) 142 | }) 143 | }) 144 | 145 | describe('when emiting events', function () { 146 | it('should emit up when going up', function (done) { 147 | sentryOne = newSentry({ checkInterval: 10 }) 148 | sentryOne.on('up', function (name, message) { 149 | assert.strictEqual(name, sentryOne.name) 150 | done() 151 | }) 152 | 153 | sentryOne.start() 154 | }) 155 | 156 | it('should emit down when noticing another sentry is no longer available', function (done) { 157 | sentryOne = newSentry() 158 | sentryTwo = newSentry() 159 | 160 | sentryOne.on('down', function (name, message) { 161 | assert.strictEqual(name, sentryTwo.name) 162 | done() 163 | }) 164 | 165 | sentryOne.start(defaults, function () { 166 | sentryTwo.start(defaults, function () { 167 | sentryTwo.stop() 168 | }) 169 | }) 170 | }) 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /test/server.auth.unit.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach */ 2 | const common = require('./common.js') 3 | const assert = require('assert') 4 | const RadarTypes = require('../src/core/type.js') 5 | const controlMessage = { 6 | to: 'control:/dev/test', 7 | op: 'ctl', 8 | options: { 9 | association: { 10 | id: 1, 11 | name: 1 12 | } 13 | } 14 | } 15 | const authProvider = { 16 | authorize: function (channel, message, client) { 17 | return false 18 | } 19 | } 20 | const authorizedType = { 21 | name: 'general control', 22 | type: 'Control', 23 | authProvider: authProvider, 24 | expression: /^control:/ 25 | } 26 | const LegacyAuthManager = require('../src/middleware').LegacyAuthManager 27 | let radarServer 28 | let socket 29 | 30 | describe('given a server', function () { 31 | describe('without authentication', function () { 32 | beforeEach(function (done) { 33 | radarServer = common.createRadarServer(done) 34 | radarServer.use(new LegacyAuthManager()) 35 | socket = { id: 1 } 36 | }) 37 | 38 | it('it should allow access', function (done) { 39 | radarServer._handleResourceMessage = function () { 40 | done() 41 | } 42 | radarServer._processMessage(socket, controlMessage) 43 | }) 44 | }) 45 | 46 | describe('with authentication', function () { 47 | beforeEach(function (done) { 48 | RadarTypes.replace([authorizedType]) 49 | radarServer = common.createRadarServer(done) 50 | radarServer.use(new LegacyAuthManager()) 51 | socket = { id: 1 } 52 | }) 53 | 54 | it('it should prevent unauthorized access', function (done) { 55 | socket.send = function (message) { 56 | assert.strictEqual('err', message.op) 57 | assert.strictEqual('auth', message.value) 58 | done() 59 | } 60 | 61 | radarServer._processMessage(socket, controlMessage) 62 | }) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /test/server.memory.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach, afterEach, gc */ 2 | const common = require('./common.js') 3 | const chai = require('chai') 4 | const expect = chai.expect 5 | const { EventEmitter } = require('events') 6 | 7 | describe('given a server', function () { 8 | let radarServer 9 | 10 | beforeEach(function (done) { 11 | radarServer = common.createRadarServer(done) 12 | }) 13 | 14 | afterEach(function (done) { 15 | radarServer.terminate(done) 16 | }) 17 | 18 | if (typeof gc === 'function') { 19 | const progress = require('smooth-progress') 20 | 21 | it('should not leak memory when clients connect and disconnect', function (done) { 22 | this.timeout(0) 23 | const totalConnections = 100000 24 | const concurrentConnections = 10000 25 | const thresholdBytes = 1024 * 1024 26 | const sockets = [] 27 | let socketsHighWater = 0 28 | let ended = false 29 | let endedConnections = 0 30 | 31 | // make sockets 32 | let s = 0 33 | function makeSocket () { 34 | const socket = new EventEmitter() 35 | socket.id = s++ 36 | return socket 37 | } 38 | function socketConnect () { 39 | const socket = makeSocket() 40 | sockets.push(socket) 41 | socketsHighWater = Math.max(sockets.length, socketsHighWater) 42 | radarServer._onSocketConnection(socket) 43 | } 44 | 45 | function checkEnd () { 46 | if (endedConnections === totalConnections && !ended) { 47 | ended = true 48 | gc() 49 | setTimeout(function () { 50 | gc() 51 | const end = process.memoryUsage().heapUsed 52 | console.log('Simulated', i.toLocaleString(), 'client connections, and saw max ', socketsHighWater.toLocaleString(), 'concurrent connections') 53 | const growth = end - start 54 | console.log('Heap growth', growth.toLocaleString(), 'bytes') 55 | expect(end - start).to.be.lessThan(thresholdBytes) 56 | done() 57 | }, 500) 58 | } 59 | } 60 | 61 | const bar = progress({ 62 | tmpl: 'Simulating ' + totalConnections.toLocaleString() + ' connections... :bar :percent :eta', 63 | width: 25, 64 | total: totalConnections 65 | }) 66 | bar.last = 0 67 | bar.i = setInterval(function () { 68 | bar.tick(endedConnections - bar.last) 69 | bar.last = endedConnections 70 | if (endedConnections === totalConnections) { clearInterval(bar.i) } 71 | }, 100) 72 | 73 | gc() 74 | const start = process.memoryUsage().heapUsed 75 | let i = 0 76 | asyncWhile(function () { return i < totalConnections }, function () { 77 | // limit concurrent 78 | if (sockets.length >= concurrentConnections || i === totalConnections) { 79 | const socket = sockets.pop() 80 | socket && socket.emit('close') 81 | endedConnections++ 82 | } else { 83 | i++ 84 | socketConnect() 85 | } 86 | }, function () { 87 | // close remaining open sockets 88 | while (sockets.length) { 89 | const socket = sockets.pop() 90 | socket && socket.emit('close') 91 | endedConnections++ 92 | } 93 | checkEnd() 94 | }) 95 | }) 96 | } else { 97 | it('skipping memory leak test, run with node --expose-gc node flag to enable test') 98 | } 99 | }) 100 | 101 | function asyncWhile (conditionPredicate, bodyFn, callbackFn) { 102 | setImmediate(function () { 103 | if (!conditionPredicate()) { return callbackFn() } 104 | bodyFn() 105 | asyncWhile(conditionPredicate, bodyFn, callbackFn) 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /test/server.middleware.unit.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach, afterEach */ 2 | const common = require('./common.js') 3 | const assert = require('assert') 4 | const controlMessage = { 5 | to: 'control:/dev/test', 6 | op: 'ctl', 7 | options: { 8 | association: { 9 | id: 1, 10 | name: 1 11 | } 12 | } 13 | } 14 | let radarServer 15 | let socket 16 | 17 | describe('given a server with filters', function () { 18 | beforeEach(function (done) { 19 | radarServer = common.createRadarServer(done) 20 | socket = { id: 1 } 21 | }) 22 | 23 | afterEach(function (done) { 24 | radarServer.terminate(done) 25 | }) 26 | 27 | describe('with no filters', function () { 28 | it('should not halt execution', function (done) { 29 | radarServer._handleResourceMessage = function () { 30 | done() 31 | } 32 | radarServer._processMessage(null, controlMessage) 33 | }) 34 | }) 35 | 36 | describe('with 1 filter', function () { 37 | it('if OK, it should run it and continue', function (done) { 38 | let called = false 39 | 40 | radarServer._handleResourceMessage = function () { 41 | assert.ok(called) 42 | done() 43 | } 44 | 45 | radarServer.use({ 46 | onMessage: function (client, message, options, next) { 47 | called = true 48 | assert.strictEqual(client.id, socket.id) 49 | assert.strictEqual(options.type, 'Control') 50 | assert.deepStrictEqual(message, controlMessage) 51 | next() 52 | } 53 | }) 54 | 55 | radarServer._processMessage(socket, controlMessage) 56 | }) 57 | 58 | it('if NOT OK, it should run it and halt', function (done) { 59 | let called = false 60 | 61 | socket.send = function (message) { 62 | assert.strictEqual('err', message.op) 63 | assert(called) 64 | done() 65 | } 66 | 67 | radarServer.use({ 68 | onMessage: function (client, message, options, next) { 69 | called = true 70 | assert.strictEqual(options.type, 'Control') 71 | socket.send({ op: 'err' }) 72 | next('err') 73 | } 74 | }) 75 | 76 | radarServer._processMessage(socket, controlMessage) 77 | }) 78 | }) 79 | 80 | describe('with multiple filters', function () { 81 | it('should respect order', function (done) { 82 | let onMessagevious 83 | 84 | socket.send = function (value) { 85 | if (value === 1) { 86 | onMessagevious = value 87 | } else if (value === 2) { 88 | assert.strictEqual(onMessagevious, 1) 89 | done() 90 | } 91 | } 92 | 93 | const firstFilter = { 94 | onMessage: function (client, message, options, next) { 95 | client.send(1) 96 | next() 97 | } 98 | } 99 | 100 | const secondFilter = { 101 | onMessage: function (client, message, options, next) { 102 | client.send(2) 103 | next() 104 | } 105 | } 106 | 107 | radarServer.use(firstFilter) 108 | radarServer.use(secondFilter) 109 | 110 | radarServer._processMessage(socket, controlMessage) 111 | }) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /test/server.session_manager.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, beforeEach */ 2 | /* eslint-disable no-unused-expressions */ 3 | 4 | const SessionManager = require('../src/server/session_manager') 5 | const ClientSession = require('../src/client/client_session') 6 | const chai = require('chai') 7 | chai.use(require('sinon-chai')) 8 | chai.use(require('chai-interface')) 9 | const expect = chai.expect 10 | const sinon = require('sinon') 11 | 12 | describe('SessionManager', function () { 13 | let sessionManager 14 | beforeEach(function () { 15 | sessionManager = new SessionManager() 16 | }) 17 | 18 | it('can be instantiated', function () { 19 | expect(sessionManager).to.be.instanceof(SessionManager) 20 | }) 21 | 22 | it('has interface', function () { 23 | expect(sessionManager).to.have.interface({ 24 | sessions: Object, 25 | adapters: Array, 26 | on: Function, 27 | once: Function, 28 | add: Function, 29 | has: Function, 30 | length: Function, 31 | get: Function 32 | }) 33 | }) 34 | 35 | describe('client session adapters', function () { 36 | describe('when instantiating with adapters option', function () { 37 | const adapters = [{ canAdapt: function () {}, adapt: function () {} }] 38 | const sessionManager = new SessionManager({ adapters: adapters }) 39 | 40 | it('assigns the adapters to this.adapters', function () { 41 | expect(sessionManager.adapters).to.deep.equal(adapters) 42 | }) 43 | 44 | it('validates the adapters', function () { 45 | expect(function () { 46 | new SessionManager({ adapters: [{bad: 'adapter'}] }) // eslint-disable-line 47 | }).to.throw(TypeError, /invalid adapter/i) 48 | }) 49 | }) 50 | 51 | describe('#canAdapt', function () { 52 | const someObj = { foo: 1 } 53 | 54 | it('returns true when an adapter matches', function () { 55 | const adapterStub1 = { canAdapt: sinon.stub().returns(false) } 56 | const adapterStub2 = { canAdapt: sinon.stub().returns(true) } 57 | sessionManager.adapters.push(adapterStub1, adapterStub2) 58 | expect(sessionManager.canAdapt(someObj)).to.be.true 59 | }) 60 | it('returns false when no adapter matches', function () { 61 | const adapterStub1 = { canAdapt: sinon.stub().returns(false) } 62 | sessionManager.adapters.push(adapterStub1) 63 | expect(sessionManager.canAdapt(someObj)) 64 | .to.be.false 65 | }) 66 | it('returns false when no adapters', function () { 67 | expect(sessionManager.canAdapt(someObj)) 68 | .to.be.false 69 | }) 70 | }) 71 | 72 | describe('#adapt', function () { 73 | const someObj = { foo: 1 } 74 | let adapter 75 | describe('given a matching adapter', function () { 76 | const session = {} 77 | beforeEach(function () { 78 | adapter = { 79 | adapt: sinon.stub().returns(session), 80 | canAdapt: sinon.stub().returns(true) 81 | } 82 | sessionManager.adapters.push(adapter) 83 | }) 84 | it('applies adapter#adapt to obj', function () { 85 | expect(sessionManager.adapt(someObj)) 86 | .to.equal(session) 87 | expect(adapter.canAdapt) 88 | .to.have.been.calledWith(someObj) 89 | expect(adapter.adapt) 90 | .to.have.been.calledWith(someObj) 91 | }) 92 | }) 93 | describe('given no matching adapter', function () { 94 | beforeEach(function () { 95 | adapter = { 96 | adapt: sinon.stub(), 97 | canAdapt: sinon.stub().returns(false) 98 | } 99 | }) 100 | it('returns null', function () { 101 | expect(sessionManager.adapt(someObj)) 102 | .to.equal(null) 103 | expect(adapter.adapt) 104 | .not.to.have.been.called 105 | }) 106 | }) 107 | }) 108 | }) 109 | 110 | describe('#isValidAdapter', function () { 111 | it('returns true if has methods canAdapt and adapt', function () { 112 | const adapter = { 113 | canAdapt: function () {}, 114 | adapt: function () {} 115 | } 116 | expect(sessionManager.isValidAdapter(adapter)) 117 | .to.be.true 118 | }) 119 | it('returns false otherwise', function () { 120 | expect(sessionManager.isValidAdapter({})) 121 | .to.be.false 122 | expect(sessionManager.isValidAdapter({ adapt: function () {} })) 123 | .to.be.false 124 | expect(sessionManager.isValidAdapter({ canAdapt: function () {} })) 125 | .to.be.false 126 | }) 127 | }) 128 | 129 | describe('#add', function () { 130 | it('returns the ClientSession', function () { 131 | const session = new ClientSession() 132 | const added = sessionManager.add(session) 133 | expect(added).to.equal(session) 134 | }) 135 | it('returns an adapted ClientSession', function () { 136 | const session = new ClientSession() 137 | sessionManager.adapters.push({ 138 | adapt: sinon.stub().returns(session), 139 | canAdapt: sinon.stub().returns(true) 140 | }) 141 | const added = sessionManager.add({}) 142 | expect(added).to.equal(session) 143 | }) 144 | }) 145 | 146 | describe('when adding a session', function () { 147 | let clientSession 148 | beforeEach(function () { 149 | clientSession = new ClientSession('', 'foo') 150 | clientSession.once = sinon.spy() 151 | sessionManager.add(clientSession) 152 | }) 153 | 154 | it('exposes legth of collection', function () { 155 | expect(sessionManager.length()).to.equal(1) 156 | }) 157 | 158 | it('can check if has id', function () { 159 | expect(sessionManager.has('foo')).to.be.true 160 | }) 161 | 162 | it('can get by id', function () { 163 | expect(sessionManager.get('foo')) 164 | .to.equal(clientSession) 165 | }) 166 | 167 | it('adding same session multiple times has no effect', function () { 168 | const added1 = sessionManager.add(clientSession) 169 | const added2 = sessionManager.add(clientSession) 170 | expect(sessionManager.length()).to.equal(1) 171 | expect(clientSession.once).to.have.callCount(1) 172 | expect(added1).to.equal(clientSession) 173 | expect(added2).to.equal(clientSession) 174 | }) 175 | 176 | describe('given a ClientSession', function () { 177 | const clientSession = new ClientSession('', 2) 178 | 179 | it('can be added', function () { 180 | sessionManager.add(clientSession) 181 | expect(sessionManager.has(2)) 182 | .to.be.true 183 | }) 184 | }) 185 | 186 | describe('given a non-ClientSession', function () { 187 | const someObj = { id: 'one' } 188 | 189 | it('tries to adapt the obj', function () { 190 | const adapter = { 191 | canAdapt: sinon.stub().returns(true), 192 | adapt: sinon.stub().returns(new ClientSession()) 193 | } 194 | sessionManager.adapters.push(adapter) 195 | 196 | try { 197 | sessionManager.add(someObj) 198 | } finally { 199 | expect(adapter.adapt).to.have.been.calledWith(someObj) 200 | } 201 | }) 202 | 203 | describe('when can be adapted', function () { 204 | const adapted = new ClientSession('', 1) 205 | let adapter 206 | 207 | beforeEach(function () { 208 | adapter = { 209 | canAdapt: sinon.stub().returns(true), 210 | adapt: sinon.stub().returns(adapted) 211 | } 212 | sessionManager.adapters.push(adapter) 213 | }) 214 | 215 | it('adds the adapted ClientSession', function () { 216 | sessionManager.add(someObj) 217 | expect(sessionManager.has(1)) 218 | .to.be.true 219 | }) 220 | }) 221 | describe('when cannot be adapted', function () { 222 | let adapter 223 | 224 | beforeEach(function () { 225 | adapter = { 226 | canAdapt: sinon.stub().returns(false) 227 | } 228 | sessionManager.adapters.push(adapter) 229 | }) 230 | 231 | it('throws', function () { 232 | expect(function () { 233 | sessionManager.add(someObj) 234 | }).to.throw(TypeError, /adapter/) 235 | }) 236 | }) 237 | }) 238 | }) 239 | }) 240 | -------------------------------------------------------------------------------- /test/stamper.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | const assert = require('assert') 3 | const Stamper = require('../src/core/stamper.js') 4 | const sentryName = 'theSentryName' 5 | const clientId = 'clientId' 6 | 7 | Stamper.setup('theSentryName') 8 | 9 | describe('a Stamper service', function () { 10 | it('adds a stamp object to objects', function () { 11 | const message = {} 12 | 13 | Stamper.stamp(message, clientId) 14 | 15 | assert(message.stamp) 16 | assert(message.stamp.id) 17 | assert.strictEqual(message.stamp.clientId, clientId) 18 | assert.strictEqual(message.stamp.sentryId, sentryName) 19 | }) 20 | 21 | it('allows optional client id', function () { 22 | const message = {} 23 | 24 | Stamper.stamp(message) 25 | 26 | assert(message.stamp) 27 | assert(message.stamp.id) 28 | assert.strictEqual(message.stamp.clientId, undefined) 29 | assert.strictEqual(message.stamp.sentryId, sentryName) 30 | }) 31 | 32 | it('does not override id if present', function () { 33 | const message = { stamp: { id: 1 } } 34 | 35 | Stamper.stamp(message, clientId) 36 | 37 | assert.strictEqual(message.stamp.id, 1) 38 | assert.strictEqual(message.stamp.clientId, clientId) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /test/status.unit.test.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, before, after, beforeEach, afterEach */ 2 | const assert = require('assert') 3 | const Status = require('../src/core/resources/status') 4 | const Persistence = require('persistence') 5 | const Common = require('./common.js') 6 | 7 | describe('given a status resource', function () { 8 | let status 9 | const FakePersistence = { 10 | read: function () {}, 11 | publish: function () {}, 12 | expire: function () {} 13 | } 14 | const Radar = { 15 | broadcast: function () {} 16 | } 17 | 18 | before(function () { 19 | Status.setBackend(FakePersistence) 20 | }) 21 | 22 | after(function () { 23 | Status.setBackend(Persistence) 24 | }) 25 | 26 | beforeEach(function () { 27 | status = new Status('aaa', Radar, {}) 28 | FakePersistence.readHashAll = function () {} 29 | FakePersistence.persistHash = function () {} 30 | FakePersistence.expire = function () {} 31 | FakePersistence.publish = function () {} 32 | }) 33 | 34 | describe('get', function () { 35 | it('sends correct values to client', function (done) { 36 | FakePersistence.readHashAll = function (key, callbackFn) { 37 | assert.strictEqual('aaa', key) 38 | callbackFn([1, 2]) 39 | } 40 | 41 | status.get({ 42 | send: function (msg) { 43 | assert.strictEqual('get', msg.op) 44 | assert.strictEqual('aaa', msg.to) 45 | assert.deepStrictEqual([1, 2], msg.value) 46 | done() 47 | } 48 | }) 49 | }) 50 | 51 | it('sends {} if not present', function (done) { 52 | FakePersistence.readHashAll = function (key, callbackFn) { 53 | assert.strictEqual('aaa', key) 54 | callbackFn(null) 55 | } 56 | 57 | status.get({ 58 | send: function (msg) { 59 | assert.strictEqual('get', msg.op) 60 | assert.strictEqual('aaa', msg.to) 61 | assert.deepStrictEqual({}, msg.value) 62 | done() 63 | } 64 | }) 65 | }) 66 | }) 67 | 68 | describe('set', function () { 69 | it('online', function () { 70 | const message = { key: 123, value: 'online' } 71 | let persisted, published 72 | FakePersistence.persistHash = function (hash, key, value) { 73 | assert.strictEqual(123, key) 74 | assert.strictEqual('online', value) 75 | persisted = true 76 | } 77 | FakePersistence.publish = function (key, value) { 78 | assert.strictEqual('aaa', key) 79 | assert.deepStrictEqual(message, value) 80 | published = true 81 | } 82 | status.set({}, message) 83 | assert.ok(persisted) 84 | assert.ok(published) 85 | }) 86 | 87 | it('offline', function () { 88 | const message = { key: 123, value: 'offline' } 89 | let persisted, published 90 | FakePersistence.persistHash = function (hash, key, value) { 91 | assert.strictEqual(123, key) 92 | assert.strictEqual('offline', value) 93 | persisted = true 94 | } 95 | FakePersistence.publish = function (key, value) { 96 | assert.strictEqual('aaa', key) 97 | assert.deepStrictEqual(message, value) 98 | published = true 99 | } 100 | status.set({}, message) 101 | assert.ok(persisted) 102 | assert.ok(published) 103 | }) 104 | 105 | it('renews expiry for maxPersistence', function () { 106 | const message = { key: 123, value: 'online' } 107 | let expired 108 | FakePersistence.expire = function (hash, expiry) { 109 | assert.strictEqual('aaa', hash) 110 | assert.strictEqual(expiry, 12 * 60 * 60) 111 | expired = true 112 | } 113 | status.set({}, message) 114 | assert.ok(expired) 115 | }) 116 | }) 117 | 118 | describe('sync', function () { 119 | it('responds with a get message', function (done) { 120 | FakePersistence.readHashAll = function (key, callbackFn) { 121 | assert.strictEqual('aaa', key) 122 | callbackFn([1, 2]) 123 | } 124 | 125 | status.sync({ 126 | id: 123, 127 | send: function (msg) { 128 | // Check message 129 | assert.strictEqual('get', msg.op) 130 | assert.strictEqual('aaa', msg.to) 131 | assert.deepStrictEqual([1, 2], msg.value) 132 | done() 133 | } 134 | }) 135 | }) 136 | it('causes a subscription', function (done) { 137 | FakePersistence.readHashAll = function (key, callbackFn) { 138 | assert.strictEqual('aaa', key) 139 | callbackFn([1, 2]) 140 | } 141 | 142 | status.sync({ 143 | id: 123, 144 | send: function (msg) { 145 | assert.ok(status.subscribers[123]) 146 | done() 147 | } 148 | }) 149 | }) 150 | }) 151 | 152 | describe('maxPersistence', function () { 153 | it('defaults to 12 hours', function (done) { 154 | assert.strictEqual(status.options.policy.maxPersistence, 12 * 60 * 60) 155 | done() 156 | }) 157 | 158 | it('can be overrided', function (done) { 159 | const options = { 160 | policy: { 161 | maxPersistence: 24 * 60 * 60 162 | } 163 | } 164 | 165 | const status = new Status('aaa', Radar, options) 166 | assert.strictEqual(status.options.policy.maxPersistence, 24 * 60 * 60) 167 | 168 | FakePersistence.expire = function (key, persistence) { 169 | assert.strictEqual(24 * 60 * 60, persistence) 170 | done() 171 | } 172 | status.set({}, { key: 123, value: 'online' }) 173 | }) 174 | }) 175 | }) 176 | 177 | describe('a status resource', function () { 178 | let radarServer 179 | 180 | describe('emitting messages', function () { 181 | beforeEach(function (done) { 182 | radarServer = Common.createRadarServer(done) 183 | }) 184 | 185 | afterEach(function (done) { 186 | radarServer.terminate(done) 187 | }) 188 | 189 | it('should emit incomming messages', function (done) { 190 | const subscribeMessage = { op: 'subscribe', to: 'status:/z1/test/ticket/1' } 191 | 192 | radarServer.on('resource:new', function (resource) { 193 | resource.on('message:incoming', function (message) { 194 | assert.strictEqual(message.to, subscribeMessage.to) 195 | done() 196 | }) 197 | }) 198 | 199 | setTimeout(function () { 200 | radarServer._processMessage({}, subscribeMessage) 201 | }, 100) 202 | }) 203 | 204 | it('should emit outgoing messages', function (done) { 205 | const subscribeMessage = { op: 'subscribe', to: 'status:/z1/test/ticket/1' } 206 | const setMessage = { op: 'set', to: 'status:/z1/test/ticket/1', value: { 1: 2 } } 207 | const socketOne = { id: 1, send: function (m) {} } 208 | const socketTwo = { id: 2, send: function (m) {} } 209 | 210 | radarServer.on('resource:new', function (resource) { 211 | resource.on('message:outgoing', function (message) { 212 | done() 213 | }) 214 | }) 215 | 216 | setTimeout(function () { 217 | radarServer._processMessage(socketOne, subscribeMessage) 218 | radarServer._processMessage(socketTwo, setMessage) 219 | }, 100) 220 | }) 221 | 222 | // Case when setting status with the api 223 | describe('when not subscribed', function () { 224 | it('should emit outgoing messages', function (done) { 225 | const setMessage = { op: 'set', to: 'status:/z1/test/ticket/1', value: { 1: 2 } } 226 | const socketOne = { id: 1, send: function (m) {} } 227 | 228 | radarServer.on('resource:new', function (resource) { 229 | resource.on('message:outgoing', function (message) { 230 | done() 231 | }) 232 | }) 233 | 234 | setTimeout(function () { 235 | radarServer._processMessage(socketOne, setMessage) 236 | }, 100) 237 | }) 238 | 239 | it('should unsubcribe (destroy resource) if there are no subscribers', function (done) { 240 | const to = 'status:/z1/test/ticket/1' 241 | const setMessage = { op: 'set', to: to, value: { 1: 2 } } 242 | const socketOne = { id: 1, send: function (m) {} } 243 | 244 | radarServer.on('resource:destroy', function (resource) { 245 | assert.strictEqual(radarServer.resources[to], resource) 246 | done() 247 | }) 248 | 249 | setTimeout(function () { 250 | radarServer._processMessage(socketOne, setMessage) 251 | }, 100) 252 | }) 253 | }) 254 | }) 255 | }) 256 | --------------------------------------------------------------------------------