@subiz/wsclient 3.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.prettierrc.js ADDED
@@ -0,0 +1,19 @@
1
+ module.exports = {
2
+ arrowParens: 'always',
3
+ bracketSpacing: false,
4
+ embeddedLanguageFormatting: 'auto',
5
+ htmlWhitespaceSensitivity: 'css',
6
+ insertPragma: false,
7
+ jsxBracketSameLine: true,
8
+ jsxSingleQuote: true,
9
+ printWidth: 120,
10
+ proseWrap: 'preserve',
11
+ quoteProps: 'as-needed',
12
+ requirePragma: false,
13
+ semi: false,
14
+ singleQuote: true,
15
+ tabWidth: 2,
16
+ trailingComma: 'all',
17
+ useTabs: true,
18
+ vueIndentScriptAndStyle: false,
19
+ }
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # Subiz rtclient
2
+
3
+ This library implements subiz long polling protocol
4
+
5
+ ## Method
6
+ ### subscribe
7
+ ### onEvents
8
+ ### onInterrupted
9
+ ### stop
10
+
11
+ ## Example
12
+ ```
13
+ var Realtime = require('@subiz/wsclient')
14
+ var realtime = new Realtime('https://realtime-0.subiz.net/', {
15
+ account_id: 'ac1234',
16
+ getAccessToken:()=> '333344',
17
+ user_mask: 'mask123'
18
+ })
19
+
20
+ realtime.onEvent(ev => console.log(ev))
21
+ realtime.onInterrupted(() => console.log('some message may have been losted'))
22
+
23
+ // subscribe events
24
+ realtime.subscribe([ev1, ev2, ev3])
25
+
26
+ realtime.stop()
27
+ ```
package/index.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require('./realtime.js')
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@subiz/wsclient",
3
+ "version": "3.1.5",
4
+ "main": "index.js",
5
+ "scripts": {
6
+ "test": "node realtime.test.js"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/subiz/wsclient.git"
11
+ },
12
+ "author": "Thanh Pham <thanhpham@vietnambiz.com>",
13
+ "license": "MIT",
14
+ "homepage": "https://github.com/subiz/wsclient#readme",
15
+ "description": "",
16
+ "devDependencies": {
17
+ "tape": "^5.3.1"
18
+ },
19
+ "dependencies": {
20
+ "@subiz/flow": "^1.0.9"
21
+ }
22
+ }
package/realtime.js ADDED
@@ -0,0 +1,312 @@
1
+ var flow = require('@subiz/flow')
2
+
3
+ // Conn represents a realtime connection between an user and the realtime
4
+ // server (https://rt-0.subiz.com)
5
+ //
6
+ // You can subscribe for realtime events and it will sent you new events
7
+ // as fast as possible through onEvents callback.
8
+ //
9
+ // It will try it best to keep the communication with the realtime
10
+ // server. But if the user is offline for too long or her network can't
11
+ // keep up with the incomming events (rarely happend with today
12
+ // internet speed). The connection will be halt. Dead.
13
+ //
14
+ // Once dead, onDead will be called. After that, the connection is
15
+ // useless, all resources will be released.
16
+ function Conn(apiUrl, credential, onDead, onEvents, callAPI) {
17
+ callAPI = callAPI || xhrsend // allow hook
18
+ credential = credential || {}
19
+ // tell if connection is dead
20
+ var dead = false
21
+
22
+ var lastToken = ''
23
+
24
+ // kill force the connection to dead state
25
+ this.kill = function() {
26
+ dead = true
27
+ }
28
+
29
+ // long polling loop, polling will run sequentialy. Each polling will start right
30
+ // after the previous returned. In case of error, polling loop will pause based on
31
+ // exponential backoff algorithm. Polling loop will terminate if it encounter an
32
+ // un-retryable error
33
+ // the polling loop starts after the first subscribe call finished successfully
34
+ var polling = function(backoff) {
35
+ if (dead) return
36
+ callAPI('get', apiUrl + 'poll?token=' + lastToken, undefined, function(body, code) {
37
+ if (dead) return
38
+ if (retryable(code)) return setTimeout(polling, calcNextBackoff(backoff), backoff + 1)
39
+ if (code !== 200) {
40
+ // unretryable error, should kill
41
+ dead = true
42
+ return onDead('poll')
43
+ }
44
+
45
+ // 200, success
46
+ body = parseJSON(body) || {}
47
+ if (body.host) apiUrl = absUrl(body.host)
48
+
49
+ var seqToken = body.sequential_token
50
+ // the server returns a malform payload. We should retry and hope it heal soon
51
+ if (!seqToken) return setTimeout(polling, calcNextBackoff(backoff), backoff + 1)
52
+ lastToken = seqToken
53
+ onEvents(body.events || [])
54
+ return polling(0)
55
+ })
56
+ }
57
+
58
+ var subQueue = flow.batch(50, 100, function(events) {
59
+ if (dead) return ['dead']
60
+ if (events.length <= 0) return []
61
+
62
+ var out = []
63
+ return flow
64
+ .loop(function() {
65
+ return new Promise(function(rs) {
66
+ var query = '?token=' + lastToken
67
+ credential.getAccessToken().then(function(access_token) {
68
+ if (credential.user_mask) query += '&user-mask=' + encodeURIComponent(credential.user_mask)
69
+ else if (access_token) query += '&access-token=' + access_token
70
+
71
+ callAPI('post', apiUrl + 'subs' + query, JSON.stringify({events: events}), function(body, code) {
72
+ if (dead) {
73
+ out = repeat('dead', events.length)
74
+ return rs(false) // break loop
75
+ }
76
+
77
+ if (retryable(code)) return setTimeout(rs, 3000, true)
78
+ // unretryable error
79
+ if (code !== 200) {
80
+ // we are unable to start the communication with realtime server.
81
+ // Must notify the user by killing the connection
82
+ dead = true
83
+ onDead('subscribe', body, code)
84
+ out = repeat('dead', events.length)
85
+ return rs(false)
86
+ }
87
+
88
+ if (lastToken) return rs(false)
89
+
90
+ // first time sub, should grab the initial token
91
+ body = parseJSON(body) || {}
92
+ var initialToken = body.initial_token
93
+ // the server returns a malform payload. We should retry and hope it heal soon
94
+ if (!initialToken) return setTimeout(rs, 3000, true)
95
+ if (body.host) apiUrl = absUrl(body.host)
96
+
97
+ lastToken = initialToken
98
+ polling(0)
99
+ return rs(false)
100
+ })
101
+ })
102
+ })
103
+ })
104
+ .then(function() {
105
+ return out
106
+ })
107
+ })
108
+
109
+ // subscribe call /subs API to tell server that we are listening for those events
110
+ // this function may start polling loop if it hasn't been started yet
111
+ // Note: this function is not design to be thread safed so, do not call this
112
+ // function multiple times at once
113
+ this.subscribe = subQueue.push.bind(subQueue)
114
+ }
115
+
116
+ // Realtime is just a stronger Conn.
117
+ // This class helps you subscribe and listen realtime event from the
118
+ // realtime server
119
+ // additional features compare to conn:
120
+ // + auto recreate and resub if the last conn is dead
121
+ // + don't subscribe already subscribed events
122
+ function Realtime(apiUrls, credential, callAPI) {
123
+ console.log('FUCK', apiUrls, '|', credential)
124
+ if (typeof apiUrls === 'string' || apiUrls instanceof String) apiUrls = [apiUrls]
125
+ credential = credential || {}
126
+ if (!credential.getAccessToken) {
127
+ credential.getAccessToken = function() {
128
+ return Promise.resolve('')
129
+ }
130
+ }
131
+
132
+ var pubsub = new Pubsub()
133
+
134
+ // holds all topics that realtime must subscribed
135
+ // {
136
+ // a: 'online',
137
+ // b: 'offline',
138
+ // c: 'connecting',
139
+ // }
140
+ var topics = {}
141
+
142
+ // stop the connection
143
+ var stop = false
144
+ this.stop = function() {
145
+ if (stop) return
146
+ stop = true
147
+ conn.kill()
148
+ }
149
+
150
+ this.onEvent = function(cb) {
151
+ return pubsub.on('event', cb)
152
+ }
153
+
154
+ this.onInterrupted = function(cb) {
155
+ return pubsub.on('interrupted', cb)
156
+ }
157
+
158
+ var conn
159
+ this.subscribe = function(events) {
160
+ if (stop) return Promise.resolve({})
161
+ if (typeof events === 'string') events = [events]
162
+ if (!Array.isArray(events)) return Promise.resolve({error: 'param should be an array or string'})
163
+
164
+ // ignore already subscribed events
165
+ var all = []
166
+ for (var i = 0; i < events.length; i++) {
167
+ var topic = events[i]
168
+ if (!topic) continue
169
+ if (!topics[topic]) all.push(conn.subscribe(topic))
170
+ }
171
+ if (all.length === 0) return Promise.resolve({})
172
+ return Promise.all(all).then(function(errs) {
173
+ for (var i = 0; i < errs.length; i++) if (errs[i]) return {error: errs[i]}
174
+ for (var j = 0; j < events.length; j++) topics[events[j]] = true
175
+ return {}
176
+ })
177
+ }
178
+
179
+ // reconnect make sure there is alway a Conn running in the background
180
+ // if the Conn is dead, it recreate a new one
181
+ var reconnect = function() {
182
+ if (stop) return
183
+ // reset subscribed topic
184
+ var allTopics = Object.keys(topics)
185
+ for (var i = 0; i < allTopics.length; i++) topics[allTopics[i]] = false
186
+
187
+ var randomUrl = apiUrls[Math.floor(Math.random() * apiUrls.length)]
188
+ conn = new Conn(
189
+ randomUrl,
190
+ credential,
191
+ function(code, body, status) {
192
+ if (stop) return
193
+ pubsub.emit('interrupted', code, body, status)
194
+ setTimeout(reconnect, 2000) // reconnect and resubscribe after 2 sec
195
+ },
196
+ function(topics) {
197
+ if (stop) return
198
+ for (var i = 0; i < topics.length; i++) pubsub.emit('event', topics[i])
199
+ },
200
+ callAPI,
201
+ )
202
+
203
+ // resubscribe all subscribed events
204
+ var topicKeys = Object.keys(topics)
205
+ for (var i = 0; i < topicKeys.length; i++) conn.subscribe(topicKeys[i])
206
+ }
207
+ reconnect()
208
+ }
209
+
210
+ var dofetch = function(method, url, body, cb) {
211
+ let headers = {}
212
+ if (body) headers['content-type'] = 'text/plain'
213
+ fetch(url, {
214
+ method: method,
215
+ headers: header,
216
+ body: body,
217
+ })
218
+ .then((res) => res.text().then((text) => cb && cb(text, res.status)))
219
+ .catch((err) => cb(err, -1))
220
+ }
221
+ // xhrsend sends an HTTP request
222
+ var xhrsend = function(method, url, body, cb) {
223
+ if (!XMLHttpRequest) return dofetch(method, url, body, cb)
224
+ var request = new XMLHttpRequest()
225
+ request.onreadystatechange = function(e) {
226
+ if (request.readyState !== 4) return
227
+ cb && cb(request.responseText, request.status)
228
+ cb = undefined // dont call cb anymore
229
+ }
230
+
231
+ request.onerror = function() {
232
+ cb && cb(request.responseText, -1) // network error
233
+ cb = undefined // dont call cb anymore
234
+ }
235
+
236
+ request.open(method, url)
237
+ if (body) request.setRequestHeader('content-type', 'text/plain')
238
+ request.send(body)
239
+ }
240
+
241
+ // JSON.parse without exeption
242
+ // return undefined when parsing invalid JSON string
243
+ function parseJSON(str) {
244
+ try {
245
+ return JSON.parse(str)
246
+ } catch (e) {}
247
+ }
248
+
249
+ function Pubsub() {
250
+ // holds all subscriptions for all topics
251
+ // example {
252
+ // topic1: [cb1, cb2],
253
+ // topic2: [cb3, cb4],
254
+ // }
255
+ var listeners = {}
256
+
257
+ // emit notifies any callback functions that previously
258
+ // subscribed to the topic (by the on function)
259
+ this.emit = function(topic) {
260
+ // skip the first item in arguments, which is the topic
261
+ var args = []
262
+ for (var i = 1; i < arguments.length; i++) args.push(arguments[i])
263
+
264
+ var cbs = listeners[topic]
265
+ if (!cbs) return
266
+ for (var i = 0; i < cbs.length; i++) cbs[i].apply(undefined, args)
267
+ }
268
+
269
+ // register a callback function for a topic
270
+ // when something is sent to the topic (by the emit function),
271
+ // the callback function will be called
272
+ this.on = function(topic, cb) {
273
+ if (!listeners[topic]) listeners[topic] = []
274
+ listeners[topic].push(cb)
275
+ }
276
+ }
277
+
278
+ // calcNextBackoff returns number of seconds we must wait
279
+ // before sending a next request
280
+ // the results is determind based on exponential backoff algorithm
281
+ // see https://en.wikipedia.org/wiki/Exponential_backoff
282
+ function calcNextBackoff(attempts) {
283
+ var RECONNECT_INTERVAL = 1000
284
+ var MAX_RECONNECT_INTERVAL = 10000
285
+ var RECONNECT_DECAY = 1.5
286
+
287
+ if (!attempts || attempts === -1) return 0 // first time connect
288
+ var delaytime = RECONNECT_INTERVAL * Math.pow(RECONNECT_DECAY, attempts)
289
+ return Math.min(MAX_RECONNECT_INTERVAL, delaytime)
290
+ }
291
+
292
+ // retryable decides whether we should resent the HTTP request
293
+ // based on last response or network state
294
+ function retryable(code) {
295
+ // code = 0 means server return response without CORS header, so we are unable to read it
296
+ // code = -1 means the network connection is dead
297
+ return (code >= 500 && code < 600) || code === 0 || code === -1 || code === 429
298
+ }
299
+
300
+ function repeat(ele, n) {
301
+ var arr = []
302
+ for (var i = 0; i < n; i++) arr.push(ele)
303
+ return arr
304
+ }
305
+
306
+ function absUrl(url) {
307
+ url = url || ''
308
+ if (url.startsWith('//')) return 'https:' + url
309
+ return url
310
+ }
311
+
312
+ module.exports = Realtime
@@ -0,0 +1,99 @@
1
+ var test = require('tape')
2
+ var Realtime = require('./realtime.js')
3
+
4
+ function makeMockServer(script) {
5
+ var i = -1
6
+ return (method, url, body, cb) => {
7
+ i++
8
+ script[i](method, url, body, cb)
9
+ }
10
+ }
11
+
12
+ test('realtime', async (t) => {
13
+ var callapi = makeMockServer([
14
+ async (method, url, body, cb) => {
15
+ // must first call sub and must attach user-mask in the query
16
+ t.equal(method, 'post')
17
+ t.true(url.indexOf('subs?') !== -1)
18
+ t.true(url.indexOf('user-mask=mask123') !== -1)
19
+ cb(`{"initial_token": "token0"}`, 200)
20
+ },
21
+ async (method, url, body, cb) => {
22
+ // start the poll and must attach initial token in the query
23
+ t.equal(method, 'get')
24
+ t.true(url.indexOf('poll?') !== -1)
25
+ t.true(url.indexOf('token=token0') !== -1)
26
+ cb(`{"events": [], "sequential_token": "token1"}`, 200)
27
+ },
28
+ async (method, url, body, cb) => {
29
+ // next poll must attach last received token
30
+ t.equal(method, 'get')
31
+ t.true(url.indexOf('poll?') !== -1)
32
+ t.true(url.indexOf('token=token1') !== -1)
33
+ realtime.stop()
34
+ cb(`{"events": [], "token": "-"}`, 200)
35
+ },
36
+ ])
37
+
38
+ var realtime = new Realtime('', {user_mask: 'mask123'}, callapi)
39
+ var err = await realtime.subscribe(['message_pong'])
40
+ t.true(Object.keys(err).length === 0)
41
+ })
42
+
43
+ test('realtime subs when server down and then backup', async (t) => {
44
+ var callapi = makeMockServer([
45
+ async (method, url, body, cb) => {
46
+ // must first call sub and must attach user-mask in the query
47
+ t.equal(method, 'post')
48
+ t.true(url.indexOf('subs?') !== -1)
49
+ t.true(url.indexOf('user-mask=mask123') !== -1)
50
+ cb(`502 too busy`, 502)
51
+ },
52
+ async (method, url, body, cb) => {
53
+ // should retry
54
+ t.equal(method, 'post')
55
+ t.true(url.indexOf('subs?') !== -1)
56
+ t.true(url.indexOf('user-mask=mask123') !== -1)
57
+ cb(`502 still busy`, 502)
58
+ },
59
+ async (method, url, body, cb) => {
60
+ // should retry
61
+ t.equal(method, 'post')
62
+ t.true(url.indexOf('subs?') !== -1)
63
+ t.true(url.indexOf('user-mask=mask123') !== -1)
64
+ cb(`{"initial_token": "token0"}`, 200)
65
+ },
66
+ async (method, url, body, cb) => {
67
+ t.equal(method, 'get')
68
+ t.true(url.indexOf('poll?') !== -1)
69
+ t.true(url.indexOf('token=token0') !== -1)
70
+ realtime.stop()
71
+ cb(`{"events": [], "token": "-"}`, 200)
72
+ },
73
+ ])
74
+
75
+ var realtime = new Realtime('', {user_mask: 'mask123'}, callapi)
76
+ var err = await realtime.subscribe(['message_pong'])
77
+ t.true(Object.keys(err).length === 0)
78
+ })
79
+
80
+ test('realtime must stop if there is something wrong with our credential or our code', async (t) => {
81
+ var subcode
82
+ var callapi = makeMockServer([
83
+ async (method, url, bkody, cb) => {
84
+ // must first call sub and must attach user-mask in the query
85
+ t.equal(method, 'post')
86
+ t.true(url.indexOf('subs?') !== -1)
87
+ t.true(url.indexOf('user-mask=mask123') !== -1)
88
+ cb(`400 invalid mask`, 400)
89
+ realtime.stop()
90
+ },
91
+ ])
92
+
93
+ var realtime = new Realtime('', {user_mask: 'mask123'}, callapi)
94
+ realtime.onInterrupted((_, _1, code) => {
95
+ subcode = code
96
+ })
97
+ var err = await realtime.subscribe(['message_pong'])
98
+ t.false(Object.keys(err).length === 0)
99
+ })