├── .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 | [](https://www.npmjs.com/package/radar)
6 | [](https://travis-ci.org/zendesk/radar)
7 | [](https://david-dm.org/zendesk/radar)
8 | [](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 |
--------------------------------------------------------------------------------