@subiz/wsclient 4.0.34 → 4.0.36

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.
@@ -0,0 +1,12 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(git -C /Users/thanh/Developer/wsclient log --oneline -5 -- '*webphone*')",
5
+ "Bash(node --check /Users/thanh/Developer/wsclient/webphone.js)",
6
+ "Bash(npx prettier *)",
7
+ "Bash(node --check realtime.js)",
8
+ "Bash(git *)",
9
+ "Bash(node *)"
10
+ ]
11
+ }
12
+ }
package/README.md CHANGED
@@ -1,23 +1,26 @@
1
1
  # Subiz wsclient
2
2
 
3
- This library implements subiz long polling protocol
3
+ This library implements the Subiz long polling protocol. It keeps a realtime
4
+ connection to the Subiz realtime server, lets you subscribe to event topics and
5
+ delivers new events to you as fast as possible.
4
6
 
5
- ## Method
7
+ It also ships a `WebPhone` client (built on top of the realtime connection and
8
+ WebRTC) for making and answering calls in the browser.
6
9
 
7
- ### subscribe
10
+ ## Install
8
11
 
9
- ### onEvent
10
-
11
- ### stop
12
+ ```sh
13
+ npm install @subiz/wsclient
14
+ ```
12
15
 
13
- ## Example
16
+ ## Realtime
14
17
 
15
18
  ```js
16
19
  var Realtime = require('@subiz/wsclient')
17
- var realtime = new Realtime('https://realtime-0.subiz.net/', {
20
+
21
+ var realtime = new Realtime('https://api.subiz.com.vn/rt/0/', {
18
22
  account_id: 'ac1234',
19
- getAccessToken: () => '333344',
20
- user_mask: 'mask123',
23
+ getAccessToken: () => Promise.resolve('333344'),
21
24
  })
22
25
 
23
26
  realtime.onEvent((ev) => console.log(ev))
@@ -28,3 +31,112 @@ console.log(realtime.getStatus()) // 'active'
28
31
 
29
32
  realtime.stop()
30
33
  ```
34
+
35
+ ### new Realtime(apiUrls, credential, [callAPI], [accid], [skipautoreconnect])
36
+
37
+ - `apiUrls`: a realtime endpoint (string) or a list of endpoints, each must end
38
+ with `/`. When a list is given, one endpoint is picked at random per
39
+ connection.
40
+ - `credential`: `{account_id, getAccessToken, user_ref, user_mask}`.
41
+ `getAccessToken` should return a Promise resolving to the access token
42
+ (returning a plain string also works). `user_ref` or `user_mask` are used
43
+ instead of the access token when present.
44
+ - `callAPI` (optional): custom HTTP function `(method, url, body, cb)` used
45
+ instead of the built-in XHR, mainly for testing.
46
+ - `accid` (optional): account id, defaults to `credential.account_id`.
47
+ - `skipautoreconnect` (optional): when true, the connection is not recreated
48
+ automatically after it dies.
49
+
50
+ The connection auto-reconnects 2 seconds after it dies and resubscribes all
51
+ previously subscribed topics. Polling is throttled to at most 4 requests per
52
+ second even if the server misbehaves and answers instantly.
53
+
54
+ ### subscribe(topics)
55
+
56
+ Tells the server we are listening for those topics. `topics` is a string or an
57
+ array of strings. Already-subscribed topics are skipped, so it is safe to call
58
+ multiple times. Calls are batched (up to 50 topics per request).
59
+
60
+ Returns a Promise resolving to `{}` on success or `{error}` on failure.
61
+
62
+ ### onEvent(callback)
63
+
64
+ Registers a callback that is called with each incoming event.
65
+
66
+ ### onInterrupted(callback)
67
+
68
+ Registers a callback that is called whenever the connection dies (before an
69
+ automatic reconnect) or is stopped.
70
+
71
+ ### getStatus()
72
+
73
+ Returns the current connection status: `'active'`, `'connecting'` (having
74
+ trouble reaching the server, retrying) or `'dead'`.
75
+
76
+ ### stop()
77
+
78
+ Kills the connection and disables auto-reconnect. Call `reconnect()` to start
79
+ a new connection again.
80
+
81
+ ### reconnect()
82
+
83
+ Drops the current connection (if any), creates a new one and resubscribes all
84
+ topics. Returns the resubscribe Promise.
85
+
86
+ ## WebPhone
87
+
88
+ ```js
89
+ var WebPhone = require('@subiz/wsclient/webphone.js')
90
+
91
+ var phone = new WebPhone('ac1234_ag5678_...') // agent access token
92
+
93
+ phone.onEvent((ev) => console.log(ev.type, ev.data.call_info))
94
+
95
+ // outbound call, the from-number is picked from the account's activated
96
+ // call integrations, and automatically falls back to the next number when
97
+ // the call fails
98
+ var call = await phone.makeCall('84900000000')
99
+
100
+ // or force specific from-numbers
101
+ await phone.makeCall('84900000000', ['84911111111', '84922222222'])
102
+
103
+ phone.sendDtmf('1', callid)
104
+ phone.transferCall('84933333333', callid)
105
+ phone.hangupCall(callid)
106
+
107
+ // inbound: answer a ringing call
108
+ await phone.answerWebCall(callid)
109
+ ```
110
+
111
+ ### new WebPhone(access_token, [realtime])
112
+
113
+ `access_token` must be an agent token of the form `ac..._ag..._...`. A
114
+ `Realtime` instance is created automatically unless you pass your own.
115
+ An invisible `<audio>` tag is appended to the document to play the remote
116
+ audio.
117
+
118
+ ### Methods
119
+
120
+ - `makeCall(number, [fromnumbers], [streamPm])`: starts an outbound call and
121
+ returns a Promise resolving to the ended call object. `fromnumbers` is a
122
+ number, a string or an array of numbers to call from (defaults to all
123
+ activated call integrations). On `terminated`/`failure`/`congestion` the
124
+ call is retried with the next from-number until none is left (then it
125
+ resolves with the last failed result, or `error: 'out_of_number'` when
126
+ there was no number to call from at all).
127
+ - `answerWebCall(callid)`: asks for microphone permission and answers an
128
+ incoming call.
129
+ - `hangupCall([callid])`: hangs up the given call, defaults to the current
130
+ call.
131
+ - `sendDtmf(key, callid)`: sends a DTMF key press.
132
+ - `transferCall(number, callid)`: transfers the call to another number.
133
+ - `onEvent(callback)`: subscribes to call events (`call_ringing`,
134
+ `call_ended`, ...).
135
+ - `getCall(callid)` / `getCurrentCall()` / `getCurrentCallId()`: look up call
136
+ state.
137
+ - `isMicAllowed()`: resolves to `true`/`false` whether microphone access is
138
+ granted.
139
+ - `checkMic()`: returns `{timeout, result}` promises for microphone
140
+ permission, `timeout` resolves with `'Not_authorized'` after 500ms if the
141
+ user has not decided yet.
142
+ - `getMicroStream()`: returns the current microphone `MediaStream` if any.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@subiz/wsclient",
3
- "version": "4.0.34",
3
+ "version": "4.0.36",
4
4
  "main": "realtime.js",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -15,8 +15,8 @@
15
15
  "homepage": "https://github.com/subiz/wsclient#readme",
16
16
  "description": "",
17
17
  "devDependencies": {
18
- "tape": "^5.9.0",
19
- "vite": "^6.3.5",
18
+ "tape": "^5.10.2",
19
+ "vite": "^8.2.1",
20
20
  "vite-plugin-commonjs": "^0.10.4"
21
21
  },
22
22
  "dependencies": {
package/realtime.js CHANGED
@@ -28,13 +28,24 @@ function Conn(apiUrl, credential, onDead, onEvents, callAPI, accid) {
28
28
  // using an exponential backoff strategy. It will terminate if an unretryable
29
29
  // error is encountered.
30
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 () {
35
+ if (polling_started) return
36
+ polling_started = true
37
+ polling(0)
38
+ }
31
39
  var polling = function (backoff) {
32
40
  if (is_dead) return
41
+ last_poll_at = Date.now()
33
42
  callAPI('get', apiUrl + 'poll?token=' + lastToken + '&v=5&account-id=' + accid, undefined, function (body, code) {
34
43
  if (is_dead) return
44
+ var wait = MIN_POLL_INTERVAL - (Date.now() - last_poll_at)
45
+ if (wait < 0) wait = 0
35
46
  if (retryable(code)) {
36
47
  is_polling_stuck = true
37
- return setTimeout(polling, calcNextBackoff(backoff), backoff + 1)
48
+ return setTimeout(polling, Math.max(calcNextBackoff(backoff), wait), backoff + 1)
38
49
  }
39
50
 
40
51
  if (code !== 200) {
@@ -53,6 +64,7 @@ function Conn(apiUrl, credential, onDead, onEvents, callAPI, accid) {
53
64
  if (!seqToken) return kill('poll')
54
65
  lastToken = seqToken
55
66
  onEvents(body.events || [])
67
+ if (wait > 0) return setTimeout(polling, wait, 0)
56
68
  return polling(0)
57
69
  })
58
70
  }
@@ -89,64 +101,73 @@ function Conn(apiUrl, credential, onDead, onEvents, callAPI, accid) {
89
101
  }
90
102
 
91
103
  var query = '?token=' + lastToken
92
- credential.getAccessToken().then(function (access_token) {
93
- if (is_dead) {
94
- resolve(repeat('dead', topics.length))
95
- return rs(false) // break loop
96
- }
97
-
98
- if (credential.user_ref) query += '&user_ref=' + encodeURIComponent(credential.user_ref)
99
- else if (credential.user_mask) query += '&user-mask=' + encodeURIComponent(credential.user_mask)
100
- else if (access_token) query += '&access-token=' + access_token
101
-
102
- var fullurl =
103
- apiUrl + 'subs' + query + '&v=5&account-id=' + encodeURIComponent(accid || credential.account_id)
104
- callAPI('post', fullurl, JSON.stringify({events: topics}), function (body, code) {
105
- if (done) return
104
+ Promise.resolve()
105
+ .then(function () {
106
+ return credential.getAccessToken()
107
+ })
108
+ .then(function (access_token) {
106
109
  if (is_dead) {
107
110
  resolve(repeat('dead', topics.length))
108
111
  return rs(false) // break loop
109
112
  }
110
113
 
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))
122
- return rs(false)
123
- }
124
- is_sub_stuck = false
125
- done = true
126
- if (lastToken) {
114
+ if (credential.user_ref) query += '&user_ref=' + encodeURIComponent(credential.user_ref)
115
+ 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)
139
+ }
140
+ 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
161
+ startPolling()
127
162
  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
163
+ return rs(false) // to break the loop
164
+ })
165
+ })
166
+ .catch(function () {
167
+ // getAccessToken failed, retry instead of hanging the loop
168
+ is_sub_stuck = true
169
+ setTimeout(rs, 3000, true)
148
170
  })
149
- })
150
171
  })
151
172
  })
152
173
 
@@ -192,8 +213,11 @@ function Realtime(apiUrls, credential, callAPI, accid, skipautoreconnect) {
192
213
  // stop the connection
193
214
  this.stop = function () {
194
215
  if (!conn) return
195
- conn.kill()
216
+ // detach before killing so onDead knows this is intentional and
217
+ // doesn't schedule a reconnect
218
+ var oldconn = conn
196
219
  conn = undefined
220
+ oldconn.kill()
197
221
  pubsub.emit('interrupted')
198
222
  }
199
223
 
@@ -237,7 +261,14 @@ function Realtime(apiUrls, credential, callAPI, accid, skipautoreconnect) {
237
261
  var conn
238
262
  var reconnect = function () {
239
263
  var randomUrl = apiUrls[Math.floor(Math.random() * apiUrls.length)]
240
- if (conn) conn.kill() // kill old connection
264
+ if (conn) {
265
+ // detach before killing so the old conn's onDead doesn't
266
+ // schedule another reconnect (which would kill this new conn,
267
+ // schedule another one, and churn forever)
268
+ var oldconn = conn
269
+ conn = undefined
270
+ oldconn.kill()
271
+ }
241
272
  var myconn = new Conn(
242
273
  randomUrl,
243
274
  credential,
package/webphone.js CHANGED
@@ -34,7 +34,7 @@ function WebPhone(access_token, realtime) {
34
34
  rs(access_token)
35
35
  })
36
36
  },
37
- })
37
+ }, undefined, accid)
38
38
 
39
39
  var webrtcconn = new WebRTCConn({
40
40
  accid: accid,
@@ -52,6 +52,7 @@ function WebPhone(access_token, realtime) {
52
52
  },
53
53
  onTrack: function (event) {
54
54
  if (event.track.kind != 'audio') return
55
+ if (!$audio) return
55
56
  $audio.srcObject = event.streams[0]
56
57
  },
57
58
  })
@@ -100,8 +101,8 @@ function WebPhone(access_token, realtime) {
100
101
 
101
102
  var me = this
102
103
  this.isMicAllowed = function () {
103
- return me.checkMic().then(function (out) {
104
- return out.result
104
+ return me.checkMic().result.then(function (stream) {
105
+ return !!stream
105
106
  })
106
107
  }
107
108
 
@@ -117,7 +118,7 @@ function WebPhone(access_token, realtime) {
117
118
  var microPermission = getMicroPermissions()
118
119
  return {
119
120
  timeout: Promise.race([timeout, microPermission]),
120
- result: getMicroPermissions(),
121
+ result: microPermission,
121
122
  }
122
123
  }
123
124
 
@@ -142,8 +143,8 @@ function WebPhone(access_token, realtime) {
142
143
  getMicroPermissions().then(function (stream) {
143
144
  current_call_id = callid
144
145
  webrtcconn.answerCall(callid, stream).then(function (out) {
145
- if (out.error) return rs({error})
146
- rs(body)
146
+ if (out.error) return rs({error: out.error})
147
+ rs(out.body)
147
148
  })
148
149
  })
149
150
  })
@@ -163,7 +164,7 @@ function WebPhone(access_token, realtime) {
163
164
  fromnumbers = _numbers
164
165
  }
165
166
 
166
- if (typeof fromnumbers === 'string' || fromnumbers instanceof String || typeof value === 'number') {
167
+ if (typeof fromnumbers === 'string' || fromnumbers instanceof String || typeof fromnumbers === 'number') {
167
168
  fromnumbers = [fromnumbers + '']
168
169
  }
169
170
 
@@ -176,7 +177,7 @@ function WebPhone(access_token, realtime) {
176
177
 
177
178
  let callnumber = chooseCallNumber(fromnumbers, _state.called)
178
179
  if (!callnumber)
179
- return (
180
+ return Promise.resolve(
180
181
  _state.last_result || {
181
182
  start: Date.now(),
182
183
  ended: Date.now(),
@@ -198,7 +199,7 @@ function WebPhone(access_token, realtime) {
198
199
  if (!_state.called) _state.called = {}
199
200
  _state.called[callnumber] = true
200
201
  _state.last_result = out
201
- return this.makeCall(number, fromnumbers, streamPm, _a, _b, _state)
202
+ return me.makeCall(number, fromnumbers, streamPm, _a, _b, _state)
202
203
  }
203
204
  return out
204
205
  })