@subiz/wsclient 4.0.36 → 4.0.37

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/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "@subiz/wsclient",
3
- "version": "4.0.36",
3
+ "version": "4.0.37",
4
4
  "main": "realtime.js",
5
+ "files": [
6
+ "realtime.js",
7
+ "webphone.js",
8
+ "webrtc.js",
9
+ "common.js",
10
+ "README.md"
11
+ ],
5
12
  "scripts": {
6
- "dev": "vite",
7
- "test": "node realtime.test.js"
13
+ "test": "node --test realtime.lifecycle.test.js common.transport.test.js webrtc.lifecycle.test.js && node realtime.test.js"
8
14
  },
9
15
  "repository": {
10
16
  "type": "git",
@@ -13,13 +19,8 @@
13
19
  "author": "Thanh Pham <thanhpham@vietnambiz.com>",
14
20
  "license": "MIT",
15
21
  "homepage": "https://github.com/subiz/wsclient#readme",
16
- "description": "",
22
+ "description": "Subiz realtime subscriptions over HTTP long polling and browser WebRTC calling.",
17
23
  "devDependencies": {
18
- "tape": "^5.10.2",
19
- "vite": "^8.2.1",
20
- "vite-plugin-commonjs": "^0.10.4"
21
- },
22
- "dependencies": {
23
- "@subiz/flow": "^1.1.0"
24
+ "tape": "^5.10.3"
24
25
  }
25
26
  }
package/realtime.js CHANGED
@@ -1,4 +1,3 @@
1
- var flow = require('@subiz/flow')
2
1
  var {xhrsend, parseJSON, Pubsub} = require('./common.js')
3
2
 
4
3
  // Conn represents a realtime connection between an user and the realtime
@@ -18,166 +17,215 @@ function Conn(apiUrl, credential, onDead, onEvents, callAPI, accid) {
18
17
  callAPI = callAPI || xhrsend // allow hook
19
18
  credential = credential || {}
20
19
  var lastToken = ''
20
+ var lastAccessToken = ''
21
21
  if (!accid) accid = ''
22
22
  var is_dead = false // tell if the connection is dead
23
23
  var is_polling_stuck = false // tell if we've having trouble doing /poll
24
24
  var is_sub_stuck = true // tell if we've having trouble doing /subscribe
25
25
 
26
- // The long polling loop runs sequentially, with each poll starting immediately
27
- // after the previous one completes. If an error occurs, the loop will pause
28
- // using an exponential backoff strategy. It will terminate if an unretryable
29
- // error is encountered.
30
- // The first loop begins after the first subscription call completes successfully
31
- var MIN_POLL_INTERVAL = 250 // max 4 req/s, in case the server returns instantly instead of holding the long poll
32
- var last_poll_at = 0
33
- var polling_started = false // make sure only one polling loop is running
34
- var startPolling = function () {
26
+ // Server holds polls for at most 90s. Allow transport overhead, but recover
27
+ // before a request can hang forever. Custom transports may return an abort handle.
28
+ var POLL_TIMEOUT = 105000
29
+ var timers = new Set()
30
+ var requests = new Set()
31
+ var subscriptions = new Set()
32
+ function later(fn, delay) {
33
+ var timer = setTimeout(function () {
34
+ timers.delete(timer)
35
+ if (!is_dead) fn()
36
+ }, delay)
37
+ timers.add(timer)
38
+ return timer
39
+ }
40
+ function cancelTimer(timer) {
41
+ clearTimeout(timer)
42
+ timers.delete(timer)
43
+ }
44
+ function request(method, url, body, timeout, cb) {
45
+ var settled = false
46
+ var transport
47
+ function abort() {
48
+ try {
49
+ if (typeof transport === 'function') transport()
50
+ else if (transport && typeof transport.abort === 'function') transport.abort()
51
+ } catch (err) {
52
+ reportCallbackError(err)
53
+ }
54
+ }
55
+ function cancel() {
56
+ if (settled) return
57
+ settled = true
58
+ cancelTimer(timer)
59
+ requests.delete(cancel)
60
+ abort()
61
+ }
62
+ function complete(body, code) {
63
+ if (settled || is_dead) return
64
+ settled = true
65
+ cancelTimer(timer)
66
+ requests.delete(cancel)
67
+ cb(body, code)
68
+ }
69
+ requests.add(cancel)
70
+ var timer = later(function () {
71
+ cancel()
72
+ if (!is_dead) cb(undefined, -1)
73
+ }, timeout)
74
+ try {
75
+ transport = callAPI(method, url, body, complete)
76
+ } catch (err) {
77
+ complete(undefined, -1)
78
+ }
79
+ // A synchronous custom transport can stop the connection before returning.
80
+ if (is_dead) abort()
81
+ }
82
+
83
+ var MIN_POLL_INTERVAL = 250
84
+ var polling_started = false
85
+ function startPolling() {
35
86
  if (polling_started) return
36
87
  polling_started = true
37
88
  polling(0)
38
89
  }
39
- var polling = function (backoff) {
90
+ function polling(backoff) {
40
91
  if (is_dead) return
41
- last_poll_at = Date.now()
42
- callAPI('get', apiUrl + 'poll?token=' + lastToken + '&v=5&account-id=' + accid, undefined, function (body, code) {
43
- if (is_dead) return
44
- var wait = MIN_POLL_INTERVAL - (Date.now() - last_poll_at)
45
- if (wait < 0) wait = 0
46
- if (retryable(code)) {
47
- is_polling_stuck = true
48
- return setTimeout(polling, Math.max(calcNextBackoff(backoff), wait), backoff + 1)
49
- }
50
-
51
- if (code !== 200) {
52
- // unretryable error, should kill
53
- is_polling_stuck = true
54
- return kill('poll')
55
- }
56
-
57
- // 200, success
58
- is_polling_stuck = false
59
- body = parseJSON(body) || {}
60
- if (body.host) apiUrl = absUrl(body.host)
92
+ var started = Date.now()
93
+ request(
94
+ 'get',
95
+ apiUrl + 'poll?token=' + encodeURIComponent(lastToken) + '&v=5&account-id=' + encodeURIComponent(accid),
96
+ undefined,
97
+ POLL_TIMEOUT,
98
+ function (body, code) {
99
+ var wait = Math.max(0, MIN_POLL_INTERVAL - (Date.now() - started))
100
+ if (retryable(code)) {
101
+ is_polling_stuck = true
102
+ return later(
103
+ function () {
104
+ polling(backoff + 1)
105
+ },
106
+ Math.max(calcNextBackoff(backoff), wait),
107
+ )
108
+ }
109
+ if (code !== 200) {
110
+ is_polling_stuck = true
111
+ return kill('poll')
112
+ }
113
+ body = parseJSON(body) || {}
114
+ if (!body.sequential_token) return kill('poll')
115
+ is_polling_stuck = false
116
+ if (body.host) apiUrl = absUrl(body.host)
117
+ lastToken = body.sequential_token
118
+ onEvents(body.events || [])
119
+ if (!is_dead)
120
+ later(function () {
121
+ polling(0)
122
+ }, wait)
123
+ },
124
+ )
125
+ }
61
126
 
62
- var seqToken = body.sequential_token
63
- // the server returns a malform payload. We should end
64
- if (!seqToken) return kill('poll')
65
- lastToken = seqToken
66
- onEvents(body.events || [])
67
- if (wait > 0) return setTimeout(polling, wait, 0)
68
- return polling(0)
127
+ var pendingTopics = []
128
+ var batchTimer
129
+ var batchRunning = false
130
+ function scheduleBatch() {
131
+ if (is_dead || batchRunning || !pendingTopics.length) return
132
+ cancelTimer(batchTimer)
133
+ // Flush full queues early; smaller batches wait from their oldest topic.
134
+ var delay = pendingTopics.length > 50 ? 0 : Math.max(0, 100 - (Date.now() - pendingTopics[0].created))
135
+ batchTimer = later(flushBatch, delay)
136
+ }
137
+ function flushBatch() {
138
+ batchTimer = undefined
139
+ if (is_dead || batchRunning || !pendingTopics.length) return
140
+ batchRunning = true
141
+ var batch = pendingTopics.splice(0, 50)
142
+ function finish(results) {
143
+ batch.forEach(function (item, i) { item.resolve(results[i]) })
144
+ batchRunning = false
145
+ scheduleBatch()
146
+ }
147
+ subscribeBatch(batch.map(function (item) { return item.topic })).then(finish, function (err) {
148
+ reportCallbackError(err)
149
+ kill('subs')
150
+ finish(repeat('dead', batch.length))
69
151
  })
70
152
  }
71
-
72
- var subQueue = flow.batch(50, 100, function (topics) {
153
+ function subscribeBatch(topics) {
73
154
  if (is_dead) return Promise.resolve(repeat('dead', topics.length))
74
- if (topics.length <= 0) return Promise.resolve([])
75
-
76
- var resolve
77
- var promise = new Promise((rs) => {
78
- resolve = rs
79
- })
80
-
81
- var done = false
82
- setTimeout(function () {
83
- if (!done && !is_dead) is_sub_stuck = true
84
- }, 4000)
85
-
86
- setTimeout(() => {
87
- // too long, kill
88
- if (done) return
89
- done = true
90
- is_sub_stuck = true
91
- resolve(repeat('dead', topics.length))
92
- if (is_dead) return
93
- kill('subs')
94
- }, 60000)
95
-
96
- flow.loop(function () {
97
- return new Promise(function (rs) {
98
- if (is_dead) {
99
- resolve(repeat('dead', topics.length))
100
- return rs(false) // break loop
101
- }
102
-
103
- var query = '?token=' + lastToken
155
+ if (!topics.length) return Promise.resolve([])
156
+ return new Promise(function (resolve) {
157
+ var done = false
158
+ function finish(result) {
159
+ if (done) return
160
+ done = true
161
+ cancelTimer(stuckTimer)
162
+ cancelTimer(deadline)
163
+ cancelTimer(retryTimer)
164
+ subscriptions.delete(cancel)
165
+ resolve(result)
166
+ }
167
+ function cancel() {
168
+ finish(repeat('dead', topics.length))
169
+ }
170
+ subscriptions.add(cancel)
171
+ var stuckTimer = later(function () {
172
+ is_sub_stuck = true
173
+ }, 4000)
174
+ var deadline = later(function () {
175
+ is_sub_stuck = true
176
+ kill('subs')
177
+ }, 60000)
178
+ var retryTimer
179
+ function retry() {
180
+ if (done || is_dead) return
181
+ is_sub_stuck = true
182
+ retryTimer = later(attempt, 3000)
183
+ }
184
+ function attempt() {
185
+ if (done || is_dead) return
104
186
  Promise.resolve()
105
187
  .then(function () {
106
188
  return credential.getAccessToken()
107
189
  })
108
- .then(function (access_token) {
109
- if (is_dead) {
110
- resolve(repeat('dead', topics.length))
111
- return rs(false) // break loop
112
- }
113
-
190
+ .then(function (accessToken) {
191
+ if (done || is_dead) return
192
+ lastAccessToken = accessToken || ''
193
+ var query = '?token=' + encodeURIComponent(lastToken)
114
194
  if (credential.user_ref) query += '&user_ref=' + encodeURIComponent(credential.user_ref)
115
195
  else if (credential.user_mask) query += '&user-mask=' + encodeURIComponent(credential.user_mask)
116
- else if (access_token) query += '&access-token=' + access_token
117
-
118
- var fullurl =
119
- apiUrl + 'subs' + query + '&v=5&account-id=' + encodeURIComponent(accid || credential.account_id)
120
- callAPI('post', fullurl, JSON.stringify({events: topics}), function (body, code) {
121
- if (done) return
122
- if (is_dead) {
123
- resolve(repeat('dead', topics.length))
124
- return rs(false) // break loop
125
- }
126
-
127
- if (retryable(code)) {
128
- is_sub_stuck = true
129
- return setTimeout(rs, 3000, true)
130
- }
131
-
132
- // unretryable error
133
- if (code !== 200) {
134
- // we are unable to start the communication with the realtime server.
135
- // Must notify the user by killing the connection
136
- kill('subs')
137
- resolve(repeat('dead', topics.length))
138
- return rs(false)
196
+ else if (accessToken) query += '&access-token=' + encodeURIComponent(accessToken)
197
+ var url = apiUrl + 'subs' + query + '&v=5&account-id=' + encodeURIComponent(accid || credential.account_id)
198
+ request('post', url, JSON.stringify({events: topics}), 60000, function (body, code) {
199
+ if (done || is_dead) return
200
+ if (retryable(code)) return retry()
201
+ if (code !== 200) return kill('subs')
202
+ if (!lastToken) {
203
+ body = parseJSON(body) || {}
204
+ if (!body.initial_token) return kill('subs')
205
+ if (body.host) apiUrl = absUrl(body.host)
206
+ lastToken = body.initial_token
139
207
  }
140
208
  is_sub_stuck = false
141
- done = true
142
- if (lastToken) {
143
- resolve(topics)
144
- return rs(false)
145
- }
146
-
147
- // first time sub, should grab the initial token
148
- body = parseJSON(body) || {}
149
- var initialToken = body.initial_token
150
- // the server returns a malform payload. We should end
151
- if (!initialToken) {
152
- kill('subs')
153
- resolve(repeat('dead', topics.length))
154
- return rs(false) // break loop
155
- }
156
-
157
- // learn new host
158
- if (body.host) apiUrl = absUrl(body.host)
159
-
160
- lastToken = initialToken
209
+ finish(topics)
161
210
  startPolling()
162
- resolve(topics)
163
- return rs(false) // to break the loop
164
211
  })
165
212
  })
166
- .catch(function () {
167
- // getAccessToken failed, retry instead of hanging the loop
168
- is_sub_stuck = true
169
- setTimeout(rs, 3000, true)
170
- })
171
- })
213
+ .catch(retry)
214
+ }
215
+ attempt()
172
216
  })
173
-
174
- return promise
175
- })
217
+ }
176
218
 
177
219
  // subscribe call /subs API to tell server that we are listening for those events
178
220
  // this function may start polling loop if it hasn't been started yet
179
221
  // its safe to call this function multiple times
180
- this.subscribe = subQueue.push.bind(subQueue)
222
+ this.subscribe = function (topic) {
223
+ if (is_dead) return Promise.resolve('dead')
224
+ return new Promise(function (resolve) {
225
+ pendingTopics.push({topic, resolve, created: Date.now()})
226
+ scheduleBatch()
227
+ })
228
+ }
181
229
 
182
230
  this.getStatus = function () {
183
231
  if (is_dead) return 'dead'
@@ -185,9 +233,38 @@ function Conn(apiUrl, credential, onDead, onEvents, callAPI, accid) {
185
233
  return 'active'
186
234
  }
187
235
 
236
+ // Page exit cannot wait for credential refresh or a normal XHR response.
237
+ this.cleanup = function (browserWindow) {
238
+ if (is_dead || !lastToken) return
239
+ var url = apiUrl + 'cleanup?token=' + encodeURIComponent(lastToken) + '&account-id=' + encodeURIComponent(accid)
240
+ // Also support hosts where /rt goes through the standard API auth wrapper.
241
+ if (credential.user_ref) url += '&user_ref=' + encodeURIComponent(credential.user_ref)
242
+ else if (credential.user_mask) url += '&user-mask=' + encodeURIComponent(credential.user_mask)
243
+ else if (lastAccessToken) url += '&access-token=' + encodeURIComponent(lastAccessToken)
244
+ try {
245
+ if (browserWindow.navigator && typeof browserWindow.navigator.sendBeacon === 'function') {
246
+ if (browserWindow.navigator.sendBeacon(url)) return
247
+ }
248
+ } catch (e) {}
249
+ try {
250
+ if (typeof browserWindow.fetch === 'function') {
251
+ Promise.resolve(browserWindow.fetch(url, {method: 'POST', keepalive: true, credentials: 'include'})).catch(
252
+ function () {},
253
+ )
254
+ }
255
+ } catch (e) {}
256
+ }
257
+
188
258
  // kill force the connection to dead state
189
259
  var kill = function (reason) {
260
+ if (is_dead) return
190
261
  is_dead = true
262
+ for (var timer of timers) clearTimeout(timer)
263
+ timers.clear()
264
+ for (var cancel of requests) cancel()
265
+ for (var finish of subscriptions) finish()
266
+ for (var item of pendingTopics) item.resolve('dead')
267
+ pendingTopics = []
191
268
  onDead(reason)
192
269
  }
193
270
  this.kill = kill
@@ -205,13 +282,41 @@ function Realtime(apiUrls, credential, callAPI, accid, skipautoreconnect) {
205
282
  }
206
283
  }
207
284
  if (!accid) accid = credential.account_id || ''
208
- var pubsub = new Pubsub()
285
+ var pubsub = new Pubsub(reportCallbackError)
286
+ var reconnectTimer
287
+ var stopped = false
288
+ var leavingPage = false
289
+ var browserWindow =
290
+ typeof window !== 'undefined' &&
291
+ window.document &&
292
+ typeof window.addEventListener === 'function' &&
293
+ typeof window.removeEventListener === 'function'
294
+ ? window
295
+ : undefined
296
+
297
+ var onPageHide = function (event) {
298
+ // A page saved in the back/forward cache may resume this connection.
299
+ if (event.persisted || leavingPage) return
300
+ leavingPage = true
301
+ clearTimeout(reconnectTimer)
302
+ reconnectTimer = undefined
303
+ browserWindow.removeEventListener('pagehide', onPageHide)
304
+ if (!conn) return
305
+ var oldconn = conn
306
+ conn = undefined
307
+ oldconn.cleanup(browserWindow)
308
+ oldconn.kill('pagehide')
309
+ }
209
310
 
210
311
  // holds all topics that realtime must subscribed
211
312
  var topics = {}
212
313
 
213
314
  // stop the connection
214
315
  this.stop = function () {
316
+ stopped = true
317
+ clearTimeout(reconnectTimer)
318
+ reconnectTimer = undefined
319
+ if (browserWindow) browserWindow.removeEventListener('pagehide', onPageHide)
215
320
  if (!conn) return
216
321
  // detach before killing so onDead knows this is intentional and
217
322
  // doesn't schedule a reconnect
@@ -260,6 +365,11 @@ function Realtime(apiUrls, credential, callAPI, accid, skipautoreconnect) {
260
365
  // if the Conn is dead, it recreate a new one
261
366
  var conn
262
367
  var reconnect = function () {
368
+ if (leavingPage) return Promise.resolve({error: 'dead'})
369
+ stopped = false
370
+ clearTimeout(reconnectTimer)
371
+ reconnectTimer = undefined
372
+ if (browserWindow) browserWindow.addEventListener('pagehide', onPageHide)
263
373
  var randomUrl = apiUrls[Math.floor(Math.random() * apiUrls.length)]
264
374
  if (conn) {
265
375
  // detach before killing so the old conn's onDead doesn't
@@ -276,7 +386,7 @@ function Realtime(apiUrls, credential, callAPI, accid, skipautoreconnect) {
276
386
  if (conn === undefined || myconn != conn) return // outdated
277
387
  conn = undefined // prevent dupplicate dead fire
278
388
  pubsub.emit('interrupted', code, body, status)
279
- if (!skipautoreconnect) setTimeout(reconnect, 2000) // reconnect and resubscribe after 2 sec
389
+ if (!skipautoreconnect && !stopped && !leavingPage) reconnectTimer = setTimeout(reconnect, 2000)
280
390
  },
281
391
  function (events) {
282
392
  if (myconn != conn) return
@@ -329,4 +439,10 @@ function absUrl(url) {
329
439
  return url
330
440
  }
331
441
 
442
+ function reportCallbackError(err) {
443
+ try {
444
+ if (typeof console !== 'undefined' && console.error) console.error('Realtime callback failed:', err)
445
+ } catch (ignored) {}
446
+ }
447
+
332
448
  module.exports = Realtime
package/webphone.js CHANGED
@@ -1,9 +1,9 @@
1
- var {xhrsend, parseJSON, Pubsub, randomString} = require('./common.js')
1
+ var {xhrsend, parseJSON, Pubsub, randomString, reportCallbackError} = require('./common.js')
2
2
  const WebRTCConn = require('./webrtc.js')
3
3
  var Realtime = require('./realtime.js')
4
4
 
5
5
  function WebPhone(access_token, realtime) {
6
- var pubsub = new Pubsub()
6
+ var pubsub = new Pubsub(reportCallbackError)
7
7
 
8
8
  var $audio = null
9
9
  // chuẩn bị thẻ audio để nghe được
@@ -28,13 +28,18 @@ function WebPhone(access_token, realtime) {
28
28
  if (!agid.startsWith('ag')) throw 'invalid_access_token'
29
29
 
30
30
  if (!realtime)
31
- realtime = new Realtime('https://api.subiz.com.vn/rt/0/', {
32
- getAccessToken: function () {
33
- return new Promise(function (rs) {
34
- rs(access_token)
35
- })
31
+ realtime = new Realtime(
32
+ 'https://api.subiz.com.vn/rt/0/',
33
+ {
34
+ getAccessToken: function () {
35
+ return new Promise(function (rs) {
36
+ rs(access_token)
37
+ })
38
+ },
36
39
  },
37
- }, undefined, accid)
40
+ undefined,
41
+ accid,
42
+ )
38
43
 
39
44
  var webrtcconn = new WebRTCConn({
40
45
  accid: accid,
@@ -139,14 +144,9 @@ function WebPhone(access_token, realtime) {
139
144
  }
140
145
 
141
146
  this.answerWebCall = function (callid) {
142
- return new Promise(function (rs) {
143
- getMicroPermissions().then(function (stream) {
144
- current_call_id = callid
145
- webrtcconn.answerCall(callid, stream).then(function (out) {
146
- if (out.error) return rs({error: out.error})
147
- rs(out.body)
148
- })
149
- })
147
+ current_call_id = callid
148
+ return webrtcconn.answerCall(callid, getMicroPermissions()).then(function (out) {
149
+ return out.error ? {error: out.error} : out.body
150
150
  })
151
151
  }
152
152
 
@@ -159,6 +159,7 @@ function WebPhone(access_token, realtime) {
159
159
  // makeCall(number, ['a','b','c'], streamPm) // fallback specific number
160
160
  // makeCall(number, function() {}, streamPm) //
161
161
  this.makeCall = function (number, fromnumbers, streamPm, _a, _b, _state) {
162
+ var ownsSource = !_state
162
163
  if (!_state) _state = {}
163
164
  if (!fromnumbers || (Array.isArray(fromnumbers) && fromnumbers.length == 0)) {
164
165
  fromnumbers = _numbers
@@ -173,8 +174,6 @@ function WebPhone(access_token, realtime) {
173
174
  callid += `-${randomString(4, base)}-${randomString(12, base)}`
174
175
  current_call_id = callid
175
176
 
176
- if (!streamPm) streamPm = getMicroPermissions()
177
-
178
177
  let callnumber = chooseCallNumber(fromnumbers, _state.called)
179
178
  if (!callnumber)
180
179
  return Promise.resolve(
@@ -188,9 +187,13 @@ function WebPhone(access_token, realtime) {
188
187
  id: callid,
189
188
  hangup_code: 'failure',
190
189
  status: 'ended',
191
- }
190
+ },
192
191
  )
193
- return webrtcconn.makeCall(number, callnumber, streamPm, callid).then((out) => {
192
+ // Each attempt owns its clone. The source stays live across number fallback
193
+ // and is released once the complete dialing sequence finishes.
194
+ if (!_state.source) _state.source = Promise.resolve(streamPm || getMicroPermissions())
195
+ var attemptStream = _state.source.then((stream) => stream && stream.clone())
196
+ var result = webrtcconn.makeCall(number, callnumber, attemptStream, callid).then((out) => {
194
197
  if (out.error) return out
195
198
  if (out.hangup_code == 'cancel') return out
196
199
  if (out.answered > 0) return out
@@ -203,6 +206,14 @@ function WebPhone(access_token, realtime) {
203
206
  }
204
207
  return out
205
208
  })
209
+ if (!ownsSource) return result
210
+ return result.finally(() => {
211
+ _state.source
212
+ .then((stream) => {
213
+ if (stream) stream.getTracks().forEach((track) => track.stop())
214
+ }, reportCallbackError)
215
+ .catch(reportCallbackError)
216
+ })
206
217
  }
207
218
 
208
219
  var current_call_id = ''