@robono/calling 0.0.0-stage → 0.1.3
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/CHANGELOG.md +30 -0
- package/LICENSE +130 -0
- package/README.md +143 -2
- package/SBOM.spdx.json +44 -0
- package/dist/events.js +38 -0
- package/dist/history.d.ts +4 -0
- package/dist/history.js +27 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +403 -0
- package/package.json +48 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# 0.1.3
|
|
2
|
+
|
|
3
|
+
Authenticated event listening, no idle state polling, foreground reconciliation and silent idle-session expiry. Bounded event reconnects preserve existing call controls and optional UI.
|
|
4
|
+
|
|
5
|
+
# Changelog
|
|
6
|
+
|
|
7
|
+
## 0.1.2
|
|
8
|
+
|
|
9
|
+
- Add an optional availability reason while preserving voiceAvailable and existing call-control behavior.
|
|
10
|
+
|
|
11
|
+
## 0.1.1
|
|
12
|
+
|
|
13
|
+
- Stop unanswered ringing locally at the server deadline even if polling stalls.
|
|
14
|
+
- Add ordered, repeatable hold/resume, silenced tracks, a bounded held lease and fresh authorization before resuming.
|
|
15
|
+
- Dismiss locally failed media instead of leaving connected controls visible.
|
|
16
|
+
|
|
17
|
+
- Back off state polling after rate limits and temporary failures, honoring Retry-After.
|
|
18
|
+
- Preserve HTTP failure status when a proxy returns a non-JSON response.
|
|
19
|
+
- Share concurrent state refreshes so background scheduling and foreground polling cannot apply the same signaling state twice.
|
|
20
|
+
- Keep the calling engine independent of the optional mobile screen package.
|
|
21
|
+
|
|
22
|
+
## 0.1.0
|
|
23
|
+
|
|
24
|
+
- One-to-one WebRTC audio with direct connections and relay fallback.
|
|
25
|
+
- Stable start request IDs, cancellation during microphone permission or signaling, bounded transport deadlines and media cleanup.
|
|
26
|
+
- Per-contact availability, answer, mute, hangup, connection state and call history.
|
|
27
|
+
- Browser defaults and injectable media adapters for native hosts.
|
|
28
|
+
- TypeScript declarations and history merge/display helpers.
|
|
29
|
+
|
|
30
|
+
This headless package connects your app's own interface and native integrations. It does not install mobile screens, push credentials or native notification services. Calling access is configured separately for each network.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
ROBONO SDK LICENSE AGREEMENT
|
|
2
|
+
Version 1.0, July 20, 2026
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Add to Loop LLC. All rights reserved.
|
|
5
|
+
|
|
6
|
+
This Robono SDK License Agreement (the "Agreement") is between Add to Loop LLC,
|
|
7
|
+
operating the Robono service ("Robono"), and the person or entity that downloads,
|
|
8
|
+
installs, copies, or uses the Software ("You"). By downloading, installing,
|
|
9
|
+
copying, or using the Software, You accept this Agreement. If You do not accept
|
|
10
|
+
it, do not use the Software.
|
|
11
|
+
|
|
12
|
+
1. Definitions
|
|
13
|
+
|
|
14
|
+
"Software" means this Robono software development kit, its documentation, and
|
|
15
|
+
any updates Robono provides under this Agreement.
|
|
16
|
+
|
|
17
|
+
"Robono Service" means the messaging interoperability, routing, policy,
|
|
18
|
+
conversion, delivery, and related services operated or expressly authorized by
|
|
19
|
+
Robono.
|
|
20
|
+
|
|
21
|
+
"Application" means a product or service owned or controlled by You that is
|
|
22
|
+
authorized to connect to the Robono Service.
|
|
23
|
+
|
|
24
|
+
2. Limited License
|
|
25
|
+
|
|
26
|
+
Subject to this Agreement, Robono grants You a limited, non-exclusive,
|
|
27
|
+
non-transferable, non-sublicensable, revocable license to:
|
|
28
|
+
|
|
29
|
+
(a) install, copy, and use the Software internally to develop, test, and
|
|
30
|
+
operate an Application that communicates with the Robono Service; and
|
|
31
|
+
|
|
32
|
+
(b) reproduce and distribute the portions of the Software that are necessarily
|
|
33
|
+
included in Your Application, solely in compiled, minified, bundled, or object
|
|
34
|
+
code form and solely as needed for that Application to communicate with the
|
|
35
|
+
Robono Service.
|
|
36
|
+
|
|
37
|
+
No right is granted to distribute the Software as a standalone product or SDK.
|
|
38
|
+
|
|
39
|
+
3. Restrictions
|
|
40
|
+
|
|
41
|
+
You may not, and may not permit another person to:
|
|
42
|
+
|
|
43
|
+
(a) sell, rent, lease, sublicense, publish, or redistribute the Software as a
|
|
44
|
+
standalone product, library, SDK, or service;
|
|
45
|
+
|
|
46
|
+
(b) use the Software or a modified or derivative version of it to develop,
|
|
47
|
+
provide, or support a product or service that competes with the Robono Service;
|
|
48
|
+
|
|
49
|
+
(c) use the Software to bypass Robono, avoid applicable usage controls or fees,
|
|
50
|
+
or access the Robono Service without authorization;
|
|
51
|
+
|
|
52
|
+
(d) remove or alter copyright, trademark, attribution, or proprietary notices;
|
|
53
|
+
|
|
54
|
+
(e) reverse engineer, decompile, or disassemble the Software except to the
|
|
55
|
+
limited extent applicable law expressly prohibits this restriction; or
|
|
56
|
+
|
|
57
|
+
(f) use the Software in violation of applicable law, the rights of another
|
|
58
|
+
person, or the terms governing access to the Robono Service.
|
|
59
|
+
|
|
60
|
+
4. Ownership and Reserved Rights
|
|
61
|
+
|
|
62
|
+
The Software is licensed, not sold. Robono and its licensors retain all right,
|
|
63
|
+
title, and interest in the Software and all related intellectual property.
|
|
64
|
+
Except for the limited rights expressly granted in Section 2, all rights are
|
|
65
|
+
reserved.
|
|
66
|
+
|
|
67
|
+
No patent license is granted except the minimum license, if any, necessarily
|
|
68
|
+
required to exercise the rights expressly granted in Section 2, and that
|
|
69
|
+
limited patent license applies only while the Software is used with an
|
|
70
|
+
authorized Robono Service.
|
|
71
|
+
|
|
72
|
+
5. Service Access
|
|
73
|
+
|
|
74
|
+
This Agreement does not itself grant an account, API credentials, service
|
|
75
|
+
availability, support, or a right to access the Robono Service. Service access
|
|
76
|
+
may be governed by additional terms, plans, limits, and policies. Robono may
|
|
77
|
+
suspend or terminate access to protect users, systems, legal compliance, or the
|
|
78
|
+
integrity of the Robono Service.
|
|
79
|
+
|
|
80
|
+
6. Updates and Support
|
|
81
|
+
|
|
82
|
+
Robono is not required to provide maintenance, updates, support, or continued
|
|
83
|
+
availability of any version of the Software unless separately agreed in
|
|
84
|
+
writing.
|
|
85
|
+
|
|
86
|
+
7. Term and Termination
|
|
87
|
+
|
|
88
|
+
This Agreement continues until terminated. Your rights terminate automatically
|
|
89
|
+
if You breach this Agreement. On termination, You must stop using the Software
|
|
90
|
+
and delete copies under Your control, except for copies necessarily contained
|
|
91
|
+
in previously distributed Applications. Sections 3, 4, 8, 9, 10, and 11
|
|
92
|
+
survive termination.
|
|
93
|
+
|
|
94
|
+
8. Disclaimer of Warranties
|
|
95
|
+
|
|
96
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE SOFTWARE IS PROVIDED "AS IS" AND
|
|
97
|
+
"AS AVAILABLE," WITHOUT WARRANTIES OF ANY KIND, EXPRESS, IMPLIED, OR
|
|
98
|
+
STATUTORY, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
|
99
|
+
PURPOSE, TITLE, AND NON-INFRINGEMENT.
|
|
100
|
+
|
|
101
|
+
9. Limitation of Liability
|
|
102
|
+
|
|
103
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, ROBONO WILL NOT BE LIABLE FOR INDIRECT,
|
|
104
|
+
INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR LOSS
|
|
105
|
+
OF DATA, REVENUE, PROFITS, GOODWILL, OR BUSINESS OPPORTUNITY, ARISING FROM OR
|
|
106
|
+
RELATING TO THE SOFTWARE. ROBONO'S TOTAL LIABILITY RELATING TO THE SOFTWARE
|
|
107
|
+
WILL NOT EXCEED THE GREATER OF ONE HUNDRED U.S. DOLLARS (US $100) OR THE AMOUNT
|
|
108
|
+
YOU PAID ROBONO FOR THE SOFTWARE DURING THE TWELVE MONTHS BEFORE THE EVENT
|
|
109
|
+
GIVING RISE TO THE CLAIM. THESE LIMITATIONS APPLY TO THE MAXIMUM EXTENT
|
|
110
|
+
PERMITTED BY LAW.
|
|
111
|
+
|
|
112
|
+
10. Governing Law
|
|
113
|
+
|
|
114
|
+
This Agreement is governed by the laws of the State of Florida, excluding its
|
|
115
|
+
conflict-of-laws rules. The state and federal courts located in Palm Beach
|
|
116
|
+
County, Florida have exclusive jurisdiction, and each party consents to their
|
|
117
|
+
jurisdiction and venue.
|
|
118
|
+
|
|
119
|
+
11. General
|
|
120
|
+
|
|
121
|
+
You may not assign this Agreement without Robono's prior written consent.
|
|
122
|
+
Robono may assign this Agreement in connection with a reorganization, transfer
|
|
123
|
+
of intellectual property, financing, merger, acquisition, or sale of all or
|
|
124
|
+
substantially all of the relevant assets. If a provision is unenforceable, it
|
|
125
|
+
will be enforced to the maximum extent permitted and the remaining provisions
|
|
126
|
+
will remain effective. A waiver must be in writing. This Agreement is the
|
|
127
|
+
entire agreement concerning the Software unless You and Robono sign a separate
|
|
128
|
+
written agreement that expressly replaces it.
|
|
129
|
+
|
|
130
|
+
Questions about this license may be submitted at https://robono.com/contact.
|
package/README.md
CHANGED
|
@@ -1,3 +1,144 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Robono Calling SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Add one-to-one voice calls within your app or between people connected across Robono Bridge networks. The SDK manages WebRTC negotiation, direct connections, relay fallback, call state and microphone cleanup. Your app keeps its identity and platform notification integration. Use your own interface, or choose the separate optional mobile screen package. The calling engine never requires a Robono interface.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @robono/calling@0.1.2
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This is a versioned npm-compatible package. Commit your package lockfile to retain its integrity checksum. The release includes `SHA256SUMS` and the package includes an SPDX software bill of materials.
|
|
12
|
+
|
|
13
|
+
- [Calling integration guide](https://robono.com/docs#calling)
|
|
14
|
+
- [HTTP API](https://robono.com/api-reference?spec=calling)
|
|
15
|
+
- [Developer calling workspace](https://robono.com/calling)
|
|
16
|
+
|
|
17
|
+
## Connect your backend
|
|
18
|
+
|
|
19
|
+
Open Calling in your Robono developer workspace, enable calling for the integration, and copy its admission and service URLs. Your server authenticates the user, then posts the following to that URL using a network API key with `calling:connect` scope:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{"operation":"admit","kind":"network","protocolVersion":1,"externalUserId":"YOUR_AUTHENTICATED_USER_ID"}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Use the exact external user ID from the accepted Bridge relationship. Validate the returned `serviceUrl` against your configured calling service, then exchange the returned `ticket` within 60 seconds at `serviceUrl + '/sessions'` using `{ticket, connectionId}`. A connection ID is `bridge:CONNECTION_ID` between networks or `app:CONVERSATION_ID` for a Robono Messenger endpoint. Omit `connectionId` to listen across authorized contacts.
|
|
26
|
+
|
|
27
|
+
Return the service URL, resulting session token and expiry to that user's client over HTTPS. Keep network API keys on your server and tokens out of logs and URLs. Renew expired sessions through your authenticated backend; never silently reopen a microphone.
|
|
28
|
+
|
|
29
|
+
Both networks must allow voice on the accepted relationship:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{"calling":{"voice":true,"video":false,"protocol_version":1}}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Keep the existing messaging capabilities and protocol version. Check live availability before enabling a call action.
|
|
36
|
+
|
|
37
|
+
## Browser integration
|
|
38
|
+
|
|
39
|
+
Create one client per signed-in device and keep it alive across screen navigation. Here `session` comes from your backend, `audio` is an HTML audio element, and `renderCall` updates your app's UI:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { RobonoCallingClient, createHttpTransport } from '@robono/calling';
|
|
43
|
+
|
|
44
|
+
const client = new RobonoCallingClient({
|
|
45
|
+
request: createHttpTransport({ baseUrl: session.serviceUrl, token: session.token }),
|
|
46
|
+
onChange: renderCall,
|
|
47
|
+
onRemoteStream(stream) {
|
|
48
|
+
audio.srcObject = stream;
|
|
49
|
+
if (stream) audio.play().catch(() => showEnableAudioButton());
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
await client.listen();
|
|
53
|
+
const { voiceAvailable } = await client.availability(connectionId);
|
|
54
|
+
|
|
55
|
+
// Bind these to user actions, with error handling in your UI:
|
|
56
|
+
// await client.call(connectionId);
|
|
57
|
+
// await client.answer();
|
|
58
|
+
// client.mute(true); client.mute(false);
|
|
59
|
+
// await client.hangUp();
|
|
60
|
+
// await client.dispose(); // logout or destruction of this device session
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Use HTTPS and request microphone permission from the Call or Answer action. Browsers may require a user gesture to play remote audio. Do not call `answer()` just because a notification arrived. `forceRelay: true` is available for your relay acceptance test; normal calls prefer a direct route.
|
|
64
|
+
|
|
65
|
+
Use the `onChange` snapshot to render ringing, connecting and connected states. Start the timer at `call.connectedAt`, not when Answer is pressed. A preparation state can precede a server call ID; retain the same screen and allow cancellation. `hangUp()` handles cancellation, decline and ending an active call.
|
|
66
|
+
|
|
67
|
+
## Optional mobile interface
|
|
68
|
+
|
|
69
|
+
`@robono/calling-react-native` is a separate package for networks that want supplied React Native screens, sounds, controls and audio adapters. It is not a dependency of this engine. Existing WebRTC clients and networks with their own calling UI can continue using the engine or HTTP API without installing it.
|
|
70
|
+
|
|
71
|
+
## Native hosts
|
|
72
|
+
|
|
73
|
+
Supply `getUserMedia`, `createPeerConnection` and, if needed, `createRemoteStream` adapters through the constructor. The microphone adapter receives an optional `{isCurrent}` callback: check it before capture after asynchronous permission or audio-session work. A late returned stream is stopped if the call has already been cancelled.
|
|
74
|
+
|
|
75
|
+
The headless SDK does not install iOS or Android native code. Your host manages microphone permission, audio sessions/focus, speaker/Bluetooth routing, interruptions, background execution, native incoming UI and push delivery. Select the speaker route before connection if requested and preserve it when media starts. Release native audio resources when the call ends. Use the same protocol on both platforms.
|
|
76
|
+
|
|
77
|
+
## Incoming notifications
|
|
78
|
+
|
|
79
|
+
Enable network notifications on your admission endpoint with `{"operation":"notifications","enabled":true}`. Robono sends signed `calling.incoming` and `calling.dismissed` events to your network webhook. Your backend sends push through your app's own provider; Robono does not receive your app's push keys through this SDK.
|
|
80
|
+
|
|
81
|
+
Use the matching backend release:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
npm install https://github.com/IMLX16/robono-sdks/releases/download/calling-v0.1.1/robono-server-0.11.0.tgz
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Verify the raw webhook body with `verifyRobonoWebhook`, then use `toCallingPushPayload` from `@robono/server`. Route only to devices owned by `recipient.external_user_id` in `recipient.child_app_id`. Durably queue verified events before acknowledging them. Deduplicate by `event_id`; retain the highest per-call sequence for at least 24 hours. A dismissal (sequence 2) overrides an invitation (sequence 1), even if events arrive out of order. Never ring past `ring_expires_at`. Dismissal clears ringing; it does not end an answered call.
|
|
88
|
+
|
|
89
|
+
When opened, a notification should authenticate the current user and refresh current call state. Notification content is routing information, not authorization. Platform settings control notification presentation and delivery.
|
|
90
|
+
|
|
91
|
+
## History
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import { mergeCallHistory, describeCall } from '@robono/calling/history';
|
|
95
|
+
const page = await client.history({ after: saved.cursor, limit: 100 });
|
|
96
|
+
const updated = mergeCallHistory(saved, page);
|
|
97
|
+
await saveHistory(updated); // Commit entries and cursor together.
|
|
98
|
+
const label = describeCall(updated.entries.at(-1));
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Initialize `saved` as `{cursor: 0, entries: []}`. Continue while `hasMore` is true. Keep separate history cursors per account and query scope. Store entries by call ID and show them on the caller's side of the conversation. `durationMs` excludes ringing and setup. Refresh history on resume and hangup, and recheck availability before callback.
|
|
102
|
+
|
|
103
|
+
## Failure handling
|
|
104
|
+
|
|
105
|
+
- 401: stop media and obtain a new session through your backend.
|
|
106
|
+
- 403: refresh account and relationship permissions.
|
|
107
|
+
- 409: refresh call state; another device may control the call or a participant may be busy.
|
|
108
|
+
- 429: back off before retrying.
|
|
109
|
+
- 503/network failure: use bounded retries and show a recoverable error.
|
|
110
|
+
|
|
111
|
+
The client retries ambiguous Start responses with the same request ID, stops late microphone capture after cancellation, and releases media on signaling loss. Your UI should handle rejected actions and avoid automatic redial.
|
|
112
|
+
|
|
113
|
+
## Verify your integration
|
|
114
|
+
|
|
115
|
+
Use two sandbox networks and two devices. Check calls both ways, audible two-way speech, mute, speaker, Bluetooth, decline, caller cancellation, no answer, history, Wi-Fi/cellular and relay fallback. Repeat native foreground, background and locked-screen tests on iPhone and Android. Confirm messaging continues working during rollout.
|
|
116
|
+
|
|
117
|
+
The package uses ESM and includes TypeScript declarations. Node.js 22+ runs its development tests; browser/native hosts provide WebRTC.
|
|
118
|
+
|
|
119
|
+
## License and support
|
|
120
|
+
|
|
121
|
+
Use is governed by the included Robono SDK License Agreement. Contact [Robono support](https://robono.com/contact?topic=sdk).
|
|
122
|
+
|
|
123
|
+
## Ringing and interruptions
|
|
124
|
+
|
|
125
|
+
Unanswered calls expire 30 seconds after creation. Stop ringing at `ringRemainingMs` (a duration from the response), or `ringExpiresAt` for older responses. A late Answer is rejected. Do not send End to represent a timeout: that action means user cancellation or decline. The service records `no_answer` independently, and the client also stops a stale ringing screen when polling stalls.
|
|
126
|
+
|
|
127
|
+
For an established call, `client.hold()` silences local and remote tracks immediately and acknowledges the interruption to the service. A held endpoint can stop polling for up to five minutes; retries do not extend that deadline. The other endpoint keeps its normal signaling lease and may hang up. Show Call on hold when `localHeld` or `remoteHeld` is true. Keep the user's manual mute preference separate from hold.
|
|
128
|
+
|
|
129
|
+
Call `client.resume(prepareAudio)` only when the OS permits audio to return, or when the person explicitly requests resume. The client first refreshes authorization and call state, then awaits your `prepareAudio` callback, acknowledges resume and restores tracks. The callback must reject if another call still owns audio. A stale response, remote hangup, expired session or ended call cannot restart capture. The optional mobile package supplies this wiring and a Resume call control. No voicemail is involved.
|
|
130
|
+
|
|
131
|
+
## Event-based listening
|
|
132
|
+
|
|
133
|
+
Use `@robono/calling@0.1.3` with `@robono/calling-react-native@0.2.2` when using the optional mobile interface. Existing custom interfaces remain supported. See https://robono.com/docs#calling-events for the lifecycle and recovery contract.
|
|
134
|
+
|
|
135
|
+
- Foreground, idle: an authenticated WebSocket listens for incoming calls. No periodic state polling. A ping/pong every 20 seconds maintains presence.
|
|
136
|
+
- Active calls: events trigger state reconciliation immediately; a state health check runs every 3 seconds. The native background fallback also checks every 3 seconds; overlapping checks are coalesced.
|
|
137
|
+
- Idle in background: call `setForeground(false)`. The event socket and state checks stop. Your backend delivers Robono's signed incoming-call events through your app's APNs/FCM notifications. A WebSocket does not replace push delivery.
|
|
138
|
+
- Return to foreground: call `setForeground(true)`. The socket reconnects and current state is checked. Do not create a new call to recover a listening session. The optional controller forwards this lifecycle method to the engine.
|
|
139
|
+
- Event reconnection: delays start at 1 second, double to 30 seconds (plus up to 20% jitter), honor Retry-After, and stop after eight retries. Foreground re-entry can restart recovery. State recovery stops after six consecutive failed idle checks.
|
|
140
|
+
- Expired idle sessions: `onSessionExpired` requests new admission without presenting a failed-call screen. Dispose the expired client and create a new one with a new session from your backend. Use bounded backoff for admission failures. Authorization revocation is not a retryable session expiration. An active call cannot be transferred to a new session.
|
|
141
|
+
|
|
142
|
+
`createHttpTransport` supplies the event subscription automatically. If wrapping it, preserve `request.subscribe = transport.subscribe`. A custom request without `subscribe`, or `{events:false}`, retains the compatibility polling interval (`pollMs`, default 1000 ms).
|
|
143
|
+
|
|
144
|
+
The 0.1.2 engine had polling and retry backoff but did not include this event transport. No native permissions or rebuild are added by this JavaScript patch to an app already using the native calling package.
|
package/SBOM.spdx.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"spdxVersion": "SPDX-2.3",
|
|
3
|
+
"dataLicense": "CC0-1.0",
|
|
4
|
+
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
+
"name": "@robono/calling@0.1.3",
|
|
6
|
+
"documentNamespace": "http://spdx.org/spdxdocs/@robono%2fcalling-0.1.3-***",
|
|
7
|
+
"creationInfo": {
|
|
8
|
+
"created": "2026-10-25T10:32:11.266Z",
|
|
9
|
+
"creators": [
|
|
10
|
+
"Tool: npm/cli-11.6.2"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"documentDescribes": [
|
|
14
|
+
"SPDXRef-Package-robono.calling-0.1.3"
|
|
15
|
+
],
|
|
16
|
+
"packages": [
|
|
17
|
+
{
|
|
18
|
+
"name": "@robono/calling",
|
|
19
|
+
"SPDXID": "SPDXRef-Package-robono.calling-0.1.3",
|
|
20
|
+
"versionInfo": "0.1.3",
|
|
21
|
+
"packageFileName": "",
|
|
22
|
+
"description": "One-to-one WebRTC voice calling for connected Robono Bridge networks.",
|
|
23
|
+
"primaryPackagePurpose": "LIBRARY",
|
|
24
|
+
"downloadLocation": "NOASSERTION",
|
|
25
|
+
"filesAnalyzed": false,
|
|
26
|
+
"homepage": "https://robono.com/docs#calling",
|
|
27
|
+
"licenseDeclared": "SEE LICENSE IN LICENSE",
|
|
28
|
+
"externalRefs": [
|
|
29
|
+
{
|
|
30
|
+
"referenceCategory": "PACKAGE-MANAGER",
|
|
31
|
+
"referenceType": "purl",
|
|
32
|
+
"referenceLocator": "pkg:npm/%40robono/calling@0.1.3"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"relationships": [
|
|
38
|
+
{
|
|
39
|
+
"spdxElementId": "SPDXRef-DOCUMENT",
|
|
40
|
+
"relatedSpdxElement": "SPDXRef-Package-robono.calling-0.1.3",
|
|
41
|
+
"relationshipType": "DESCRIBES"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Authenticated event hints with bounded reconnects. No idle state polling. */
|
|
2
|
+
export function subscribeCallingEvents({baseUrl,token,WebSocketImpl}, {onEvent,onStatus=()=>{},onError=()=>{}}) {
|
|
3
|
+
let socket,timer,deadline,stable,stale,closed=false,paused=false,failures=0,retryAfter=0,terminal=false;
|
|
4
|
+
const url=baseUrl.replace(/^http/, 'ws').replace(/\/$/,'')+'/events';
|
|
5
|
+
function clear(){clearTimeout(timer);clearTimeout(deadline);clearTimeout(stable);clearTimeout(stale);}
|
|
6
|
+
function disconnect(){const previous=socket;socket=null;if(previous){previous.onclose=null;previous.onmessage=null;previous.onerror=()=>{};previous.close();}}
|
|
7
|
+
function retry(){
|
|
8
|
+
clear();if(closed||paused||terminal)return;
|
|
9
|
+
if(failures>=8){onStatus('disconnected');return;}
|
|
10
|
+
const delay=Math.max(retryAfter,Math.min(30000,1000*2**failures++)*(1+Math.random()*.2));retryAfter=0;
|
|
11
|
+
onStatus('reconnecting');timer=setTimeout(connect,delay);
|
|
12
|
+
}
|
|
13
|
+
function touch(){clearTimeout(stale);stale=setTimeout(()=>{disconnect();retry();},50000);}
|
|
14
|
+
function connect(){
|
|
15
|
+
if(closed||paused||terminal)return;
|
|
16
|
+
try{socket=new WebSocketImpl(url);}catch{retry();return;}
|
|
17
|
+
const ws=socket;onStatus('connecting');
|
|
18
|
+
deadline=setTimeout(()=>{disconnect();retry();},10000);
|
|
19
|
+
ws.onopen=()=>{if(ws===socket)ws.send(JSON.stringify({type:'authenticate',token}));};
|
|
20
|
+
ws.onmessage=event=>{
|
|
21
|
+
if(ws!==socket)return;
|
|
22
|
+
let data;try{data=JSON.parse(event.data);}catch{return;}
|
|
23
|
+
if(data.type==='ready'){clearTimeout(deadline);touch();stable=setTimeout(()=>{failures=0;},60000);onStatus('listening');onEvent();}
|
|
24
|
+
else if(data.type==='changed')onEvent();
|
|
25
|
+
else if(data.type==='ping'){touch();ws.send(JSON.stringify({type:'pong'}));}
|
|
26
|
+
else if(data.type==='error'){
|
|
27
|
+
retryAfter=Math.max(0,Number(data.retryAfterMs)||0);
|
|
28
|
+
if(data.status===401||data.status===403){terminal=true;clear();disconnect();onStatus(data.status===403?'access_revoked':'session_expired');onError(Object.assign(Error(data.error),{status:data.status}));}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
ws.onerror=()=>{};ws.onclose=()=>{if(ws!==socket)return;socket=null;retry();};
|
|
32
|
+
}
|
|
33
|
+
connect();
|
|
34
|
+
return {
|
|
35
|
+
setEnabled(enabled){if(closed||terminal)return;if(enabled&&!paused&&socket)return;paused=!enabled;clear();disconnect();if(enabled){failures=0;connect();}else onStatus('paused');},
|
|
36
|
+
close(){closed=true;clear();disconnect();},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CallHistoryEntry, CallHistoryPage } from './index.js';
|
|
2
|
+
export interface SavedCallHistory { cursor: number; entries: CallHistoryEntry[] }
|
|
3
|
+
export function mergeCallHistory(saved: SavedCallHistory, page: CallHistoryPage): SavedCallHistory;
|
|
4
|
+
export function describeCall(entry: CallHistoryEntry): { label: string; detail: string; missed: boolean };
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Pure helpers shared by clients; only finalized server records enter history.
|
|
2
|
+
export function mergeCallHistory(saved, page) {
|
|
3
|
+
if (!page || !Array.isArray(page.entries) || !Number.isSafeInteger(page.nextCursor) ||
|
|
4
|
+
page.nextCursor < saved.cursor || typeof page.hasMore !== 'boolean') throw Error('invalid_call_history');
|
|
5
|
+
const byId = new Map(saved.entries.map(entry => [entry.id, entry]));
|
|
6
|
+
let sequence = saved.cursor;
|
|
7
|
+
for (const entry of page.entries) {
|
|
8
|
+
if (!entry || typeof entry.id !== 'string' || typeof entry.connectionId !== 'string' ||
|
|
9
|
+
!['caller', 'callee'].includes(entry.role) || typeof entry.reason !== 'string' ||
|
|
10
|
+
!Number.isSafeInteger(entry.sequence) || entry.sequence <= sequence || entry.sequence > page.nextCursor ||
|
|
11
|
+
!Number.isFinite(entry.createdAt) || !Number.isFinite(entry.endedAt) ||
|
|
12
|
+
!Number.isFinite(entry.durationMs) || entry.durationMs < 0 ||
|
|
13
|
+
entry.connectedAt !== null && !Number.isFinite(entry.connectedAt)) throw Error('invalid_call_history');
|
|
14
|
+
sequence = entry.sequence;
|
|
15
|
+
byId.set(entry.id, entry);
|
|
16
|
+
}
|
|
17
|
+
if (page.hasMore && page.nextCursor <= saved.cursor) throw Error('invalid_call_history');
|
|
18
|
+
return { cursor: page.nextCursor, entries: [...byId.values()].sort((a,b) => a.sequence - b.sequence) };
|
|
19
|
+
}
|
|
20
|
+
export function describeCall(entry) {
|
|
21
|
+
const incoming = entry.role === 'callee';
|
|
22
|
+
if (entry.connectedAt != null) return { label: incoming ? 'Incoming voice call' : 'Outgoing voice call',
|
|
23
|
+
detail: `${Math.floor(entry.durationMs / 60000)}:${String(Math.floor(entry.durationMs / 1000) % 60).padStart(2, '0')}`, missed: false };
|
|
24
|
+
if (entry.reason === 'declined') return { label: incoming ? 'Declined voice call' : 'Call declined', detail: '', missed: incoming };
|
|
25
|
+
if (['cancelled', 'no_answer'].includes(entry.reason)) return { label: incoming ? 'Missed voice call' : entry.reason === 'cancelled' ? 'Cancelled voice call' : 'No answer', detail: '', missed: incoming };
|
|
26
|
+
return { label: 'Call couldn’t connect', detail: '', missed: incoming };
|
|
27
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface CallHistoryEntry { id: string; sequence: number; connectionId: string; role: 'caller' | 'callee'; reason: string; createdAt: number; answeredAt: number | null; connectedAt: number | null; endedAt: number; durationMs: number }
|
|
2
|
+
export interface CallHistoryPage { entries: CallHistoryEntry[]; nextCursor: number; hasMore: boolean }
|
|
3
|
+
export type CallState = 'ringing' | 'connecting' | 'connected' | 'ended';
|
|
4
|
+
export interface Call { id: string; version: number; connectionId: string; state: CallState; role: 'caller' | 'callee'; controlled: boolean; reason?: string; remoteDescription: RTCSessionDescriptionInit | null; remoteCandidates: RTCIceCandidateInit[]; createdAt: number; ringExpiresAt?: number; ringRemainingMs?: number; localHeld?: boolean; remoteHeld?: boolean; holdExpiresAt?: number | null; holdRevision?: number; answeredAt?: number; connectedAt?: number; endedAt?: number }
|
|
5
|
+
export interface Snapshot { listeningStatus?: EventStatus; voiceAvailable: boolean; call: Call | null; muted: boolean; localHeld?: boolean; error: string | null; preparing?: boolean; nativePresentation?: { mode?: string; dismissedCallIds?: string[]; speakerEnabled?: boolean; callerNames?: Record<string, string>; appOwnedCallIds?: string[]; appAnsweringCallIds?: string[]; appRingingCallIds: string[]; systemRingingCallIds: string[] } | null }
|
|
6
|
+
export interface EventSubscription { setEnabled(enabled: boolean): void; close(): void }
|
|
7
|
+
export type EventStatus = 'connecting' | 'listening' | 'reconnecting' | 'disconnected' | 'paused' | 'session_expired' | 'access_revoked';
|
|
8
|
+
export interface Request { (operation: string, payload?: Record<string, unknown>): Promise<any>; subscribe?: (callbacks: {onEvent: () => void; onStatus?: (status: EventStatus) => void; onError?: (error: Error & {status?: number}) => void}) => EventSubscription }
|
|
9
|
+
export function createHttpTransport(options: { baseUrl: string; token: string; fetcher?: typeof fetch; events?: boolean; WebSocketImpl?: typeof WebSocket }): Request;
|
|
10
|
+
export class RobonoCallingClient {
|
|
11
|
+
constructor(options: { request: Request; onSessionExpired?: (error: Error) => void; onChange?: (state: Snapshot) => void; onRemoteStream?: (stream: MediaStream | null) => void; getUserMedia?: (context?: { isCurrent: () => boolean }) => Promise<MediaStream>; createPeerConnection?: (config: RTCConfiguration) => RTCPeerConnection; pollMs?: number; forceRelay?: boolean; createId?: () => string; createRemoteStream?: (track: MediaStreamTrack) => MediaStream });
|
|
12
|
+
state: Snapshot;
|
|
13
|
+
readonly closed: boolean;
|
|
14
|
+
setForeground(value: boolean): void;
|
|
15
|
+
listen(): Promise<void>;
|
|
16
|
+
refresh(): Promise<boolean>;
|
|
17
|
+
call(connectionId?: string): Promise<void>;
|
|
18
|
+
history(query?: { after?: number; limit?: number; connectionId?: string }): Promise<CallHistoryPage>;
|
|
19
|
+
availability(connectionId: string): Promise<{ voiceAvailable: boolean; reason?: 'ready' | 'calling_not_enabled' | 'contact_unavailable' }>;
|
|
20
|
+
answer(): Promise<void>;
|
|
21
|
+
hangUp(): Promise<void>;
|
|
22
|
+
hold(): Promise<void>;
|
|
23
|
+
resume(prepareAudio?: () => Promise<void>): Promise<void>;
|
|
24
|
+
mute(muted?: boolean): void;
|
|
25
|
+
dispose(): Promise<void>;
|
|
26
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {subscribeCallingEvents} from './events.js';
|
|
2
|
+
/** Audio-only calling client. Media and identity adapters are supplied by the host. */
|
|
3
|
+
export function createHttpTransport({ baseUrl, token, fetcher = fetch, events = true, WebSocketImpl = globalThis.WebSocket }) {
|
|
4
|
+
const request = async (operation, payload = {}) => {
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
let timer;
|
|
7
|
+
const work = (async () => {
|
|
8
|
+
const response = await fetcher(`${baseUrl}/${operation}`, {
|
|
9
|
+
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
10
|
+
body: JSON.stringify(payload), signal: controller.signal, redirect: 'error',
|
|
11
|
+
});
|
|
12
|
+
// Headers arriving does not mean the request completed. Keep the deadline
|
|
13
|
+
// through body consumption so a stalled response cannot freeze controls.
|
|
14
|
+
let data;
|
|
15
|
+
try { data = await response.json(); }
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (response.ok) throw error;
|
|
18
|
+
// Proxies may return HTML for 429/503. Preserve status and Retry-After.
|
|
19
|
+
data = {};
|
|
20
|
+
}
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
const e = Error(typeof data.error === 'string' ? data.error : data.error?.message || 'request_failed');
|
|
23
|
+
e.status = response.status;
|
|
24
|
+
const retryAfter = response.headers?.get('Retry-After');
|
|
25
|
+
if (retryAfter) {
|
|
26
|
+
const seconds = /^\d+(?:\.\d+)?$/.test(retryAfter.trim()) ? Number(retryAfter) : null;
|
|
27
|
+
const delay = seconds == null ? Date.parse(retryAfter) - Date.now() : seconds * 1000;
|
|
28
|
+
if (Number.isFinite(delay)) e.retryAfterMs = Math.max(0, delay);
|
|
29
|
+
}
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
return data;
|
|
33
|
+
})();
|
|
34
|
+
// Native fetch cancellation may abort the socket without settling its promise.
|
|
35
|
+
// Bound the complete operation ourselves, including response-body decoding.
|
|
36
|
+
const deadline = new Promise((_, reject) => { timer = setTimeout(() => {
|
|
37
|
+
controller.abort(); reject(Error('request_timeout'));
|
|
38
|
+
}, 4500); });
|
|
39
|
+
try { return await Promise.race([work, deadline]); } finally { clearTimeout(timer); }
|
|
40
|
+
};
|
|
41
|
+
if(events && WebSocketImpl) request.subscribe = callbacks => subscribeCallingEvents({baseUrl,token,WebSocketImpl},callbacks);
|
|
42
|
+
return request;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class RobonoCallingClient {
|
|
46
|
+
constructor({ request, onSessionExpired = () => {}, onChange = () => {}, onRemoteStream = () => {},
|
|
47
|
+
getUserMedia = () => navigator.mediaDevices.getUserMedia({ audio: true, video: false }),
|
|
48
|
+
createPeerConnection = config => new RTCPeerConnection(config), pollMs = 1000, forceRelay = false, createId = () => crypto.randomUUID(),
|
|
49
|
+
createRemoteStream = track => new MediaStream([track]) } = {}) {
|
|
50
|
+
Object.assign(this, { request, onSessionExpired, onChange, onRemoteStream, getUserMedia, createPeerConnection, pollMs, forceRelay, createId, createRemoteStream });
|
|
51
|
+
this.state = { voiceAvailable: false, call: null, muted: false, error: null };
|
|
52
|
+
this.pc = null; this.stream = null; this.epoch = 0; this.busy = false; this.closed = false;
|
|
53
|
+
this.lastSuccess = Date.now(); this.started = false; this.remoteCount = 0;
|
|
54
|
+
this.pendingRTC = new Set(); this.mediaTimers = new Set();
|
|
55
|
+
this.locallyEnded = new Set(); this.endRequests = new Map();
|
|
56
|
+
this.expiredRings = new Set();
|
|
57
|
+
this.pollFailures = 0; this.pollNotBefore = 0; this.foreground = true;
|
|
58
|
+
this.localHeld = false; this.holdRevision = 0; this.holdDeadline = 0;
|
|
59
|
+
}
|
|
60
|
+
rtc(promise, phase, timeoutMs = 8000) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const finish = (fn, value) => { clearTimeout(timer); this.pendingRTC.delete(cancel); fn(value); };
|
|
63
|
+
const cancel = () => finish(reject, Error('call_cancelled'));
|
|
64
|
+
const timer = setTimeout(() => finish(reject, Error(phase + '_timeout')), timeoutMs);
|
|
65
|
+
this.pendingRTC.add(cancel); promise.then(v => finish(resolve, v), e => finish(reject, e));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
emit() {
|
|
69
|
+
const call = this.state.call;
|
|
70
|
+
if(this.events && call && call.state!=='ended' && !this.timer && !this.polling && !this.closed)this.timer=setTimeout(()=>this.poll(),3000);
|
|
71
|
+
this.scheduleRingDeadline(call);
|
|
72
|
+
// The local WebRTC transport, not a later server poll, determines when this
|
|
73
|
+
// device can communicate. Keep the server snapshot separate for sequencing.
|
|
74
|
+
const connected = this.mediaConnectedAt != null && this.mediaCallId === call?.id &&
|
|
75
|
+
call.controlled && ['connecting', 'connected'].includes(call.state);
|
|
76
|
+
this.onChange({ ...this.state, preparing: this.busy, localHeld: this.localHeld,
|
|
77
|
+
call: connected ? { ...call, state: this.pc?.connectionState === 'disconnected' ? 'connecting' : 'connected', connectedAt: this.mediaConnectedAt } : call });
|
|
78
|
+
}
|
|
79
|
+
scheduleRingDeadline(call) {
|
|
80
|
+
if (call?.state !== 'ringing' || !call.controlled) {
|
|
81
|
+
clearTimeout(this.ringTimer); this.ringCallId = null; return;
|
|
82
|
+
}
|
|
83
|
+
const remaining = Number.isFinite(call.ringRemainingMs) ? call.ringRemainingMs :
|
|
84
|
+
Number.isFinite(call.ringExpiresAt) ? call.ringExpiresAt - Date.now() : null;
|
|
85
|
+
if (remaining == null) return;
|
|
86
|
+
const deadline = Date.now() + Math.max(0, remaining);
|
|
87
|
+
if (this.ringCallId === call.id && this.ringDeadline <= deadline) return;
|
|
88
|
+
clearTimeout(this.ringTimer); this.ringCallId = call.id; this.ringDeadline = deadline;
|
|
89
|
+
this.ringTimer = setTimeout(() => {
|
|
90
|
+
if (this.state.call?.id !== call.id || this.state.call.state !== 'ringing') return;
|
|
91
|
+
// A stalled poll must not leave an unanswered phone ringing. The server
|
|
92
|
+
// independently finalizes No answer; never send End (which means Decline).
|
|
93
|
+
this.locallyEnded.add(call.id); this.expiredRings.add(call.id);
|
|
94
|
+
while (this.expiredRings.size > 256) this.expiredRings.delete(this.expiredRings.values().next().value);
|
|
95
|
+
while (this.locallyEnded.size > 256) this.locallyEnded.delete(this.locallyEnded.values().next().value);
|
|
96
|
+
this.resetMedia();
|
|
97
|
+
this.state.call = {...this.state.call, state: 'ended', reason: 'no_answer', endedAt: Date.now()};
|
|
98
|
+
this.emit();
|
|
99
|
+
}, Math.min(2147483647, Math.max(0, remaining)));
|
|
100
|
+
}
|
|
101
|
+
async listen() {
|
|
102
|
+
if (this.started) return; this.started = true;
|
|
103
|
+
this.watchdog = setInterval(() => {
|
|
104
|
+
// Match the service's ten-second endpoint lease. Six seconds left almost
|
|
105
|
+
// no recovery time after a 4.5-second request timeout plus the poll delay.
|
|
106
|
+
// Explicit authorization failures still stop media immediately in refresh.
|
|
107
|
+
const heldLease = this.localHeld && this.holdDeadline > Date.now();
|
|
108
|
+
const unconfirmedHold = this.pendingHold && Date.now() - this.pendingHold.at > 10000;
|
|
109
|
+
if (!this.busy && this.mediaCallId && (this.pc || this.stream) && (unconfirmedHold || !heldLease && Date.now() - this.lastSuccess > 10000)) {
|
|
110
|
+
const id = this.state.call?.id; this.finishLocal('connection_lost');
|
|
111
|
+
if (id) this.request('end', { callId: id }).catch(() => {});
|
|
112
|
+
}
|
|
113
|
+
}, 500);
|
|
114
|
+
if(this.request.subscribe) this.events=this.request.subscribe({
|
|
115
|
+
onEvent:()=>this.wake(), onStatus:status=>{this.state.listeningStatus=status;this.emit();},
|
|
116
|
+
onError:e=>this.sessionError(e),
|
|
117
|
+
});
|
|
118
|
+
await this.poll();
|
|
119
|
+
}
|
|
120
|
+
sessionError(e) {
|
|
121
|
+
const active=this.state.call && this.state.call.state!=='ended';
|
|
122
|
+
this.state.error=active?e.message:null;this.state.listeningStatus=e.status===403?'access_revoked':'session_expired';
|
|
123
|
+
this.resetMedia();this.closed=true;clearTimeout(this.timer);clearInterval(this.watchdog);this.events?.close();this.emit();
|
|
124
|
+
if(e.status===401)this.onSessionExpired(e);
|
|
125
|
+
}
|
|
126
|
+
wake(){if(this.closed)return;clearTimeout(this.timer);if(this.polling||this.busy){this.eventPending=true;return;}void this.poll();}
|
|
127
|
+
setForeground(value){this.foreground=value;this.events?.setEnabled(value||!!this.state.call&&this.state.call.state!=='ended');if(value)this.wake();else if(!this.state.call||this.state.call.state==='ended')clearTimeout(this.timer);}
|
|
128
|
+
|
|
129
|
+
// Refresh without scheduling a second poll loop (also used for native wake races).
|
|
130
|
+
// False means the cached snapshot is not authoritative for this attempt.
|
|
131
|
+
async refresh() {
|
|
132
|
+
if (this.refreshing) return this.refreshing;
|
|
133
|
+
const work = this.refreshState();
|
|
134
|
+
this.refreshing = work;
|
|
135
|
+
try { return await work; } finally { if (this.refreshing === work) this.refreshing = null; }
|
|
136
|
+
}
|
|
137
|
+
async refreshState() {
|
|
138
|
+
if (this.closed || Date.now() < this.pollNotBefore) return false;
|
|
139
|
+
const epoch = this.epoch;
|
|
140
|
+
try {
|
|
141
|
+
const state = await this.request('state');
|
|
142
|
+
if (this.closed) return false;
|
|
143
|
+
this.lastSuccess = Date.now();
|
|
144
|
+
this.pollFailures = 0; this.pollNotBefore = 0;
|
|
145
|
+
if (epoch === this.epoch) {
|
|
146
|
+
this.state.error = null;
|
|
147
|
+
if (!this.busy && !(state.version < (this.state.call?.version ?? -1))) {
|
|
148
|
+
this.state.voiceAvailable = state.voiceAvailable;
|
|
149
|
+
await this.apply(state.call);
|
|
150
|
+
if (this.pendingHold) await this.confirmHold(this.pendingHold);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (this.closed) return false;
|
|
156
|
+
const delay = Math.min(30000, Math.max(1000, this.pollMs) * 2 ** Math.min(this.pollFailures++, 5));
|
|
157
|
+
this.pollNotBefore = Date.now() + Math.max(delay, Number.isFinite(e.retryAfterMs) ? e.retryAfterMs : 0);
|
|
158
|
+
// Credential revocations remain authoritative even after a local action.
|
|
159
|
+
if (epoch === this.epoch || e.status === 401 || e.status === 403) {
|
|
160
|
+
if(e.status===401||e.status===403){this.sessionError(e);return false;}
|
|
161
|
+
this.state.error = this.state.call && this.state.call.state!=='ended' ? e.message : null;
|
|
162
|
+
this.emit();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
async poll() {
|
|
168
|
+
if(this.closed||this.polling)return;
|
|
169
|
+
this.polling=true;this.timer=null;
|
|
170
|
+
try{await this.refresh();
|
|
171
|
+
}finally{this.polling=false;}
|
|
172
|
+
if(this.closed)return;
|
|
173
|
+
const active=this.state.call&&this.state.call.state!=='ended';
|
|
174
|
+
if(!this.foreground&&!active){this.events?.setEnabled(false);return;}
|
|
175
|
+
const eventPending=this.eventPending;this.eventPending=false;
|
|
176
|
+
const retry=this.pollFailures>0&&this.pollFailures<6;
|
|
177
|
+
const delay=Math.max(eventPending?0:this.events?3000:this.pollMs,this.pollNotBefore-Date.now());
|
|
178
|
+
if(active||!this.events||retry||eventPending)this.timer=setTimeout(()=>this.poll(),Math.min(2147483647,delay));
|
|
179
|
+
}
|
|
180
|
+
async apply(call) {
|
|
181
|
+
// A pending/late server poll cannot reopen a call the person already ended.
|
|
182
|
+
if (call && call.state !== 'ended' && this.locallyEnded.has(call.id)) {
|
|
183
|
+
// Reconcile a lost End response on subsequent polls, without reopening UI.
|
|
184
|
+
if (!this.expiredRings.has(call.id)) await this.endRemote(call.id).catch(() => {});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (call && this.state.call && call.version < this.state.call.version) return;
|
|
188
|
+
if (!call) { if (this.pc) this.resetMedia(); this.state.call = null; this.emit(); return; }
|
|
189
|
+
const wasHeld = this.state.call?.remoteHeld;
|
|
190
|
+
this.state.call = call;
|
|
191
|
+
if (call.state === 'ended' || !call.controlled) this.resetMedia();
|
|
192
|
+
else if (this.pc && this.mediaCallId === call.id) {
|
|
193
|
+
const pc = this.pc;
|
|
194
|
+
if (call.remoteDescription && !pc.remoteDescription) await this.rtc(pc.setRemoteDescription(call.remoteDescription), 'remote_description');
|
|
195
|
+
if (pc !== this.pc) return;
|
|
196
|
+
if (pc.remoteDescription) {
|
|
197
|
+
for (const candidate of call.remoteCandidates.slice(this.remoteCount)) {
|
|
198
|
+
await this.rtc(pc.addIceCandidate(candidate), 'remote_candidate'); if (pc !== this.pc) return; this.remoteCount++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (wasHeld && !call.remoteHeld && !this.localHeld) this.checkResumedTransport();
|
|
203
|
+
this.syncAudioTracks();
|
|
204
|
+
this.emit();
|
|
205
|
+
}
|
|
206
|
+
syncAudioTracks() {
|
|
207
|
+
const held = this.localHeld || this.state.call?.localHeld || this.state.call?.remoteHeld;
|
|
208
|
+
this.stream?.getAudioTracks().forEach(t => { t.enabled = !held && !this.state.muted; });
|
|
209
|
+
this.remoteStream?.getAudioTracks().forEach(t => { t.enabled = !held; });
|
|
210
|
+
}
|
|
211
|
+
/** Silence immediately; only a server-acknowledged hold relaxes the lease. */
|
|
212
|
+
async hold() {
|
|
213
|
+
const call = this.state.call;
|
|
214
|
+
if (!call?.controlled || call.state !== 'connected' || !this.pc) throw Error('call_not_connected');
|
|
215
|
+
this.localHeld = true; this.syncAudioTracks(); this.emit();
|
|
216
|
+
const revision = this.holdRevision = Math.max(this.holdRevision, call.holdRevision || 0) + 1;
|
|
217
|
+
const intent = this.pendingHold = {callId: call.id, held: true, revision, at: Date.now()};
|
|
218
|
+
await this.confirmHold(intent);
|
|
219
|
+
}
|
|
220
|
+
async confirmHold(intent) {
|
|
221
|
+
const {callId, revision} = intent;
|
|
222
|
+
const result = await this.retry('hold', {callId, held: true, revision});
|
|
223
|
+
if (this.mediaCallId !== callId || !this.localHeld || revision !== this.holdRevision) return;
|
|
224
|
+
if (this.pendingHold === intent) this.pendingHold = null;
|
|
225
|
+
this.holdDeadline = result.holdExpiresAt || 0;
|
|
226
|
+
await this.apply(result);
|
|
227
|
+
}
|
|
228
|
+
/** Revalidate before activating audio; a late response cannot restart a call. */
|
|
229
|
+
async resume(prepareAudio = async () => {}) {
|
|
230
|
+
const id = this.mediaCallId, revision = this.holdRevision;
|
|
231
|
+
if (!id || !this.localHeld) return;
|
|
232
|
+
if (!await this.refresh()) throw Error('call_state_unavailable');
|
|
233
|
+
if (this.mediaCallId !== id || !this.localHeld || revision !== this.holdRevision) return;
|
|
234
|
+
await prepareAudio();
|
|
235
|
+
if (this.mediaCallId !== id || !this.localHeld || revision !== this.holdRevision) return;
|
|
236
|
+
const nextRevision = ++this.holdRevision;
|
|
237
|
+
this.pendingHold = null;
|
|
238
|
+
const result = await this.retry('hold', {callId: id, held: false, revision: nextRevision});
|
|
239
|
+
if (this.mediaCallId !== id || nextRevision !== this.holdRevision) return;
|
|
240
|
+
this.localHeld = false; this.holdDeadline = 0; this.lastSuccess = Date.now();
|
|
241
|
+
await this.apply(result);
|
|
242
|
+
// A transport that failed while iOS suspended the app cannot be presented
|
|
243
|
+
// as a resumed conversation. End it cleanly rather than expose dead audio.
|
|
244
|
+
this.checkResumedTransport();
|
|
245
|
+
}
|
|
246
|
+
checkResumedTransport() {
|
|
247
|
+
const pc = this.pc;
|
|
248
|
+
if (!pc) return;
|
|
249
|
+
if (['failed', 'closed'].includes(pc.connectionState)) { this.mediaError(Error('media_disconnected')); return; }
|
|
250
|
+
if (pc.connectionState === 'disconnected') {
|
|
251
|
+
const timer = setTimeout(() => {
|
|
252
|
+
this.mediaTimers.delete(timer);
|
|
253
|
+
if (this.pc === pc && pc.connectionState === 'disconnected' && !this.localHeld && !this.state.call?.remoteHeld) this.mediaError(Error('media_disconnected'));
|
|
254
|
+
}, 10000);
|
|
255
|
+
this.mediaTimers.add(timer);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
resetMedia() {
|
|
259
|
+
this.epoch++;
|
|
260
|
+
for (const timer of this.mediaTimers) clearTimeout(timer); this.mediaTimers.clear();
|
|
261
|
+
for (const cancel of this.pendingRTC) cancel();
|
|
262
|
+
const pc = this.pc; this.pc = null;
|
|
263
|
+
if (pc) { pc.onicecandidate = pc.onconnectionstatechange = pc.ontrack = null; pc.close(); }
|
|
264
|
+
this.stream?.getTracks().forEach(t => t.stop()); this.stream = null;
|
|
265
|
+
this.remoteStream = null; this.localHeld = false; this.holdDeadline = 0; this.holdRevision = 0; this.pendingHold = null;
|
|
266
|
+
this.candidates = []; this.remoteCount = 0; this.mediaCallId = null; this.mediaConnectedAt = null; this.state.muted = false;
|
|
267
|
+
this.onRemoteStream(null);
|
|
268
|
+
}
|
|
269
|
+
async prepare(connectionId) {
|
|
270
|
+
this.resetMedia(); const epoch = this.epoch;
|
|
271
|
+
const ice = await this.request('ice', connectionId ? { connectionId } : {});
|
|
272
|
+
if (this.closed || epoch !== this.epoch) throw Error('call_cancelled');
|
|
273
|
+
if (this.forceRelay && !ice.relayAvailable) throw Error('relay_not_configured');
|
|
274
|
+
const stream = await this.rtc(this.getUserMedia({ isCurrent: () => !this.closed && epoch === this.epoch }).then(stream => {
|
|
275
|
+
if (this.closed || epoch !== this.epoch) stream.getTracks().forEach(t => t.stop());
|
|
276
|
+
return stream;
|
|
277
|
+
}), 'microphone', 30000);
|
|
278
|
+
if (this.closed || epoch !== this.epoch) { stream.getTracks().forEach(t => t.stop()); throw Error('call_cancelled'); }
|
|
279
|
+
if (!stream.getAudioTracks().length || stream.getVideoTracks().length) { stream.getTracks().forEach(t => t.stop()); throw Error('audio_only'); }
|
|
280
|
+
this.stream = stream;
|
|
281
|
+
const pc = this.pc = this.createPeerConnection({ iceServers: ice.iceServers, iceTransportPolicy: this.forceRelay ? 'relay' : 'all' });
|
|
282
|
+
this.candidates = []; this.sendTail = Promise.resolve();
|
|
283
|
+
for (const track of stream.getAudioTracks()) pc.addTrack(track, stream);
|
|
284
|
+
pc.ontrack = e => { if (this.pc === pc) { this.remoteStream = e.streams[0] || this.createRemoteStream(e.track); this.syncAudioTracks(); this.onRemoteStream(this.remoteStream); } };
|
|
285
|
+
pc.onicecandidate = e => {
|
|
286
|
+
if (!e.candidate || this.pc !== pc) return;
|
|
287
|
+
this.candidates.push(e.candidate.toJSON()); this.flushCandidates();
|
|
288
|
+
};
|
|
289
|
+
pc.onconnectionstatechange = () => {
|
|
290
|
+
if (this.pc !== pc || !this.mediaCallId) return;
|
|
291
|
+
if (pc.connectionState === 'connected') { this.reportMediaConnection(pc); this.emit(); }
|
|
292
|
+
if (pc.connectionState === 'failed' && !this.localHeld && !this.state.call?.remoteHeld) this.mediaError(Error('media_failed'));
|
|
293
|
+
if (pc.connectionState === 'disconnected') {
|
|
294
|
+
const timer = setTimeout(() => {
|
|
295
|
+
this.mediaTimers.delete(timer);
|
|
296
|
+
if (this.pc === pc && pc.connectionState === 'disconnected' && !this.localHeld && !this.state.call?.remoteHeld) this.mediaError(Error('media_disconnected'));
|
|
297
|
+
}, 10000);
|
|
298
|
+
this.mediaTimers.add(timer);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
return pc;
|
|
302
|
+
}
|
|
303
|
+
reportMediaConnection(pc) {
|
|
304
|
+
if (pc !== this.pc || pc.connectionState !== 'connected' || !this.mediaCallId || this.mediaConnectedAt != null) return;
|
|
305
|
+
const callId = this.mediaCallId;
|
|
306
|
+
this.mediaConnectedAt = Date.now();
|
|
307
|
+
this.emit();
|
|
308
|
+
// Acknowledge promptly; never let an old request alter a later call.
|
|
309
|
+
void this.retry('connected', { callId }).then(call => {
|
|
310
|
+
if (this.pc === pc && this.mediaCallId === callId && call?.id === callId) return this.apply(call);
|
|
311
|
+
}).catch(e => { if (this.pc === pc && this.mediaCallId === callId) this.mediaError(e); });
|
|
312
|
+
}
|
|
313
|
+
flushCandidates() {
|
|
314
|
+
if (!this.mediaCallId || !this.pc) return;
|
|
315
|
+
const callId = this.mediaCallId, pc = this.pc, candidates = this.candidates.splice(0);
|
|
316
|
+
for (const candidate of candidates) this.sendTail = this.sendTail.then(async () => {
|
|
317
|
+
if (this.pc === pc) await this.retry('candidate', { callId, candidate });
|
|
318
|
+
}).catch(e => { if (this.pc === pc) this.mediaError(e); });
|
|
319
|
+
}
|
|
320
|
+
mediaError(e) {
|
|
321
|
+
const callId = this.mediaCallId; this.finishLocal(e.message);
|
|
322
|
+
if (callId) this.request('failed', { callId }).catch(() => {});
|
|
323
|
+
}
|
|
324
|
+
finishLocal(error) {
|
|
325
|
+
const call = this.state.call;
|
|
326
|
+
this.resetMedia(); this.state.error = error;
|
|
327
|
+
if (call && call.state !== 'ended' && call.controlled) {
|
|
328
|
+
this.locallyEnded.add(call.id);
|
|
329
|
+
while (this.locallyEnded.size > 256) this.locallyEnded.delete(this.locallyEnded.values().next().value);
|
|
330
|
+
this.state.call = {...call, state: 'ended', reason: 'media_failed', endedAt: Date.now()};
|
|
331
|
+
}
|
|
332
|
+
this.emit();
|
|
333
|
+
}
|
|
334
|
+
async action(fn) {
|
|
335
|
+
if (this.busy || this.closed) throw Error('client_not_ready');
|
|
336
|
+
this.busy = true; this.state.error = null; this.emit();
|
|
337
|
+
try { return await fn(); }
|
|
338
|
+
catch (e) { this.mediaError(e); throw e; }
|
|
339
|
+
finally { this.busy = false; this.emit(); if(this.eventPending){this.eventPending=false;this.wake();} }
|
|
340
|
+
}
|
|
341
|
+
async history(query = {}) { return this.request('history', query); }
|
|
342
|
+
async availability(connectionId) { return this.request('availability', { connectionId }); }
|
|
343
|
+
async retry(operation, payload) {
|
|
344
|
+
try { return await this.request(operation, payload); }
|
|
345
|
+
catch (e) { if (e.status && e.status < 500) throw e; return this.request(operation, payload); }
|
|
346
|
+
}
|
|
347
|
+
async call(connectionId) {
|
|
348
|
+
if (this.state.call && this.state.call.state !== 'ended') throw Error('busy');
|
|
349
|
+
return this.action(async () => {
|
|
350
|
+
const pc = await this.prepare(connectionId); const epoch = this.epoch;
|
|
351
|
+
await this.rtc(pc.setLocalDescription(await this.rtc(pc.createOffer(), 'create_offer')), 'local_description');
|
|
352
|
+
if (this.epoch !== epoch || this.closed) throw Error('call_cancelled');
|
|
353
|
+
const payload = { ...(connectionId ? { connectionId } : {}), requestId: this.createId(), description: { type: 'offer', sdp: pc.localDescription.sdp } };
|
|
354
|
+
// Retrying this exact request is safe: the service deduplicates requestId.
|
|
355
|
+
const call = await this.retry('start', payload); this.lastSuccess = Date.now();
|
|
356
|
+
if (this.epoch !== epoch) { await this.retry('end', { callId: call.id }); return; }
|
|
357
|
+
this.state.call = call; this.mediaCallId = call.id; this.flushCandidates(); this.reportMediaConnection(pc); this.emit();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
async answer() {
|
|
361
|
+
const call = this.state.call;
|
|
362
|
+
if (!call || call.role !== 'callee' || call.state !== 'ringing' || !call.controlled) throw Error('no_incoming_call');
|
|
363
|
+
return this.action(async () => {
|
|
364
|
+
const pc = await this.prepare(call.connectionId), epoch = this.epoch;
|
|
365
|
+
await this.rtc(pc.setRemoteDescription(call.remoteDescription), 'remote_description');
|
|
366
|
+
await this.rtc(pc.setLocalDescription(await this.rtc(pc.createAnswer(), 'create_answer')), 'local_description');
|
|
367
|
+
if (this.epoch !== epoch || this.closed) throw Error('call_cancelled');
|
|
368
|
+
const result = await this.retry('answer', { callId: call.id, description: { type: 'answer', sdp: pc.localDescription.sdp } }); this.lastSuccess = Date.now();
|
|
369
|
+
if (this.epoch !== epoch) { await this.retry('end', { callId: call.id }); return; }
|
|
370
|
+
this.state.call = result; this.mediaCallId = call.id; this.flushCandidates(); this.reportMediaConnection(pc); await this.apply(result);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
async endRemote(callId) {
|
|
374
|
+
if (this.endRequests.has(callId)) return this.endRequests.get(callId);
|
|
375
|
+
const pending = this.retry('end', { callId }); this.endRequests.set(callId, pending);
|
|
376
|
+
try { return await pending; } finally { if (this.endRequests.get(callId) === pending) this.endRequests.delete(callId); }
|
|
377
|
+
}
|
|
378
|
+
async hangUp() {
|
|
379
|
+
const call = this.state.call; this.resetMedia();
|
|
380
|
+
if (call && call.state !== 'ended' && call.controlled) {
|
|
381
|
+
this.locallyEnded.add(call.id);
|
|
382
|
+
while (this.locallyEnded.size > 256) this.locallyEnded.delete(this.locallyEnded.values().next().value);
|
|
383
|
+
// Stop the local screen/audio immediately; server acknowledgement can be
|
|
384
|
+
// slow. The remote end remains retried using this same immutable call ID.
|
|
385
|
+
this.state.call = { ...call, state: 'ended', endedAt: Date.now(), reason: 'hangup' };
|
|
386
|
+
this.emit();
|
|
387
|
+
try {
|
|
388
|
+
const ended = await this.endRemote(call.id);
|
|
389
|
+
if (this.state.call?.id === call.id) this.state.call = ended;
|
|
390
|
+
} catch (e) { if (this.state.call?.id === call.id) this.state.error = e.message; }
|
|
391
|
+
}
|
|
392
|
+
this.emit();
|
|
393
|
+
}
|
|
394
|
+
mute(muted = true) { this.state.muted = muted; this.syncAudioTracks(); this.emit(); }
|
|
395
|
+
async dispose() {
|
|
396
|
+
this.events?.close();
|
|
397
|
+
this.closed = true; clearTimeout(this.timer); clearInterval(this.watchdog);
|
|
398
|
+
clearTimeout(this.ringTimer);
|
|
399
|
+
await this.hangUp();
|
|
400
|
+
// Older sandbox hosts may not have close; media is already released.
|
|
401
|
+
await this.request('close').catch(() => {});
|
|
402
|
+
}
|
|
403
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
|
+
"bugs": {
|
|
3
|
+
"url": "https://robono.com/contact?topic=sdk",
|
|
4
|
+
"email": "support@robono.com"
|
|
5
|
+
},
|
|
6
|
+
"support": "https://robono.com/contact?topic=sdk",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Add to Loop LLC",
|
|
9
|
+
"email": "support@robono.com",
|
|
10
|
+
"url": "https://robono.com"
|
|
11
|
+
},
|
|
12
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public",
|
|
17
|
+
"provenance": true
|
|
18
|
+
},
|
|
2
19
|
"name": "@robono/calling",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
20
|
+
"version": "0.1.3",
|
|
21
|
+
"description": "One-to-one WebRTC voice calling for connected Robono Bridge networks.",
|
|
22
|
+
"homepage": "https://robono.com/docs#calling",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/IMLX16/robono-sdks.git",
|
|
26
|
+
"directory": "packages/calling"
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./history": {
|
|
36
|
+
"types": "./dist/history.d.ts",
|
|
37
|
+
"import": "./dist/history.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"README.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
"LICENSE",
|
|
45
|
+
"SBOM.spdx.json"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22"
|
|
49
|
+
}
|
|
50
|
+
}
|