@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/README.md +363 -92
- package/common.js +54 -53
- package/package.json +11 -10
- package/realtime.js +249 -133
- package/webphone.js +31 -20
- package/webrtc.js +340 -277
- package/.claude/settings.local.json +0 -12
- package/.prettierrc.js +0 -19
- package/realtime.test.js +0 -338
- package/vite.config.js +0 -16
package/README.md
CHANGED
|
@@ -1,142 +1,413 @@
|
|
|
1
1
|
# Subiz wsclient
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
delivers new events to you as fast as possible.
|
|
3
|
+
Subiz realtime subscriptions over HTTP long polling, with browser audio calling
|
|
4
|
+
through WebRTC.
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
| Entry point | Purpose |
|
|
7
|
+
| --- | --- |
|
|
8
|
+
| `@subiz/wsclient` | `Realtime`: event subscriptions, polling, and reconnects. |
|
|
9
|
+
| `@subiz/wsclient/webphone.js` | `WebPhone`: microphone capture, remote audio playback, and outbound number fallback. |
|
|
10
|
+
| `@subiz/wsclient/webrtc.js` | `WebRTCConn`: signaling and call lifecycle with application-owned UI and media capture. |
|
|
9
11
|
|
|
10
|
-
##
|
|
12
|
+
## Installation and runtime
|
|
11
13
|
|
|
12
14
|
```sh
|
|
13
15
|
npm install @subiz/wsclient
|
|
14
16
|
```
|
|
15
17
|
|
|
18
|
+
The package has no runtime dependencies and exports CommonJS modules. Use a
|
|
19
|
+
CommonJS-capable bundler for browser applications. `Realtime` uses
|
|
20
|
+
`XMLHttpRequest` when available and falls back to
|
|
21
|
+
global `fetch`; a custom transport can also be supplied.
|
|
22
|
+
|
|
23
|
+
`WebPhone` requires a browser with `window`, `document.body`, WebRTC, and
|
|
24
|
+
`navigator.mediaDevices.getUserMedia`. Initialize it after the document body
|
|
25
|
+
exists, in client-side code rather than during server-side rendering. Microphone
|
|
26
|
+
capture requires a secure context and user permission. Start calls from a user
|
|
27
|
+
action and account for the browser's audio autoplay policy.
|
|
28
|
+
|
|
16
29
|
## Realtime
|
|
17
30
|
|
|
18
31
|
```js
|
|
19
|
-
|
|
32
|
+
const Realtime = require('@subiz/wsclient')
|
|
20
33
|
|
|
21
|
-
|
|
34
|
+
const realtime = new Realtime('https://api.subiz.com.vn/rt/0/', {
|
|
22
35
|
account_id: 'ac1234',
|
|
23
|
-
getAccessToken: () =>
|
|
36
|
+
getAccessToken: async () => 'YOUR_ACCESS_TOKEN',
|
|
24
37
|
})
|
|
25
38
|
|
|
26
|
-
realtime.onEvent((
|
|
39
|
+
realtime.onEvent((event) => console.log(event))
|
|
40
|
+
realtime.onInterrupted((reason) => console.log('Realtime interrupted:', reason))
|
|
41
|
+
|
|
42
|
+
async function startRealtime() {
|
|
43
|
+
const result = await realtime.subscribe(['message_sent', 'user_info_updated'])
|
|
44
|
+
if (result.error) {
|
|
45
|
+
console.error('Subscription failed:', result.error)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
console.log(realtime.getStatus())
|
|
49
|
+
}
|
|
27
50
|
|
|
28
|
-
|
|
29
|
-
await realtime.subscribe(['message_sent', 'user_info_updated'])
|
|
30
|
-
console.log(realtime.getStatus()) // 'active'
|
|
51
|
+
startRealtime().catch(console.error)
|
|
31
52
|
|
|
32
|
-
|
|
53
|
+
// When the application no longer needs events:
|
|
54
|
+
// realtime.stop()
|
|
33
55
|
```
|
|
34
56
|
|
|
35
|
-
|
|
57
|
+
Topic names and credentials must match the server's subscription permissions.
|
|
58
|
+
Construction creates the connection wrapper; polling starts after the first
|
|
59
|
+
successful nonempty subscription.
|
|
36
60
|
|
|
37
|
-
|
|
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.
|
|
61
|
+
### Constructor
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
```js
|
|
64
|
+
new Realtime(apiUrls, credential, callAPI, accid, skipautoreconnect)
|
|
65
|
+
```
|
|
53
66
|
|
|
54
|
-
|
|
67
|
+
Only `apiUrls` and the credentials needed by your server are required.
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
| Parameter | Behavior |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `apiUrls` | Endpoint string or nonempty array of endpoints, each ending in `/`. A host is selected randomly on each new connection. |
|
|
72
|
+
| `credential.account_id` | Account ID used unless the positional `accid` overrides it. |
|
|
73
|
+
| `credential.getAccessToken` | Function returning an access token or a Promise for one. Called for each subscription attempt, so it can refresh credentials. Defaults to an empty token. |
|
|
74
|
+
| `credential.user_ref` / `credential.user_mask` | Alternative subscription credentials. Precedence is `user_ref`, then `user_mask`, then the access token. `getAccessToken` is still called when either alternative is present. |
|
|
75
|
+
| `callAPI` | Optional transport; see [Custom HTTP transport](#custom-http-transport). |
|
|
76
|
+
| `accid` | Optional account ID override. |
|
|
77
|
+
| `skipautoreconnect` | Defaults to false. Set to true to disable automatic reconnects after connection failure. |
|
|
59
78
|
|
|
60
|
-
|
|
79
|
+
### Methods
|
|
61
80
|
|
|
62
|
-
|
|
81
|
+
| Method | Result and behavior |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `subscribe(topics)` | Accepts a string or array of strings. Resolves to `{}` on success or `{error}` on failure. Deduplicates topics and batches up to 50 per request. |
|
|
84
|
+
| `onEvent(callback)` | Registers a callback receiving each event. Returns `undefined`; no listener removal API is exposed. |
|
|
85
|
+
| `onInterrupted(callback)` | Registers a callback for connection failure or explicit stop. Failure supplies `'poll'` or `'subs'`; explicit stop supplies no reason. Page-exit cleanup does not emit this callback. |
|
|
86
|
+
| `getStatus()` | Returns `'connecting'`, `'active'`, or `'dead'`. Initial state is `'connecting'`; `'active'` means subscriptions and polling are not currently marked as stuck. |
|
|
87
|
+
| `stop()` | Stops local polling, aborts pending requests, and disables automatic reconnects. Preserves the topic list for a later explicit reconnect. |
|
|
88
|
+
| `reconnect()` | Replaces the connection and resubscribes remembered topics. Resolves to the subscription result, `{}` or `{error}`. Works after `stop()`. |
|
|
89
|
+
|
|
90
|
+
There is no topic unsubscribe API. An explicit `reconnect()` replaces the
|
|
91
|
+
connection without emitting `onInterrupted` for the old connection.
|
|
92
|
+
|
|
93
|
+
Subscriptions are queued internally and sent one batch at a time. Small batches
|
|
94
|
+
wait up to 100 ms from their first queued topic; a queue exceeding 50 topics can
|
|
95
|
+
flush earlier. A pending request can delay later batches. Stopping or replacing
|
|
96
|
+
the connection also settles subscriptions still waiting in the queue.
|
|
97
|
+
|
|
98
|
+
### Retry and delivery behavior
|
|
99
|
+
|
|
100
|
+
The client reconnects two seconds after a connection dies and resubscribes its
|
|
101
|
+
remembered topics, unless stopped or automatic reconnects are disabled.
|
|
102
|
+
|
|
103
|
+
- Polls have a 105-second deadline, allowing for the server's 90-second hold.
|
|
104
|
+
A timeout aborts the request and retries with the same sequential token.
|
|
105
|
+
- Network failures, HTTP 429, and HTTP 5xx are retryable. Poll retries use
|
|
106
|
+
backoff capped at ten seconds, with a minimum 250 ms between poll starts.
|
|
107
|
+
- Subscription batches have a 60-second total deadline, including credential
|
|
108
|
+
acquisition and retries. Retryable subscription failures wait three seconds
|
|
109
|
+
before another attempt. Exhausting the deadline kills the connection.
|
|
110
|
+
- Other unsuccessful HTTP statuses, or a successful initial subscription/poll
|
|
111
|
+
response missing its required token, kill the connection.
|
|
112
|
+
- `stop()` and `reconnect()` cancel connection requests and timers. Late
|
|
113
|
+
responses are ignored even if the transport cannot abort requests.
|
|
114
|
+
|
|
115
|
+
Listener exceptions and rejected Promises are reported through `console.error`
|
|
116
|
+
without blocking other listeners or polling. Listener Promises are not awaited:
|
|
117
|
+
protocol progress does not acknowledge completion of your asynchronous event
|
|
118
|
+
processing. The client does not persist events or deduplicate application event
|
|
119
|
+
IDs; handle replay and durable processing in your application when needed.
|
|
120
|
+
|
|
121
|
+
### Browser page exit
|
|
122
|
+
|
|
123
|
+
Each browser instance listens for `pagehide`. When `persisted` is false, it
|
|
124
|
+
sends a best-effort `POST` to `cleanup` on the latest realtime host using the
|
|
125
|
+
latest sequential token, account ID, and last subscription credentials. It uses
|
|
126
|
+
`navigator.sendBeacon`, falling back to `fetch` with `keepalive: true` if needed.
|
|
127
|
+
It does not wait for an access-token refresh or use the custom `callAPI` hook.
|
|
128
|
+
|
|
129
|
+
Cleanup is sent only after a subscription token has been received. The exiting
|
|
130
|
+
instance stops polling and will not reconnect. A new page must
|
|
131
|
+
create a new instance. No cleanup is sent for network loss, a background tab,
|
|
132
|
+
or `pagehide` with `persisted: true`. Runtimes without a browser document do not
|
|
133
|
+
install this listener. Delivery on page exit is not guaranteed; server expiry
|
|
134
|
+
handles abandoned connections if cleanup cannot be delivered.
|
|
135
|
+
|
|
136
|
+
`stop()` removes the page listener and stops local work; it does not send a
|
|
137
|
+
server cleanup request. An explicit `reconnect()` reattaches the listener while
|
|
138
|
+
the page is still open.
|
|
63
139
|
|
|
64
|
-
|
|
140
|
+
## WebPhone
|
|
65
141
|
|
|
66
|
-
|
|
142
|
+
```js
|
|
143
|
+
const WebPhone = require('@subiz/wsclient/webphone.js')
|
|
144
|
+
const phone = new WebPhone('ac1234_ag5678_YOUR_AGENT_TOKEN')
|
|
145
|
+
|
|
146
|
+
phone.onEvent((event) => {
|
|
147
|
+
const info = event.data.call_info
|
|
148
|
+
console.log(event.type, info.call_id, phone.getCall(info.call_id))
|
|
149
|
+
if (event.type === 'call_invited') {
|
|
150
|
+
// Show an Answer button that calls answerIncoming(info.call_id).
|
|
151
|
+
}
|
|
152
|
+
})
|
|
67
153
|
|
|
68
|
-
|
|
69
|
-
|
|
154
|
+
// Bind these functions to your application's call controls.
|
|
155
|
+
async function dial() {
|
|
156
|
+
// This Promise settles when the call ENDS or setup fails.
|
|
157
|
+
const result = await phone.makeCall('84900000000', ['84911111111', '84922222222'])
|
|
158
|
+
if (result.error) console.error('Call failed:', result.error)
|
|
159
|
+
else console.log('Call ended:', result.hangup_code)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function answerIncoming(callid) {
|
|
163
|
+
const result = await phone.answerWebCall(callid)
|
|
164
|
+
if (result && result.error) console.error('Answer failed:', result.error)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sendDigit(key) {
|
|
168
|
+
return phone.sendDtmf(key, phone.getCurrentCallId())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function transfer(number) {
|
|
172
|
+
return phone.transferCall(number, phone.getCurrentCallId())
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hangup() {
|
|
176
|
+
phone.hangupCall()
|
|
177
|
+
}
|
|
178
|
+
```
|
|
70
179
|
|
|
71
|
-
|
|
180
|
+
Do not wait for `makeCall()` to resolve before enabling hangup, DTMF, or transfer
|
|
181
|
+
controls. Use events and `getCurrentCallId()` while the call is in progress.
|
|
182
|
+
Each outbound fallback attempt gets a new call ID. Event `call_info` can be
|
|
183
|
+
partial; `getCall(callid)` provides the current locally known state.
|
|
72
184
|
|
|
73
|
-
|
|
74
|
-
trouble reaching the server, retrying) or `'dead'`.
|
|
185
|
+
### Constructor
|
|
75
186
|
|
|
76
|
-
|
|
187
|
+
```js
|
|
188
|
+
new WebPhone(access_token, realtime)
|
|
189
|
+
```
|
|
77
190
|
|
|
78
|
-
|
|
79
|
-
|
|
191
|
+
`access_token` must be an agent token shaped like `ac..._ag..._...`. The wrapper
|
|
192
|
+
uses this fixed token for signaling and integration lookup; it does not expose
|
|
193
|
+
a token refresh setter. The optional `realtime` must serve the same account and
|
|
194
|
+
agent. Otherwise, the wrapper creates its own `Realtime` instance.
|
|
80
195
|
|
|
81
|
-
|
|
196
|
+
The wrapper appends a hidden autoplay `<audio>` element to `document.body` and
|
|
197
|
+
assigns remote audio to it. It fetches activated call integrations immediately
|
|
198
|
+
and schedules another lookup two minutes after each response. If from-numbers
|
|
199
|
+
are omitted before that initial lookup finishes, dialing can return
|
|
200
|
+
`{error: 'out_of_number'}`. Pass explicit from-numbers when needed.
|
|
82
201
|
|
|
83
|
-
|
|
84
|
-
topics. Returns the resubscribe Promise.
|
|
202
|
+
### Methods
|
|
85
203
|
|
|
86
|
-
|
|
204
|
+
| Method | Result and behavior |
|
|
205
|
+
| --- | --- |
|
|
206
|
+
| `makeCall(number, fromnumbers, streamPm)` | Resolves to an ended call object or `{error}`. Optional `fromnumbers` accepts a string, number, or array; omitted/empty values use the discovered integrations. Optional `streamPm` supplies a microphone stream or Promise for one. |
|
|
207
|
+
| `answerWebCall(callid)` | Requests microphone access and answers. Resolves to the parsed response body or `{error}` after setup, rather than waiting for call end. |
|
|
208
|
+
| `hangupCall(callid)` | Ends the call locally and sends a best-effort server hangup. Defaults to the current call ID. Returns `undefined`. |
|
|
209
|
+
| `sendDtmf(key, callid)` | Sends a digit; resolves to `{body}` or `{error}`. Requires an explicit call ID. |
|
|
210
|
+
| `transferCall(number, callid)` | Requests a transfer; resolves to `{body}` or `{error}`. Requires an explicit call ID. |
|
|
211
|
+
| `onEvent(callback)` | Registers a callback for call events such as `call_invited`, `call_ringing`, `call_joined`, and `call_ended`. No unsubscribe handle is returned. |
|
|
212
|
+
| `getCall(callid)` | Returns known call state, or `undefined`. Omitting the ID returns the call map. Ended calls are periodically removed. |
|
|
213
|
+
| `getCurrentCall()` | Finds the current non-ended call, or returns `undefined`. May update the current call ID. |
|
|
214
|
+
| `getCurrentCallId()` | Returns the last selected call ID, or `''`. An ID alone does not mean the call is still active. |
|
|
215
|
+
| `checkMic()` | Starts a microphone request and returns `{timeout, result}` Promises; see below. |
|
|
216
|
+
| `isMicAllowed()` | Requests microphone access and resolves to a boolean. This can show a permission prompt and acquire a stream. |
|
|
217
|
+
| `getMicroStream()` | Returns the last acquired microphone stream, if any. Its tracks may already have stopped. |
|
|
218
|
+
|
|
219
|
+
Calls without a usable connection can make DTMF/transfer return an empty result
|
|
220
|
+
without sending a request. Treat these as commands for an established call.
|
|
221
|
+
|
|
222
|
+
### Number fallback and microphone ownership
|
|
223
|
+
|
|
224
|
+
From-numbers are attempted in order. An unanswered call retries the next number
|
|
225
|
+
only for `terminated`, `failure`, or `congestion` hangup codes. Cancellation,
|
|
226
|
+
an answered call, or a result with `error` stops the sequence. Exhaustion returns
|
|
227
|
+
the last failed call; if there was no eligible number, it returns an ended result
|
|
228
|
+
with `error: 'out_of_number'` without acquiring a microphone.
|
|
229
|
+
|
|
230
|
+
Each attempt uses a clone of the source microphone stream. The source stays
|
|
231
|
+
live across retries and all its tracks are stopped when the dialing sequence
|
|
232
|
+
finishes, including when you supplied `streamPm`. Supply a clone if another part
|
|
233
|
+
of the application needs to retain its own stream.
|
|
234
|
+
|
|
235
|
+
`checkMic().result` resolves to a `MediaStream`, or `undefined` if permission
|
|
236
|
+
fails. `checkMic().timeout` resolves to the same result if ready within 500 ms,
|
|
237
|
+
otherwise to `'Not_authorized'`. That string means the request is still pending,
|
|
238
|
+
not necessarily denied; the request continues. Standalone permission checks do
|
|
239
|
+
not stop the acquired tracks. Release an unused result yourself:
|
|
87
240
|
|
|
88
241
|
```js
|
|
89
|
-
|
|
242
|
+
async function checkAndReleaseMicrophone() {
|
|
243
|
+
const {result} = phone.checkMic()
|
|
244
|
+
const stream = await result
|
|
245
|
+
if (!stream) return false
|
|
246
|
+
stream.getTracks().forEach((track) => track.stop())
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Setup, errors, and lifecycle
|
|
252
|
+
|
|
253
|
+
Each call setup attempt has one shared **60-second deadline** covering microphone
|
|
254
|
+
permission, realtime subscription, SDP/ICE, and the call/answer response. It is
|
|
255
|
+
not reset between stages. A timeout returns `{error: 'setup_timeout'}` and
|
|
256
|
+
releases the peer and media. Successful setup clears the deadline; it does not
|
|
257
|
+
limit call duration.
|
|
258
|
+
|
|
259
|
+
Hangup settles a pending setup even while microphone permission is unresolved.
|
|
260
|
+
A stream granted after cancellation or timeout is stopped. Listener exceptions
|
|
261
|
+
and rejected Promises are logged without interrupting signaling or cleanup.
|
|
90
262
|
|
|
91
|
-
|
|
263
|
+
Common setup errors include `no_stream`, `setup_timeout`, and subscription or
|
|
264
|
+
signaling errors. Error values may include a server response, rather than a
|
|
265
|
+
fixed enum. Check the resolved result's `error` property and also handle rejected
|
|
266
|
+
Promises from application-supplied media providers or browser API failures.
|
|
92
267
|
|
|
93
|
-
|
|
268
|
+
Transport failures can produce local `call_ended` events. A disconnected peer
|
|
269
|
+
has a 30-second recovery window; failed or closed transports end immediately.
|
|
270
|
+
The client also checks for stale RTP counters and unanswered invitations, with
|
|
271
|
+
90-second and 120-second thresholds respectively when their checks apply.
|
|
94
272
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
273
|
+
`WebPhone` currently exposes no `shutdown()` or `destroy()` method. Hanging up
|
|
274
|
+
releases call media but does not stop its integration refresh timer, remove its
|
|
275
|
+
audio element, or dispose its realtime connection. Reuse one wrapper for the
|
|
276
|
+
page lifetime. Use `WebRTCConn` directly when you need explicit disposal or
|
|
277
|
+
control over audio rendering.
|
|
99
278
|
|
|
100
|
-
|
|
101
|
-
await phone.makeCall('84900000000', ['84911111111', '84922222222'])
|
|
279
|
+
## WebRTCConn (advanced)
|
|
102
280
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
phone.hangupCall(callid)
|
|
281
|
+
The lower-level client manages signaling and peers. Your application supplies
|
|
282
|
+
microphone streams, renders remote tracks, and owns the realtime connection.
|
|
106
283
|
|
|
107
|
-
|
|
108
|
-
|
|
284
|
+
```js
|
|
285
|
+
const Realtime = require('@subiz/wsclient')
|
|
286
|
+
const WebRTCConn = require('@subiz/wsclient/webrtc.js')
|
|
287
|
+
|
|
288
|
+
const accessToken = 'ac1234_ag5678_YOUR_AGENT_TOKEN'
|
|
289
|
+
const realtime = new Realtime('https://api.subiz.com.vn/rt/0/', {
|
|
290
|
+
account_id: 'ac1234',
|
|
291
|
+
getAccessToken: () => accessToken,
|
|
292
|
+
})
|
|
293
|
+
const audio = document.createElement('audio')
|
|
294
|
+
audio.autoplay = true
|
|
295
|
+
document.body.appendChild(audio)
|
|
296
|
+
|
|
297
|
+
const rtc = new WebRTCConn({
|
|
298
|
+
accid: 'ac1234',
|
|
299
|
+
agid: 'ag5678',
|
|
300
|
+
access_token: accessToken,
|
|
301
|
+
realtime,
|
|
302
|
+
env: window,
|
|
303
|
+
setupTimeoutMs: 60000,
|
|
304
|
+
onEvent: (event) => console.log(event),
|
|
305
|
+
onTrack: (event) => {
|
|
306
|
+
if (event.track.kind === 'audio') audio.srcObject = event.streams[0]
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// Bind to an Answer button for a call received through onEvent.
|
|
311
|
+
async function answer(callid) {
|
|
312
|
+
const streamPromise = navigator.mediaDevices.getUserMedia({audio: true, video: false})
|
|
313
|
+
const result = await rtc.answerCall(callid, streamPromise)
|
|
314
|
+
if (result.error) console.error(result.error)
|
|
315
|
+
return result
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function dispose() {
|
|
319
|
+
for (const [callid, call] of Object.entries(rtc.matchCall())) {
|
|
320
|
+
if (call.status !== 'ended') rtc.hangupCall(callid)
|
|
321
|
+
}
|
|
322
|
+
rtc.shutdown()
|
|
323
|
+
realtime.stop() // Only stop a realtime instance owned by this application scope.
|
|
324
|
+
audio.srcObject = null
|
|
325
|
+
audio.remove()
|
|
326
|
+
}
|
|
109
327
|
```
|
|
110
328
|
|
|
111
|
-
###
|
|
329
|
+
### Options
|
|
330
|
+
|
|
331
|
+
Required options are `accid`, `agid`, `access_token`, `realtime`, and `env`.
|
|
332
|
+
For browsers, pass `window` as `env`; custom environments must provide WebRTC
|
|
333
|
+
constructors, `Promise`, `JSON`, `Date`, and timeout/interval functions. The
|
|
334
|
+
default transport also uses global `XMLHttpRequest` or `fetch`; `env` alone is
|
|
335
|
+
not a complete native-runtime adapter.
|
|
112
336
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
337
|
+
| Optional option | Default and behavior |
|
|
338
|
+
| --- | --- |
|
|
339
|
+
| `apiUrl` | `https://callcenter.subiz.com.vn/wc/`; must end in `/`. |
|
|
340
|
+
| `setupTimeoutMs` | `60000`; a positive number overrides the shared setup budget. |
|
|
341
|
+
| `callAPI` | Custom signaling transport using the contract below. |
|
|
342
|
+
| `onEvent(event)` | Receives call lifecycle events. |
|
|
343
|
+
| `onTrack(event)` | Receives remote track events for application-managed playback. |
|
|
344
|
+
| `collect(name, callid, durationMs)` | Optional timing callback. |
|
|
345
|
+
| `apiHostSuffixes` | `['.subiz.com.vn']`; allowed hosts for server-returned HTTPS `api_url` values. A leading dot matches subdomains; other entries match an exact host. |
|
|
346
|
+
|
|
347
|
+
An accepted server `api_url` pins subsequent requests to the call's host. A
|
|
348
|
+
network failure on a pinned host retries once through `apiUrl`; HTTP 4xx/5xx do
|
|
349
|
+
not trigger that fallback. Callback failures are logged and isolated.
|
|
117
350
|
|
|
118
351
|
### Methods
|
|
119
352
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
353
|
+
| Method | Contract |
|
|
354
|
+
| --- | --- |
|
|
355
|
+
| `makeCall(number, fromnumber, streamPm, callid, campaignid, outboundcallentryid, channel)` | Requires a Promise for a live audio stream. Optional call ID is generated if absent; the final three arguments add call metadata. Resolves on call end or setup failure. No number fallback is performed here. |
|
|
356
|
+
| `answerCall(callid, stream)` | Accepts a stream or Promise. Resolves after answer setup to `{body}` or `{error}`. |
|
|
357
|
+
| `listenCall(callid, callback)` | Receive-only setup. Calls `callback(error, connectionId)` once ICE connects or setup fails. |
|
|
358
|
+
| `joinCall(callid, stream, callback)` | Bidirectional setup with an already acquired stream. Same callback contract as `listenCall`. |
|
|
359
|
+
| `hangupCall(callid)` | Ends locally and signals server hangup. Returns `undefined`. |
|
|
360
|
+
| `sendDtmf(key, callid)` / `transferCall(number, callid)` | Same signaling commands exposed by `WebPhone`. |
|
|
361
|
+
| `setCallMuted(callid, muted)` | Enables/disables local audio tracks. Returns `{body: {muted}}` or `{error}`. |
|
|
362
|
+
| `isCallMuted(callid)` | Returns the locally stored mute state. |
|
|
363
|
+
| `matchCall(callid)` | Returns known call state. Omit the ID, or pass `'*'` or `'-'`, for the call map. |
|
|
364
|
+
| `expireCall(callInfo)` | Marks a call ended locally and emits `call_ended`; requires `callInfo.call_id`. Does not signal server hangup. |
|
|
365
|
+
| `debugState()` | Returns a diagnostic snapshot of calls, sessions, and transport state. |
|
|
366
|
+
| `shutdown()` | Cancels pending setup and call waits, closes peers, stops owned media and timers, and ignores late results. Terminal for this instance. |
|
|
367
|
+
|
|
368
|
+
Success callbacks for `listenCall`/`joinCall` receive `undefined` as the first
|
|
369
|
+
argument. Awaiting those methods does not give a `{body}`/`{error}` result; use
|
|
370
|
+
the callback. Streams passed into call setup become owned by the client and
|
|
371
|
+
are stopped on cleanup. New setup for the same call replaces older setup;
|
|
372
|
+
superseded operations can return `replaced`.
|
|
373
|
+
|
|
374
|
+
`shutdown()` does not stop the supplied realtime instance or send server hangups.
|
|
375
|
+
Call `hangupCall()` first when you need to signal termination. The realtime
|
|
376
|
+
listener remains registered but ignores events after shutdown; dispose an owned
|
|
377
|
+
realtime instance alongside the client.
|
|
378
|
+
|
|
379
|
+
## Custom HTTP transport
|
|
380
|
+
|
|
381
|
+
Both `Realtime` and `WebRTCConn` accept:
|
|
382
|
+
|
|
383
|
+
```js
|
|
384
|
+
function callAPI(method, url, body, callback) {
|
|
385
|
+
// Send the request, then invoke callback(responseText, httpStatus).
|
|
386
|
+
// Use 0 or -1 for a network failure.
|
|
387
|
+
// Optionally return {abort() { ... }} or a cancellation function.
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`body` is a serialized string or `undefined`; the callback takes response text
|
|
392
|
+
first and numeric status second. The built-in transport uses `text/plain` for
|
|
393
|
+
nonempty request bodies. Return a cancellation handle to release network work
|
|
394
|
+
on stop, replacement, or deadline expiry. Without one, lifecycle guards still
|
|
395
|
+
ignore late setup/poll responses. The built-in fetch transport uses
|
|
396
|
+
`AbortController` when available.
|
|
397
|
+
|
|
398
|
+
Realtime request deadlines and WebRTC setup deadlines are managed by the client.
|
|
399
|
+
The WebRTC setup budget does not impose a deadline on later DTMF, transfer, or
|
|
400
|
+
hangup requests. `WebPhone` does not expose a custom signaling transport option.
|
|
401
|
+
|
|
402
|
+
## Development
|
|
403
|
+
|
|
404
|
+
```sh
|
|
405
|
+
npm install
|
|
406
|
+
npm test
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The test command runs realtime lifecycle, HTTP transport, and WebRTC lifecycle
|
|
410
|
+
regressions with Node's test runner, followed by the existing Tape realtime
|
|
411
|
+
suite. WebRTC tests use simulated peers, media streams, transports, and clocks;
|
|
412
|
+
real-browser microphone, playback, and network interoperability require separate
|
|
413
|
+
integration testing.
|
package/common.js
CHANGED
|
@@ -1,67 +1,50 @@
|
|
|
1
|
-
function logError(accid, userid, message, line) {
|
|
2
|
-
// 24-01-11 04:07:05 app ERROR api-8948967b6-qtpfx subiz /app/vendor/github.com/subiz/log/error.go:271 {"id":141534190473829746,"class":500,"code":"locked_account,internal","number":"8f2b97e9","fields":{"ip":"\"171.224.84.93\"","no_report":"true","user_agent":"\"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36\""}}
|
|
3
|
-
let ts = displayUTCLogTime(new Date())
|
|
4
|
-
let msg = JSON.stringify({
|
|
5
|
-
id: Date.now(),
|
|
6
|
-
class: 400,
|
|
7
|
-
code: 'frontend_error',
|
|
8
|
-
number: line
|
|
9
|
-
.split('')
|
|
10
|
-
.map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
|
|
11
|
-
.join(''),
|
|
12
|
-
fields: {
|
|
13
|
-
message: JSON.stringify(message),
|
|
14
|
-
user_id: userid,
|
|
15
|
-
url: JSON.stringify(location.href),
|
|
16
|
-
},
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
xhrsend('POST', 'https://track.subiz.net/collect', `${ts} app ERROR dashboard ${accid} ${line} ${msg}`)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function displayUTCLogTime(date) {
|
|
23
|
-
let yyyy = date.getUTCFullYear().toString()
|
|
24
|
-
let yy = yyyy.substr(2, 2)
|
|
25
|
-
let mm = date.getUTCMonth() + 1
|
|
26
|
-
let dd = date.getUTCDate()
|
|
27
|
-
let hh = date.getUTCHours()
|
|
28
|
-
let MM = date.getUTCMinutes()
|
|
29
|
-
let ss = date.getUTCSeconds()
|
|
30
|
-
|
|
31
|
-
return `${yy}-${pad(mm)}-${pad(dd)} ${pad(hh)}:${pad(MM)}:${pad(ss)}`
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function pad(v) {
|
|
35
|
-
if (v < 10) return `0${v}`
|
|
36
|
-
return `${v}`
|
|
37
|
-
}
|
|
38
|
-
|
|
39
1
|
function dofetch(method, url, body, cb) {
|
|
40
2
|
let headers = {}
|
|
3
|
+
let controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined
|
|
41
4
|
if (body) headers['content-type'] = 'text/plain'
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
5
|
+
function complete(body, code) {
|
|
6
|
+
let callback = cb
|
|
7
|
+
cb = undefined
|
|
8
|
+
if (callback) callback(body, code)
|
|
9
|
+
}
|
|
10
|
+
fetch(url, {method: method, headers: headers, body: body, signal: controller && controller.signal})
|
|
11
|
+
.then((res) => res.text().then((text) => ({text, status: res.status})))
|
|
12
|
+
.then(
|
|
13
|
+
(res) => complete(res.text, res.status),
|
|
14
|
+
(err) => complete(err, -1),
|
|
15
|
+
)
|
|
16
|
+
return {
|
|
17
|
+
abort() {
|
|
18
|
+
cb = undefined
|
|
19
|
+
if (controller) controller.abort()
|
|
20
|
+
},
|
|
21
|
+
}
|
|
45
22
|
}
|
|
46
23
|
|
|
47
|
-
// xhrsend
|
|
24
|
+
// xhrsend returns an abort handle. The owner controls the request deadline.
|
|
48
25
|
var xhrsend = function (method, url, body, cb) {
|
|
49
26
|
if (typeof XMLHttpRequest === 'undefined') return dofetch(method, url, body, cb)
|
|
50
27
|
var request = new XMLHttpRequest()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
cb
|
|
54
|
-
|
|
28
|
+
function complete(body, code) {
|
|
29
|
+
var callback = cb
|
|
30
|
+
cb = undefined
|
|
31
|
+
if (callback) callback(body, code)
|
|
32
|
+
}
|
|
33
|
+
request.onreadystatechange = function () {
|
|
34
|
+
if (request.readyState === 4) complete(request.responseText, request.status)
|
|
55
35
|
}
|
|
56
|
-
|
|
57
36
|
request.onerror = function () {
|
|
58
|
-
|
|
59
|
-
cb = undefined // dont call cb anymore
|
|
37
|
+
complete(request.responseText, -1)
|
|
60
38
|
}
|
|
61
|
-
|
|
62
39
|
request.open(method, url)
|
|
63
40
|
if (body) request.setRequestHeader('content-type', 'text/plain')
|
|
64
41
|
request.send(body)
|
|
42
|
+
return {
|
|
43
|
+
abort() {
|
|
44
|
+
cb = undefined
|
|
45
|
+
request.abort()
|
|
46
|
+
},
|
|
47
|
+
}
|
|
65
48
|
}
|
|
66
49
|
|
|
67
50
|
// JSON.parse without exception
|
|
@@ -72,7 +55,7 @@ function parseJSON(str) {
|
|
|
72
55
|
} catch (e) {}
|
|
73
56
|
}
|
|
74
57
|
|
|
75
|
-
function Pubsub() {
|
|
58
|
+
function Pubsub(onError) {
|
|
76
59
|
// holds all subscriptions for all topics
|
|
77
60
|
// example {
|
|
78
61
|
// topic1: [cb1, cb2],
|
|
@@ -89,7 +72,18 @@ function Pubsub() {
|
|
|
89
72
|
|
|
90
73
|
var cbs = listeners[topic]
|
|
91
74
|
if (!cbs) return
|
|
92
|
-
for (var i = 0; i < cbs.length; i++)
|
|
75
|
+
for (var i = 0; i < cbs.length; i++) {
|
|
76
|
+
if (!onError) {
|
|
77
|
+
cbs[i].apply(undefined, args)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
var result = cbs[i].apply(undefined, args)
|
|
82
|
+
if (result && typeof result.then === 'function') Promise.resolve(result).catch(onError)
|
|
83
|
+
} catch (err) {
|
|
84
|
+
onError(err)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
93
87
|
}
|
|
94
88
|
|
|
95
89
|
// register a callback function for a topic
|
|
@@ -116,4 +110,11 @@ function randomString(len, base) {
|
|
|
116
110
|
return str
|
|
117
111
|
}
|
|
118
112
|
|
|
119
|
-
|
|
113
|
+
// Reporting consumer failures must itself be safe in browser and native hosts.
|
|
114
|
+
function reportCallbackError(err) {
|
|
115
|
+
try {
|
|
116
|
+
if (typeof console !== 'undefined' && console.error) console.error('Callback failed:', err)
|
|
117
|
+
} catch (ignored) {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {reportCallbackError, xhrsend, parseJSON, Pubsub, randomString}
|