@subiz/wsclient 4.0.28 → 4.0.31

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/README.md CHANGED
@@ -1,27 +1,30 @@
1
- # Subiz rtclient
1
+ # Subiz wsclient
2
2
 
3
3
  This library implements subiz long polling protocol
4
4
 
5
5
  ## Method
6
+
6
7
  ### subscribe
7
- ### onEvents
8
- ### onInterrupted
8
+
9
+ ### onEvent
10
+
9
11
  ### stop
10
12
 
11
13
  ## Example
12
- ```
14
+
15
+ ```js
13
16
  var Realtime = require('@subiz/wsclient')
14
17
  var realtime = new Realtime('https://realtime-0.subiz.net/', {
15
- account_id: 'ac1234',
16
- getAccessToken:()=> '333344',
17
- user_mask: 'mask123'
18
+ account_id: 'ac1234',
19
+ getAccessToken: () => '333344',
20
+ user_mask: 'mask123',
18
21
  })
19
22
 
20
- realtime.onEvent(ev => console.log(ev))
21
- realtime.onInterrupted(() => console.log('some message may have been losted'))
23
+ realtime.onEvent((ev) => console.log(ev))
22
24
 
23
25
  // subscribe events
24
- realtime.subscribe([ev1, ev2, ev3])
26
+ await realtime.subscribe(['message_sent', 'user_info_updated'])
27
+ console.log(realtime.getStatus()) // 'active'
25
28
 
26
29
  realtime.stop()
27
30
  ```
package/common.js CHANGED
@@ -64,8 +64,8 @@ var xhrsend = function (method, url, body, cb) {
64
64
  request.send(body)
65
65
  }
66
66
 
67
- // JSON.parse without exeption
68
- // return undefined when parsing invalid JSON string
67
+ // JSON.parse without exception
68
+ // return undefined when input is an invalid JSON
69
69
  function parseJSON(str) {
70
70
  try {
71
71
  return JSON.parse(str)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@subiz/wsclient",
3
- "version": "4.0.28",
3
+ "version": "4.0.31",
4
4
  "main": "realtime.js",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -16,7 +16,7 @@
16
16
  "description": "",
17
17
  "devDependencies": {
18
18
  "tape": "^5.9.0",
19
- "vite": "^6.0.7",
19
+ "vite": "^6.3.3",
20
20
  "vite-plugin-commonjs": "^0.10.4"
21
21
  },
22
22
  "dependencies": {
package/realtime.js CHANGED
@@ -8,121 +8,174 @@ var {xhrsend, parseJSON, Pubsub} = require('./common.js')
8
8
  // as fast as possible through onEvents callback.
9
9
  //
10
10
  // It will try it best to keep the communication with the realtime
11
- // server. But if the user is offline for too long or her network can't
12
- // keep up with the incomming events (rarely happend with today
13
- // internet speed). The connection will be halt. Dead.
11
+ // server. But if the user is offline for too long or the network can't
12
+ // keep up with incomming events (rarely happend with today internet speed).
13
+ // The connection will be killed. Dead.
14
14
  //
15
15
  // Once dead, onDead will be called. After that, the connection is
16
16
  // useless, all resources will be released.
17
17
  function Conn(apiUrl, credential, onDead, onEvents, callAPI, accid) {
18
18
  callAPI = callAPI || xhrsend // allow hook
19
19
  credential = credential || {}
20
- // tell if connection is dead
21
- var dead = false
22
20
 
23
21
  var lastToken = ''
24
- // kill force the connection to dead state
25
- this.kill = function () {
26
- dead = true
27
- }
28
22
  if (!accid) accid = ''
29
23
 
30
- // long polling loop, polling will run sequentialy. Each polling will start right
31
- // after the previous returned. In case of error, polling loop will pause based on
32
- // exponential backoff algorithm. Polling loop will terminate if it encounter an
33
- // un-retryable error
34
- // the polling loop starts after the first subscribe call finished successfully
24
+ var is_dead = false // tell if the connection is dead
25
+ var is_polling_stuck = false // tell if we've having trouble doing /poll
26
+ var is_sub_stuck = false // tell if we've having trouble doing /subscribe
27
+
28
+ // The long polling loop runs sequentially, with each poll starting immediately
29
+ // after the previous one completes. If an error occurs, the loop will pause
30
+ // using an exponential backoff strategy. It will terminate if an unretryable
31
+ // error is encountered.
32
+ // The first loop begins after the first subscription call completes successfully
35
33
  var polling = function (backoff) {
36
- if (dead) return
34
+ if (is_dead) return
37
35
  callAPI('get', apiUrl + 'poll?token=' + lastToken + '&account-id=' + accid, undefined, function (body, code) {
38
- if (dead) return
39
- if (retryable(code)) return setTimeout(polling, calcNextBackoff(backoff), backoff + 1)
36
+ if (is_dead) return
37
+ if (retryable(code)) {
38
+ is_polling_stuck = true
39
+ return setTimeout(polling, calcNextBackoff(backoff), backoff + 1)
40
+ }
41
+
40
42
  if (code !== 200) {
41
43
  // unretryable error, should kill
42
- dead = true
43
- return onDead('poll')
44
+ is_polling_stuck = true
45
+ return kill('poll')
44
46
  }
45
47
 
46
48
  // 200, success
49
+ is_polling_stuck = false
47
50
  body = parseJSON(body) || {}
48
51
  if (body.host) apiUrl = absUrl(body.host)
49
52
 
50
53
  var seqToken = body.sequential_token
51
- // the server returns a malform payload. We should retry and hope it heal soon
52
- if (!seqToken) return setTimeout(polling, calcNextBackoff(backoff), backoff + 1)
54
+ // the server returns a malform payload. We should end
55
+ if (!seqToken) return kill('poll')
53
56
  lastToken = seqToken
54
57
  onEvents(body.events || [])
55
58
  return polling(0)
56
59
  })
57
60
  }
58
61
 
59
- var subQueue = flow.batch(50, 100, function (events) {
60
- if (dead) return ['dead']
61
- if (events.length <= 0) return []
62
-
63
- var out = []
64
- return flow
65
- .loop(function () {
66
- return new Promise(function (rs) {
67
- var query = '?token=' + lastToken
68
- credential.getAccessToken().then(function (access_token) {
69
- if (credential.user_ref) query += '&user_ref=' + encodeURIComponent(credential.user_ref)
70
- else if (credential.user_mask) query += '&user-mask=' + encodeURIComponent(credential.user_mask)
71
- else if (access_token) query += '&access-token=' + access_token
72
-
73
- let fullurl = apiUrl + 'subs' + query + '&account-id=' + encodeURIComponent(accid || credential.account_id)
74
- callAPI('post', fullurl, JSON.stringify({events: events}), function (body, code) {
75
- if (dead) {
76
- out = repeat('dead', events.length)
77
- return rs(false) // break loop
78
- }
79
-
80
- if (retryable(code)) return setTimeout(rs, 3000, true)
81
- // unretryable error
82
- if (code !== 200) {
83
- // we are unable to start the communication with realtime server.
84
- // Must notify the user by killing the connection
85
- dead = true
86
- onDead('subscribe', body, code)
87
- out = repeat('dead', events.length)
88
- return rs(false)
89
- }
90
-
91
- if (lastToken) return rs(false)
92
-
93
- // first time sub, should grab the initial token
94
- body = parseJSON(body) || {}
95
- var initialToken = body.initial_token
96
- // the server returns a malform payload. We should retry and hope it heal soon
97
- if (!initialToken) return setTimeout(rs, 3000, true)
98
- if (body.host) apiUrl = absUrl(body.host)
99
-
100
- lastToken = initialToken
101
- polling(0)
62
+ var subQueue = flow.batch(50, 100, function (topics) {
63
+ if (is_dead) return Promise.resolve(repeat('dead', topics.length))
64
+ if (topics.length <= 0) return Promise.resolve([])
65
+
66
+ var resolve
67
+ var promise = new Promise((rs) => {
68
+ resolve = rs
69
+ })
70
+
71
+ var done = false
72
+ setTimeout(function () {
73
+ if (!done && !is_dead) is_sub_stuck = true
74
+ }, 4000)
75
+
76
+ setTimeout(() => {
77
+ // too long, kill
78
+ if (done) return
79
+ done = true
80
+ resolve(repeat('dead', topics.length))
81
+ if (is_dead) return
82
+ kill('subs')
83
+ }, 60000)
84
+
85
+ flow.loop(function () {
86
+ return new Promise(function (rs) {
87
+ if (is_dead) {
88
+ resolve(repeat('dead', topics.length))
89
+ return rs(false) // break loop
90
+ }
91
+
92
+ var query = '?token=' + lastToken
93
+ credential.getAccessToken().then(function (access_token) {
94
+ if (is_dead) {
95
+ resolve(repeat('dead', topics.length))
96
+ return rs(false) // break loop
97
+ }
98
+
99
+ if (credential.user_ref) query += '&user_ref=' + encodeURIComponent(credential.user_ref)
100
+ else if (credential.user_mask) query += '&user-mask=' + encodeURIComponent(credential.user_mask)
101
+ else if (access_token) query += '&access-token=' + access_token
102
+
103
+ var fullurl = apiUrl + 'subs' + query + '&account-id=' + encodeURIComponent(accid || credential.account_id)
104
+ callAPI('post', fullurl, JSON.stringify({events: topics}), function (body, code) {
105
+ if (done) return
106
+ if (is_dead) {
107
+ resolve(repeat('dead', topics.length))
108
+ return rs(false) // break loop
109
+ }
110
+
111
+ if (retryable(code)) {
112
+ is_sub_stuck = true
113
+ return setTimeout(rs, 3000, true)
114
+ }
115
+
116
+ // unretryable error
117
+ if (code !== 200) {
118
+ // we are unable to start the communication with the realtime server.
119
+ // Must notify the user by killing the connection
120
+ kill('subs')
121
+ resolve(repeat('dead', topics.length))
102
122
  return rs(false)
103
- })
123
+ }
124
+ is_sub_stuck = false
125
+ done = true
126
+ if (lastToken) {
127
+ resolve(topics)
128
+ return rs(false)
129
+ }
130
+
131
+ // first time sub, should grab the initial token
132
+ body = parseJSON(body) || {}
133
+ var initialToken = body.initial_token
134
+ // the server returns a malform payload. We should end
135
+ if (!initialToken) {
136
+ kill('subs')
137
+ resolve(repeat('dead', topics.length))
138
+ return rs(false) // break loop
139
+ }
140
+
141
+ // learn new host
142
+ if (body.host) apiUrl = absUrl(body.host)
143
+
144
+ lastToken = initialToken
145
+ polling(0)
146
+ resolve(topics)
147
+ return rs(false) // to break the loop
104
148
  })
105
149
  })
106
150
  })
107
- .then(function () {
108
- return out
109
- })
151
+ })
152
+
153
+ return promise
110
154
  })
111
155
 
112
156
  // subscribe call /subs API to tell server that we are listening for those events
113
157
  // this function may start polling loop if it hasn't been started yet
114
- // Note: this function is not design to be thread safed so, do not call this
115
- // function multiple times at once
158
+ // its safe to call this function multiple times
116
159
  this.subscribe = subQueue.push.bind(subQueue)
160
+
161
+ this.getStatus = function () {
162
+ if (is_dead) return 'dead'
163
+ if (is_sub_stuck || is_polling_stuck) return 'connecting'
164
+ return 'active'
165
+ }
166
+
167
+ // kill force the connection to dead state
168
+ var kill = function (reason) {
169
+ is_dead = true
170
+ onDead(reason)
171
+ }
172
+ this.kill = kill
117
173
  }
118
174
 
119
- // Realtime is just a stronger Conn.
120
- // This class helps you subscribe and listen realtime event from the
175
+ // Realtime helps you subscribe and listen realtime event from th
121
176
  // realtime server
122
- // additional features compare to conn:
123
- // + auto recreate and resub if the last conn is dead
124
- // + don't subscribe already subscribed events
125
- function Realtime(apiUrls, credential, callAPI, accid) {
177
+ // Realtime is just a stronger Conn.
178
+ function Realtime(apiUrls, credential, callAPI, accid, skipautoreconnect) {
126
179
  if (typeof apiUrls === 'string' || apiUrls instanceof String) apiUrls = [apiUrls]
127
180
  credential = credential || {}
128
181
  if (!credential.getAccessToken) {
@@ -134,19 +187,13 @@ function Realtime(apiUrls, credential, callAPI, accid) {
134
187
  var pubsub = new Pubsub()
135
188
 
136
189
  // holds all topics that realtime must subscribed
137
- // {
138
- // a: 'online',
139
- // b: 'offline',
140
- // c: 'connecting',
141
- // }
142
190
  var topics = {}
143
191
 
144
192
  // stop the connection
145
- var stop = false
146
193
  this.stop = function () {
147
- if (!conn || stop) return
148
- stop = true
194
+ if (!conn) return
149
195
  conn.kill()
196
+ conn = undefined
150
197
  pubsub.emit('interrupted')
151
198
  }
152
199
 
@@ -158,9 +205,13 @@ function Realtime(apiUrls, credential, callAPI, accid) {
158
205
  return pubsub.on('interrupted', cb)
159
206
  }
160
207
 
161
- var conn
208
+ this.getStatus = function () {
209
+ if (!conn) return 'dead'
210
+ return conn.getStatus()
211
+ }
212
+
162
213
  this.subscribe = function (events) {
163
- if (stop) return Promise.resolve({})
214
+ if (!conn) return Promise.resolve({error: 'dead'})
164
215
  if (typeof events === 'string') events = [events]
165
216
  if (!Array.isArray(events)) return Promise.resolve({error: 'param should be an array or string'})
166
217
 
@@ -169,46 +220,46 @@ function Realtime(apiUrls, credential, callAPI, accid) {
169
220
  for (var i = 0; i < events.length; i++) {
170
221
  var topic = events[i]
171
222
  if (!topic) continue
172
- if (!topics[topic]) all.push(conn.subscribe(topic))
223
+ if (topics[topic]) continue
224
+ topics[topic] = true // mark so next time we wont subscribe this event (topic) again
225
+ all.push(conn.subscribe(topic))
173
226
  }
174
227
  if (all.length === 0) return Promise.resolve({})
175
- return Promise.all(all).then(function (errs) {
176
- for (var i = 0; i < errs.length; i++) if (errs[i]) return {error: errs[i]}
177
- for (var j = 0; j < events.length; j++) topics[events[j]] = true
228
+ return Promise.all(all).then(function () {
229
+ if (!conn || conn.getStatus() == 'dead') return {error: 'dead'}
178
230
  return {}
179
231
  })
180
232
  }
181
233
 
182
234
  // reconnect make sure there is alway a Conn running in the background
183
235
  // if the Conn is dead, it recreate a new one
184
- var reconnect = function () {
185
- if (stop) return
186
- // reset subscribed topic
187
- var allTopics = Object.keys(topics)
188
- for (var i = 0; i < allTopics.length; i++) topics[allTopics[i]] = false
189
-
236
+ var conn
237
+ this.reconnect = function () {
190
238
  var randomUrl = apiUrls[Math.floor(Math.random() * apiUrls.length)]
191
- conn = new Conn(
239
+ if (conn) conn.kill() // kill old connection
240
+ let myconn = new Conn(
192
241
  randomUrl,
193
242
  credential,
194
243
  function (code, body, status) {
195
- if (stop) return
244
+ if (myconn != conn) return // outdated
196
245
  pubsub.emit('interrupted', code, body, status)
197
- setTimeout(reconnect, 2000) // reconnect and resubscribe after 2 sec
246
+ if (!skipautoreconnect) setTimeout(this.reconnect, 2000) // reconnect and resubscribe after 2 sec
198
247
  },
199
- function (topics) {
200
- if (stop) return
201
- for (var i = 0; i < topics.length; i++) pubsub.emit('event', topics[i])
248
+ function (events) {
249
+ if (myconn != conn) return
250
+ for (var i = 0; i < events.length; i++) pubsub.emit('event', events[i])
202
251
  },
203
252
  callAPI,
204
253
  accid,
205
254
  )
255
+ conn = myconn
206
256
 
257
+ var copyTopics = Object.keys(topics)
258
+ topics = {}
207
259
  // resubscribe all subscribed events
208
- var topicKeys = Object.keys(topics)
209
- for (var i = 0; i < topicKeys.length; i++) conn.subscribe(topicKeys[i])
260
+ return this.subscribe(copyTopics)
210
261
  }
211
- reconnect()
262
+ this.reconnect()
212
263
  }
213
264
 
214
265
  // calcNextBackoff returns number of seconds we must wait
package/realtime.test.js CHANGED
@@ -1,99 +1,291 @@
1
1
  var test = require('tape')
2
2
  var Realtime = require('./realtime.js')
3
3
 
4
- function makeMockServer(script) {
5
- var i = -1
6
- return (method, url, body, cb) => {
7
- i++
8
- script[i](method, url, body, cb)
4
+ async function testScript(t, script) {
5
+ let events = []
6
+ let calls = []
7
+ var realtime = new Realtime(
8
+ 'https://api5.subiz.com.vn/',
9
+ {user_mask: 'mask123'},
10
+ (method, url, body, cb) => {
11
+ calls.push({method, url, body, cb})
12
+ },
13
+ 'acctest',
14
+ true,
15
+ )
16
+ realtime.onEvent((ev) => events.push(ev))
17
+
18
+ for (let stepi in script.steps) {
19
+ let step = script.steps[stepi]
20
+ if (step.type == 'status') {
21
+ t.equal(realtime.getStatus(), step.status, script.name + '[' + stepi + ']')
22
+ continue
23
+ }
24
+
25
+ if (step.type == 'subscribe') {
26
+ realtime.subscribe(step.topics)
27
+ continue
28
+ }
29
+
30
+ if (step.type == 'reconnect') {
31
+ realtime.reconnect()
32
+ continue
33
+ }
34
+
35
+ if (step.type == 'wait') {
36
+ await sleep(step.timeout)
37
+ continue
38
+ }
39
+
40
+ if (step.type == 'call') {
41
+ t.true(step.call_id != undefined) // must define call id in the script
42
+ let start = Date.now()
43
+ let timeout = step.timeout || 10_000
44
+ let istimeout
45
+ for (;;) {
46
+ if (Date.now() - start > timeout) {
47
+ istimeout = true
48
+ break
49
+ }
50
+ // wait util api is called
51
+ await sleep(100)
52
+ let call
53
+ for (let c = 0; c < calls.length; c++) {
54
+ if (!calls[c].call_id) {
55
+ call = calls[c]
56
+ break
57
+ }
58
+ }
59
+ if (!call) continue
60
+ call.call_id = step.call_id
61
+ const myUrl = new URL(call.url)
62
+
63
+ if (step.path) t.equal(myUrl.pathname, step.path)
64
+
65
+ if (step.method) t.equal(step.method, call.method)
66
+
67
+ if (step.query) {
68
+ for (var key of Object.keys(step.query)) {
69
+ t.equal(step.query[key], myUrl.searchParams.get(key), script.name)
70
+ }
71
+ }
72
+
73
+ if (step.body) {
74
+ let keys = Object.keys(step.body)
75
+ let callbody
76
+ if (call.body) callbody = JSON.parse(call.body)
77
+ for (var key of keys) {
78
+ t.equal(JSON.stringify(step.body[key]), JSON.stringify(callbody[key]))
79
+ }
80
+ }
81
+ break
82
+ }
83
+
84
+ t.true(!istimeout, script.name + ' ' + stepi + ' ' + JSON.stringify(step))
85
+ continue
86
+ }
87
+
88
+ if (step.type == 'response') {
89
+ let call
90
+ for (var c = 0; c < calls.length; c++) {
91
+ if (calls[c].call_id == step.call_id) {
92
+ call = calls[c]
93
+ break
94
+ }
95
+ }
96
+
97
+ t.true(call != undefined)
98
+ call.cb(JSON.stringify(step.body), step.code)
99
+ continue
100
+ }
101
+ t.true(false, script.name + '[' + stepi + ']=' + step.type)
102
+ }
103
+
104
+ // check events
105
+ let scriptevents = script.events || []
106
+ t.equal(events.length, scriptevents.length)
107
+ for (let i in events) {
108
+ t.equal(JSON.stringify(events[i]), JSON.stringify(scriptevents[i]))
9
109
  }
110
+ realtime.stop()
10
111
  }
11
112
 
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)
113
+ test.only('realtime', async (t) => {
114
+ let scripts = [
115
+ {
116
+ name: 'starting status',
117
+ steps: [
118
+ {type: 'status', status: 'active'},
119
+ {type: 'subscribe', topics: ['message_sent']},
120
+ {type: 'status', status: 'active'},
121
+ {type: 'call', call_id: '1', path: '/subs', method: 'post', body: {events: ['message_sent']}},
122
+ {type: 'wait', timeout: 5000},
123
+ {type: 'status', status: 'connecting'},
124
+ {type: 'wait', timeout: 60000},
125
+ {type: 'status', status: 'dead'},
126
+ {type: 'response', call_id: '1', body: {initial_token: 'aaaaaa'}, code: 200},
127
+ {type: 'status', status: 'dead'}, // dead must be dead
128
+ ],
20
129
  },
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)
130
+ {
131
+ name: 'long subscribe',
132
+ steps: [
133
+ {type: 'subscribe', topics: ['message_sent']},
134
+ {type: 'status', status: 'active'},
135
+ {type: 'call', call_id: '1', path: '/subs', method: 'post', body: {events: ['message_sent']}},
136
+ {type: 'wait', timeout: 5000},
137
+ {type: 'status', status: 'connecting'},
138
+ {type: 'wait', timeout: 3000},
139
+ {type: 'status', status: 'connecting'},
140
+ {type: 'response', call_id: '1', body: {initial_token: 'aaaaaa'}, code: 200},
141
+ {type: 'status', status: 'active'},
142
+ ],
143
+ events: [],
27
144
  },
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)
145
+ {
146
+ name: 'double subscribe -> do nothing',
147
+ steps: [
148
+ {type: 'subscribe', topics: ['message_sent']},
149
+ {type: 'status', status: 'active'},
150
+ {type: 'call', call_id: '1', path: '/subs', method: 'post', body: {events: ['message_sent']}},
151
+ {type: 'response', call_id: '1', body: {initial_token: 'token1'}, code: 200},
152
+ {type: 'call', call_id: '2', path: '/poll', query: {token: 'token1'}, method: 'get'},
153
+ {type: 'subscribe', topics: ['message_sent', 'message_pong']},
154
+ {type: 'call', call_id: '3', path: '/subs', method: 'post', body: {events: ['message_pong']}},
155
+ ],
35
156
  },
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)
157
+ {
158
+ name: 'polling unstable',
159
+ steps: [
160
+ {type: 'subscribe', topics: ['message_sent']},
161
+ {type: 'status', status: 'active'},
162
+ {type: 'call', call_id: '1', path: '/subs', method: 'post', body: {events: ['message_sent']}},
163
+ {type: 'wait', timeout: 1000},
164
+ {type: 'response', call_id: '1', body: {initial_token: 'token1'}, code: 200},
165
+ {type: 'status', status: 'active'},
166
+ {type: 'call', call_id: '2', path: '/poll', query: {token: 'token1'}, method: 'get'},
167
+ {type: 'wait', timeout: 30000},
168
+ {type: 'response', call_id: '2', body: 'network disconnected', code: 0},
169
+ {type: 'status', status: 'connecting'},
170
+ {type: 'call', call_id: '3', path: '/poll', query: {token: 'token1'}, method: 'get'},
171
+ {type: 'wait', timeout: 30000},
172
+ {type: 'response', call_id: '3', body: '500 keep internal', code: 500},
173
+ // more than 60 sec without any data => dead
174
+ {type: 'status', status: 'connecting'},
175
+ ],
51
176
  },
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)
177
+ {
178
+ name: 'polling dead',
179
+ steps: [
180
+ {type: 'subscribe', topics: ['message_sent']},
181
+ {type: 'status', status: 'active'},
182
+ {type: 'call', call_id: '1', path: '/subs', method: 'post', body: {events: ['message_sent']}},
183
+ {type: 'wait', timeout: 1000},
184
+ {type: 'response', call_id: '1', body: {initial_token: 'token1'}, code: 200},
185
+ {type: 'status', status: 'active'},
186
+ {type: 'call', call_id: '2', path: '/poll', query: {token: 'token1'}, method: 'get'},
187
+ {type: 'response', call_id: '2', body: '400 dead', code: 400},
188
+ {type: 'status', status: 'dead'},
189
+ ],
58
190
  },
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)
191
+ {
192
+ name: 'basic',
193
+ steps: [
194
+ {type: 'subscribe', topics: ['message_sent']},
195
+ {
196
+ type: 'call',
197
+ call_id: '1',
198
+ path: '/subs',
199
+ query: {'user-mask': 'mask123'},
200
+ method: 'post',
201
+ body: {events: ['message_sent']},
202
+ },
203
+ {type: 'response', call_id: '1', body: {initial_token: 'token1'}, code: 200},
204
+ {type: 'wait', timeout: 1000},
205
+ {type: 'call', call_id: '2', path: '/poll', query: {token: 'token1'}, method: 'get'},
206
+ {type: 'status', status: 'active'},
207
+ {type: 'wait', timeout: 3000},
208
+ {type: 'response', call_id: '2', body: {events: [{text: 'xin chao'}], sequential_token: 'token2'}, code: 200},
209
+ {type: 'status', status: 'active'},
210
+ {type: 'call', call_id: '3', path: '/poll', query: {token: 'token2'}, method: 'get'},
211
+ ],
212
+ events: [{text: 'xin chao'}],
65
213
  },
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)
214
+ {
215
+ name: 'realtime subs when server down and then backup',
216
+ steps: [
217
+ {type: 'subscribe', topics: ['message_sent']},
218
+ {
219
+ type: 'call',
220
+ call_id: '1',
221
+ path: '/subs',
222
+ method: 'post',
223
+ body: {events: ['message_sent']},
224
+ },
225
+ {type: 'wait', timeout: 1000},
226
+ {type: 'response', call_id: '1', body: '502 too busy', code: 502},
227
+ {
228
+ type: 'call',
229
+ call_id: '1.1',
230
+ path: '/subs',
231
+ method: 'post',
232
+ body: {events: ['message_sent']},
233
+ },
234
+ {type: 'status', status: 'connecting'},
235
+ {type: 'wait', timeout: 1000},
236
+ {type: 'response', call_id: '1.1', body: '502 still busy', code: 502},
237
+ {type: 'status', status: 'connecting'},
238
+ {type: 'wait', timeout: 1000},
239
+ {
240
+ type: 'call',
241
+ call_id: '1.2',
242
+ path: '/subs',
243
+ method: 'post',
244
+ body: {events: ['message_sent']},
245
+ },
246
+ {type: 'status', status: 'connecting'},
247
+ {type: 'response', call_id: '1.2', body: {initial_token: 'token1'}, code: 200},
248
+ {type: 'call', call_id: '2', path: '/poll', query: {token: 'token1'}, method: 'get'},
249
+ {type: 'status', status: 'active'},
250
+ {type: 'wait', timeout: 3000},
251
+ {type: 'response', call_id: '2', body: {events: [{text: 'xin chao'}], sequential_token: 'token2'}, code: 200},
252
+ {type: 'status', status: 'active'},
253
+ {type: 'call', call_id: '3', path: '/poll', query: {token: 'token2'}, method: 'get'},
254
+ ],
255
+ events: [{text: 'xin chao'}],
72
256
  },
73
- ])
257
+ {
258
+ name: 'realtime must stop if there is something wrong with our credential or our code',
259
+ steps: [
260
+ {type: 'subscribe', topics: ['message_sent']},
261
+ {type: 'call', call_id: 1, path: '/subs'},
262
+ {type: 'response', call_id: 1, body: '400 invalid mask', code: 400},
263
+ {type: 'status', status: 'dead'},
264
+ ],
265
+ },
266
+ {
267
+ name: 'reconect',
268
+ steps: [
269
+ {type: 'subscribe', topics: ['message_sent']},
270
+ {type: 'call', call_id: 1, path: '/subs'},
271
+ {type: 'response', call_id: 1, body: '400 invalid mask', code: 400},
272
+ {type: 'status', status: 'dead'},
273
+ {type: 'reconnect'},
274
+ {type: 'wait', timeout: 1000},
275
+ {type: 'call', call_id: 2, path: '/subs'},
276
+ {type: 'wait', timeout: 1000},
277
+ {type: 'response', call_id: 2, body: {initial_token: 'token1'}, code: 200},
278
+ {type: 'status', status: 'active'},
279
+ ],
280
+ },
281
+ ]
74
282
 
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)
283
+ let ps = []
284
+ for (const script of scripts) {
285
+ // if (script.name != 'double subscribe -> do nothing') continue
286
+ ps.push(testScript(t, script))
287
+ }
288
+ await Promise.all(ps)
78
289
  })
79
290
 
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
- })
291
+ let sleep = (ms) => new Promise((rs) => setTimeout(rs, ms))
package/demo.html DELETED
@@ -1,129 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link
6
- rel="stylesheet"
7
- href="https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/css/bootstrap.min.css"
8
- />
9
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10
- <title>Subiz WebPhone Demo</title>
11
- <style>
12
- #info {
13
- background: whitesmoke;
14
- border-radius: 5px;
15
- }
16
- label {
17
- font-weight: 600;
18
- margin-right: 10px;
19
- }
20
- .info_line {
21
- display: flex;
22
- align-items: flex-start;
23
- }
24
- </style>
25
- </head>
26
- <body class="container pt-3">
27
- <img
28
- width="80"
29
- src="https://storage.googleapis.com/sbz-email/images/d009afe944c1948998544e103ab0fef2_logo.png"
30
- />
31
- <h1 class="mt-4">Subiz call demo</h1>
32
- <button
33
- class="btn btn-primary"
34
- style="width: 120px"
35
- id="btnHangup"
36
- onClick="inputToken()"
37
- >
38
- Nhập token
39
- </button>
40
- <div id="tokenStatus" class="mt-2 text-danger">Chưa nhập token</div>
41
-
42
- <div class="mt-3" style="width: 250px">
43
- <div>
44
- <label>Đầu số</label>
45
- <input
46
- class="form-control"
47
- id="fromNumber"
48
- type="text"
49
- value="842473021368"
50
- />
51
- </div>
52
- <div class="mt-3">
53
- <label>Số khách</label>
54
- <input
55
- class="form-control"
56
- placeholder="036..."
57
- id="toNumber"
58
- type="text"
59
- />
60
- </div>
61
- <div class="mt-3">
62
- <button
63
- class="btn btn-success mr-3"
64
- style="width: 150px"
65
- id="btnCall"
66
- disabled
67
- onClick="makeCall()"
68
- >
69
- Gọi
70
- </button>
71
- <button
72
- class="btn btn-outline-danger"
73
- style="width: 79px"
74
- id="btnHangup"
75
- onClick="hangupCall()"
76
- >
77
- Ngắt
78
- </button>
79
- </div>
80
- </div>
81
- <h3 class="mt-4">Thông tin cuộc gọi</h3>
82
- <div class="p-2" id="info">
83
- <div class="info_line">
84
- <label>Call ID:</label>
85
- <div id="infoCallId"></div>
86
- </div>
87
- <div class="info_line">
88
- <label>Conversation ID:</label>
89
- <div id="infoConversationId"></div>
90
- </div>
91
- <div class="info_line">
92
- <label>Ghi âm:</label>
93
- <a href="#" id="infoRecordedUrl" target="_blank"></a>
94
- </div>
95
- <div class="info_line">
96
- <label>Gọi từ số:</label>
97
- <div id="infoFromNumber"></div>
98
- </div>
99
- <div class="info_line">
100
- <label>Gọi đến số:</label>
101
- <div id="infoToNumber"></div>
102
- </div>
103
- <div class="info_line">
104
- <label>Trạng thái:</label>
105
- <div id="infoStatus"></div>
106
- </div>
107
- <div class="info_line">
108
- <label>Gọi lúc:</label>
109
- <div id="infoCreated"></div>
110
- </div>
111
- <div class="info_line">
112
- <label>Thời lượng (giây):</label>
113
- <div id="infoDuration"></div>
114
- </div>
115
- <div class="info_line">
116
- <label>Mã ngắt máy:</label>
117
- <div id="infoHangupCode"></div>
118
- </div>
119
- </div>
120
- <div class="mt-4">
121
- <h3>Sự kiện realtime</h3>
122
- <pre
123
- style="padding: 10px 15px; border-radius: 5px; background: whitesmoke"
124
- id="infoLog"
125
- ></pre>
126
- </div>
127
- <script type="module" src="./demo.js"></script>
128
- </body>
129
- </html>
package/demo.js DELETED
@@ -1,207 +0,0 @@
1
- const SubizWebPhone = require('./webphone.js')
2
-
3
- let webphone
4
- var mycallid = 'x'
5
- let accid
6
- let eventLogs = {}
7
-
8
- // setTokenStatus renders whether token is valid or invalid
9
- function setTokenStatus(me) {
10
- let $tokenStatus = document.getElementById('tokenStatus')
11
- if (me == 'loading') {
12
- $tokenStatus.innerHTML = 'Đang kiểm tra'
13
- $tokenStatus.classList.add('text-muted')
14
- $tokenStatus.classList.remove('text-danger')
15
- $tokenStatus.classList.remove('text-success')
16
- return
17
- }
18
-
19
- if (!me || !me.account_id) {
20
- $tokenStatus.innerHTML = 'Token rỗng hoặc không hợp lệ. Vui lòng nhập lại'
21
- $tokenStatus.classList.remove('text-danger')
22
- $tokenStatus.classList.remove('text-success')
23
- $tokenStatus.classList.add('text-muted')
24
- return
25
- }
26
-
27
- $tokenStatus.innerHTML = 'Token hợp lệ. Xin chào ' + me.fullname
28
- $tokenStatus.classList.add('text-success')
29
- $tokenStatus.classList.remove('text-danger')
30
- $tokenStatus.classList.remove('text-muted')
31
- }
32
-
33
- function init() {
34
- setTokenStatus('loading')
35
- fetch(`https://api.subiz.com.vn/4.0/me?x-access-token=${access_token}`)
36
- .then((response) => {
37
- return response.json()
38
- })
39
- .then((me) => {
40
- setTokenStatus(me)
41
- if (!me || !me.account_id) return null
42
- return me
43
- })
44
- .then((me) => {
45
- if (!me) return
46
- // save token
47
- try {
48
- webphone = new SubizWebPhone(access_token)
49
- accid = access_token.split('_')[0]
50
- } catch (e) {
51
- alert('ERROR2: ' + e)
52
- // setTimeout(main)
53
- return
54
- }
55
-
56
- document.getElementById('btnCall').disabled = false
57
-
58
- webphone.onEvent(function (ev) {
59
- let call = ev.data.call_info
60
- let callid = call.call_id
61
- eventLogs[callid] = eventLogs[callid] || []
62
- if (callid != mycallid) return
63
- eventLogs[callid].push(ev)
64
-
65
- // render log
66
- let logM = eventLogs[callid]
67
- if (logM) {
68
- let log = logM
69
- .map((ev) => {
70
- return `${new Date(ev.created).toLocaleString()} | ${ev.type} | ${JSON.stringify(ev.data.call_info)}`
71
- })
72
- .join('\n')
73
- document.getElementById('infoLog').innerHTML = log
74
- }
75
-
76
- if (call.status == 'dialing') {
77
- document.getElementById('infoRecordedUrl').innerHTML = ''
78
- document.getElementById('infoRecordedUrl').href = undefined
79
- document.getElementById('info').style['background-color'] = '#fffed3'
80
- }
81
-
82
- if (call.status == 'active') {
83
- document.getElementById('info').style['background-color'] = '#8cff6b'
84
- }
85
-
86
- if (call.status == 'ended' || !call.status) {
87
- document.getElementById('info').style['background-color'] = '#ffc6c6'
88
- }
89
-
90
- if (call.status == 'ended' && ev.id) {
91
- // from server, not fake
92
- document.getElementById('infoRecordedUrl').innerHTML = 'Đang tải...'
93
- document.getElementById('infoRecordedUrl').href = undefined
94
-
95
- fetchRecordedUrl(callid).then((url) => {
96
- if (url == 'unavailable') {
97
- document.getElementById('infoRecordedUrl').innerHTML = 'Không khả dụng'
98
- document.getElementById('infoRecordedUrl').href = undefined
99
- return
100
- }
101
- document.getElementById('infoRecordedUrl').innerHTML = url
102
- document.getElementById('infoRecordedUrl').href = url
103
- })
104
- }
105
- })
106
- })
107
- }
108
-
109
- // inputToken asks access token from the agent
110
- function inputToken() {
111
- access_token = prompt('Vui lòng nhập access token')
112
- localStorage.setItem('access_token', access_token)
113
- if (access_token) init()
114
- }
115
-
116
- // fetchRecordedUrl attemps to get the recorded audio url
117
- // since we dont know when the file is uploaded, we must retry multiple times.
118
- // this code is only for demo purpose
119
- function fetchRecordedUrl(callid) {
120
- // try maximum 4 times
121
- return new Promise((rs) => {
122
- fetchConversation(callid).then((convo) => {
123
- if (convo.call.recorded_audio && convo.call.recorded_audio.url) {
124
- return rs(convo.call.recorded_audio.url)
125
- }
126
- setTimeout(() => {
127
- fetchConversation(callid).then((convo) => {
128
- if (convo.call.recorded_audio && convo.call.recorded_audio.url) {
129
- return rs(convo.call.recorded_audio.url)
130
- }
131
-
132
- setTimeout(() => {
133
- fetchConversation(callid).then((convo) => {
134
- if (convo.call.recorded_audio && convo.call.recorded_audio.url) {
135
- return rs(convo.call.recorded_audio.url)
136
- }
137
-
138
- setTimeout(() => {
139
- fetchConversation(callid).then((convo) => {
140
- if (convo.call.recorded_audio && convo.call.recorded_audio.url) {
141
- return rs(convo.call.recorded_audio.url)
142
- }
143
- rs('unavailable')
144
- })
145
- }, 4000)
146
- })
147
- }, 4000)
148
- })
149
- }, 4000)
150
- })
151
- })
152
- }
153
-
154
- // fetchConversation sends a request API to get the latest conversation
155
- function fetchConversation(callid) {
156
- let call = webphone.getCall(callid)
157
- if (!call) return Promise.resolve(null)
158
- let convoid = call.conversation_id
159
- if (!convoid) return Promise.resolve(null)
160
- return fetch(
161
- `https://api.subiz.com.vn/4.0/accounts/${accid}/conversations/${convoid}?x-access-token=${access_token}`,
162
- ).then((response) => {
163
- return response.json()
164
- })
165
- }
166
-
167
- function makeCall() {
168
- if (!webphone) return
169
- let fromNumber = document.getElementById('fromNumber').value
170
- let toNumber = document.getElementById('toNumber').value
171
- //webphone.makeCall(toNumber, fromNumber);
172
-
173
- webphone.makeCall('0364821895', ['935022139843821727', '842473021368'])
174
- mycallid = webphone.getCurrentCallId()
175
- }
176
-
177
- let access_token = localStorage.getItem('access_token')
178
- if (access_token) init()
179
-
180
- // update loop
181
- setInterval(function () {
182
- if (!webphone) return
183
- let call = webphone.getCall(mycallid)
184
- if (!call) return
185
- document.getElementById('infoCallId').innerHTML = call.call_id
186
- document.getElementById('infoFromNumber').innerHTML = call.from_number
187
- document.getElementById('infoConversationId').innerHTML = call.conversation_id
188
- document.getElementById('infoToNumber').innerHTML = call.to_number
189
- document.getElementById('infoStatus').innerHTML = call.status
190
- document.getElementById('infoCreated').innerHTML = new Date(call.started).toLocaleString()
191
- if (call.status == 'active')
192
- document.getElementById('infoDuration').innerHTML = Math.floor((Date.now() - call.started) / 1000) || 0
193
- else {
194
- document.getElementById('infoDuration').innerHTML = Math.floor((call.ended - call.started) / 1000) || 0
195
- }
196
- if (call.hangup_code) document.getElementById('infoHangupCode').innerHTML = call.hangup_code
197
- }, 500)
198
-
199
- function hangupCall() {
200
- if (!webphone) return
201
- webphone.hangupCall(mycallid)
202
- }
203
-
204
- // expose to window so we can call it in HTML
205
- window.makeCall = makeCall
206
- window.hangupCall = hangupCall
207
- window.inputToken = inputToken