@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.
- package/.claude/settings.local.json +12 -0
- package/README.md +122 -10
- package/package.json +3 -3
- package/realtime.js +85 -54
- package/webphone.js +10 -9
- package/webrtc.js +803 -183
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
10
|
+
## Install
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
```sh
|
|
13
|
+
npm install @subiz/wsclient
|
|
14
|
+
```
|
|
12
15
|
|
|
13
|
-
##
|
|
16
|
+
## Realtime
|
|
14
17
|
|
|
15
18
|
```js
|
|
16
19
|
var Realtime = require('@subiz/wsclient')
|
|
17
|
-
|
|
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.
|
|
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.
|
|
19
|
-
"vite": "^
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
104
|
-
return
|
|
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:
|
|
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
|
|
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
|
|
202
|
+
return me.makeCall(number, fromnumbers, streamPm, _a, _b, _state)
|
|
202
203
|
}
|
|
203
204
|
return out
|
|
204
205
|
})
|