@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 CHANGED
@@ -1,142 +1,413 @@
1
1
  # Subiz wsclient
2
2
 
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.
3
+ Subiz realtime subscriptions over HTTP long polling, with browser audio calling
4
+ through WebRTC.
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
+ | 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
- ## Install
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
- var Realtime = require('@subiz/wsclient')
32
+ const Realtime = require('@subiz/wsclient')
20
33
 
21
- var realtime = new Realtime('https://api.subiz.com.vn/rt/0/', {
34
+ const realtime = new Realtime('https://api.subiz.com.vn/rt/0/', {
22
35
  account_id: 'ac1234',
23
- getAccessToken: () => Promise.resolve('333344'),
36
+ getAccessToken: async () => 'YOUR_ACCESS_TOKEN',
24
37
  })
25
38
 
26
- realtime.onEvent((ev) => console.log(ev))
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
- // subscribe events
29
- await realtime.subscribe(['message_sent', 'user_info_updated'])
30
- console.log(realtime.getStatus()) // 'active'
51
+ startRealtime().catch(console.error)
31
52
 
32
- realtime.stop()
53
+ // When the application no longer needs events:
54
+ // realtime.stop()
33
55
  ```
34
56
 
35
- ### new Realtime(apiUrls, credential, [callAPI], [accid], [skipautoreconnect])
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
- - `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.
61
+ ### Constructor
49
62
 
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.
63
+ ```js
64
+ new Realtime(apiUrls, credential, callAPI, accid, skipautoreconnect)
65
+ ```
53
66
 
54
- ### subscribe(topics)
67
+ Only `apiUrls` and the credentials needed by your server are required.
55
68
 
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).
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
- Returns a Promise resolving to `{}` on success or `{error}` on failure.
79
+ ### Methods
61
80
 
62
- ### onEvent(callback)
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
- Registers a callback that is called with each incoming event.
140
+ ## WebPhone
65
141
 
66
- ### onInterrupted(callback)
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
- Registers a callback that is called whenever the connection dies (before an
69
- automatic reconnect) or is stopped.
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
- ### getStatus()
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
- Returns the current connection status: `'active'`, `'connecting'` (having
74
- trouble reaching the server, retrying) or `'dead'`.
185
+ ### Constructor
75
186
 
76
- ### stop()
187
+ ```js
188
+ new WebPhone(access_token, realtime)
189
+ ```
77
190
 
78
- Kills the connection and disables auto-reconnect. Call `reconnect()` to start
79
- a new connection again.
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
- ### reconnect()
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
- Drops the current connection (if any), creates a new one and resubscribes all
84
- topics. Returns the resubscribe Promise.
202
+ ### Methods
85
203
 
86
- ## WebPhone
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
- var WebPhone = require('@subiz/wsclient/webphone.js')
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
- var phone = new WebPhone('ac1234_ag5678_...') // agent access token
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
- phone.onEvent((ev) => console.log(ev.type, ev.data.call_info))
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
- // 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')
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
- // or force specific from-numbers
101
- await phone.makeCall('84900000000', ['84911111111', '84922222222'])
279
+ ## WebRTCConn (advanced)
102
280
 
103
- phone.sendDtmf('1', callid)
104
- phone.transferCall('84933333333', callid)
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
- // inbound: answer a ringing call
108
- await phone.answerWebCall(callid)
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
- ### new WebPhone(access_token, [realtime])
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
- `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.
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
- - `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.
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
- fetch(url, {method: method, headers: headers, body: body})
43
- .then((res) => res.text().then((text) => cb && cb(text, res.status)))
44
- .catch((err) => cb && cb(err, -1))
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 sends an HTTP request
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
- request.onreadystatechange = function (e) {
52
- if (request.readyState !== 4) return
53
- cb && cb(request.responseText, request.status)
54
- cb = undefined // dont call cb anymore
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
- cb && cb(request.responseText, -1) // network error
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++) cbs[i].apply(undefined, args)
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
- module.exports = {xhrsend, parseJSON, Pubsub, randomString, logError}
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}