@robono/calling 0.0.0-stage → 0.1.2
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 +26 -0
- package/LICENSE +130 -0
- package/README.md +128 -2
- package/SBOM.spdx.json +44 -0
- package/dist/history.d.ts +4 -0
- package/dist/history.js +27 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +370 -0
- package/package.json +55 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
- Add an optional availability reason while preserving voiceAvailable and existing call-control behavior.
|
|
6
|
+
|
|
7
|
+
## 0.1.1
|
|
8
|
+
|
|
9
|
+
- Stop unanswered ringing locally at the server deadline even if polling stalls.
|
|
10
|
+
- Add ordered, repeatable hold/resume, silenced tracks, a bounded held lease and fresh authorization before resuming.
|
|
11
|
+
- Dismiss locally failed media instead of leaving connected controls visible.
|
|
12
|
+
|
|
13
|
+
- Back off state polling after rate limits and temporary failures, honoring Retry-After.
|
|
14
|
+
- Preserve HTTP failure status when a proxy returns a non-JSON response.
|
|
15
|
+
- Share concurrent state refreshes so background scheduling and foreground polling cannot apply the same signaling state twice.
|
|
16
|
+
- Keep the calling engine independent of the optional mobile screen package.
|
|
17
|
+
|
|
18
|
+
## 0.1.0
|
|
19
|
+
|
|
20
|
+
- One-to-one WebRTC audio with direct connections and relay fallback.
|
|
21
|
+
- Stable start request IDs, cancellation during microphone permission or signaling, bounded transport deadlines and media cleanup.
|
|
22
|
+
- Per-contact availability, answer, mute, hangup, connection state and call history.
|
|
23
|
+
- Browser defaults and injectable media adapters for native hosts.
|
|
24
|
+
- TypeScript declarations and history merge/display helpers.
|
|
25
|
+
|
|
26
|
+
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,129 @@
|
|
|
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.
|
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.2",
|
|
6
|
+
"documentNamespace": "http://spdx.org/spdxdocs/@robono%2fcalling-0.1.2-***",
|
|
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.2"
|
|
15
|
+
],
|
|
16
|
+
"packages": [
|
|
17
|
+
{
|
|
18
|
+
"name": "@robono/calling",
|
|
19
|
+
"SPDXID": "SPDXRef-Package-robono.calling-0.1.2",
|
|
20
|
+
"versionInfo": "0.1.2",
|
|
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.2"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"relationships": [
|
|
38
|
+
{
|
|
39
|
+
"spdxElementId": "SPDXRef-DOCUMENT",
|
|
40
|
+
"relatedSpdxElement": "SPDXRef-Package-robono.calling-0.1.2",
|
|
41
|
+
"relationshipType": "DESCRIBES"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
@@ -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,23 @@
|
|
|
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 { 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 type Request = (operation: string, payload?: Record<string, unknown>) => Promise<any>;
|
|
7
|
+
export function createHttpTransport(options: { baseUrl: string; token: string; fetcher?: typeof fetch }): Request;
|
|
8
|
+
export class RobonoCallingClient {
|
|
9
|
+
constructor(options: { request: Request; 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 });
|
|
10
|
+
state: Snapshot;
|
|
11
|
+
readonly closed: boolean;
|
|
12
|
+
listen(): Promise<void>;
|
|
13
|
+
refresh(): Promise<boolean>;
|
|
14
|
+
call(connectionId?: string): Promise<void>;
|
|
15
|
+
history(query?: { after?: number; limit?: number; connectionId?: string }): Promise<CallHistoryPage>;
|
|
16
|
+
availability(connectionId: string): Promise<{ voiceAvailable: boolean; reason?: 'ready' | 'calling_not_enabled' | 'contact_unavailable' }>;
|
|
17
|
+
answer(): Promise<void>;
|
|
18
|
+
hangUp(): Promise<void>;
|
|
19
|
+
hold(): Promise<void>;
|
|
20
|
+
resume(prepareAudio?: () => Promise<void>): Promise<void>;
|
|
21
|
+
mute(muted?: boolean): void;
|
|
22
|
+
dispose(): Promise<void>;
|
|
23
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/** Audio-only calling client. Media and identity adapters are supplied by the host. */
|
|
2
|
+
export function createHttpTransport({ baseUrl, token, fetcher = fetch }) {
|
|
3
|
+
return async (operation, payload = {}) => {
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
const timer = setTimeout(() => controller.abort(), 4500);
|
|
6
|
+
try {
|
|
7
|
+
const response = await fetcher(`${baseUrl}/${operation}`, {
|
|
8
|
+
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
9
|
+
body: JSON.stringify(payload), signal: controller.signal, redirect: 'error',
|
|
10
|
+
});
|
|
11
|
+
// Headers arriving does not mean the request completed. Keep the deadline
|
|
12
|
+
// through body consumption so a stalled response cannot freeze controls.
|
|
13
|
+
let data;
|
|
14
|
+
try { data = await response.json(); }
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (response.ok) throw error;
|
|
17
|
+
// Proxies may return HTML for 429/503. Preserve status and Retry-After.
|
|
18
|
+
data = {};
|
|
19
|
+
}
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
const e = Error(typeof data.error === 'string' ? data.error : data.error?.message || 'request_failed');
|
|
22
|
+
e.status = response.status;
|
|
23
|
+
const retryAfter = response.headers?.get('Retry-After');
|
|
24
|
+
if (retryAfter) {
|
|
25
|
+
const seconds = /^\d+(?:\.\d+)?$/.test(retryAfter.trim()) ? Number(retryAfter) : null;
|
|
26
|
+
const delay = seconds == null ? Date.parse(retryAfter) - Date.now() : seconds * 1000;
|
|
27
|
+
if (Number.isFinite(delay)) e.retryAfterMs = Math.max(0, delay);
|
|
28
|
+
}
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
return data;
|
|
32
|
+
} finally { clearTimeout(timer); }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class RobonoCallingClient {
|
|
37
|
+
constructor({ request, onChange = () => {}, onRemoteStream = () => {},
|
|
38
|
+
getUserMedia = () => navigator.mediaDevices.getUserMedia({ audio: true, video: false }),
|
|
39
|
+
createPeerConnection = config => new RTCPeerConnection(config), pollMs = 1000, forceRelay = false, createId = () => crypto.randomUUID(),
|
|
40
|
+
createRemoteStream = track => new MediaStream([track]) } = {}) {
|
|
41
|
+
Object.assign(this, { request, onChange, onRemoteStream, getUserMedia, createPeerConnection, pollMs, forceRelay, createId, createRemoteStream });
|
|
42
|
+
this.state = { voiceAvailable: false, call: null, muted: false, error: null };
|
|
43
|
+
this.pc = null; this.stream = null; this.epoch = 0; this.busy = false; this.closed = false;
|
|
44
|
+
this.lastSuccess = Date.now(); this.started = false; this.remoteCount = 0;
|
|
45
|
+
this.pendingRTC = new Set(); this.mediaTimers = new Set();
|
|
46
|
+
this.locallyEnded = new Set(); this.endRequests = new Map();
|
|
47
|
+
this.expiredRings = new Set();
|
|
48
|
+
this.pollFailures = 0; this.pollNotBefore = 0;
|
|
49
|
+
this.localHeld = false; this.holdRevision = 0; this.holdDeadline = 0;
|
|
50
|
+
}
|
|
51
|
+
rtc(promise, phase, timeoutMs = 8000) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const finish = (fn, value) => { clearTimeout(timer); this.pendingRTC.delete(cancel); fn(value); };
|
|
54
|
+
const cancel = () => finish(reject, Error('call_cancelled'));
|
|
55
|
+
const timer = setTimeout(() => finish(reject, Error(phase + '_timeout')), timeoutMs);
|
|
56
|
+
this.pendingRTC.add(cancel); promise.then(v => finish(resolve, v), e => finish(reject, e));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
emit() {
|
|
60
|
+
const call = this.state.call;
|
|
61
|
+
this.scheduleRingDeadline(call);
|
|
62
|
+
// The local WebRTC transport, not a later server poll, determines when this
|
|
63
|
+
// device can communicate. Keep the server snapshot separate for sequencing.
|
|
64
|
+
const connected = this.mediaConnectedAt != null && this.mediaCallId === call?.id &&
|
|
65
|
+
call.controlled && ['connecting', 'connected'].includes(call.state);
|
|
66
|
+
this.onChange({ ...this.state, preparing: this.busy, localHeld: this.localHeld,
|
|
67
|
+
call: connected ? { ...call, state: this.pc?.connectionState === 'disconnected' ? 'connecting' : 'connected', connectedAt: this.mediaConnectedAt } : call });
|
|
68
|
+
}
|
|
69
|
+
scheduleRingDeadline(call) {
|
|
70
|
+
if (call?.state !== 'ringing' || !call.controlled) {
|
|
71
|
+
clearTimeout(this.ringTimer); this.ringCallId = null; return;
|
|
72
|
+
}
|
|
73
|
+
const remaining = Number.isFinite(call.ringRemainingMs) ? call.ringRemainingMs :
|
|
74
|
+
Number.isFinite(call.ringExpiresAt) ? call.ringExpiresAt - Date.now() : null;
|
|
75
|
+
if (remaining == null) return;
|
|
76
|
+
const deadline = Date.now() + Math.max(0, remaining);
|
|
77
|
+
if (this.ringCallId === call.id && this.ringDeadline <= deadline) return;
|
|
78
|
+
clearTimeout(this.ringTimer); this.ringCallId = call.id; this.ringDeadline = deadline;
|
|
79
|
+
this.ringTimer = setTimeout(() => {
|
|
80
|
+
if (this.state.call?.id !== call.id || this.state.call.state !== 'ringing') return;
|
|
81
|
+
// A stalled poll must not leave an unanswered phone ringing. The server
|
|
82
|
+
// independently finalizes No answer; never send End (which means Decline).
|
|
83
|
+
this.locallyEnded.add(call.id); this.expiredRings.add(call.id);
|
|
84
|
+
while (this.expiredRings.size > 256) this.expiredRings.delete(this.expiredRings.values().next().value);
|
|
85
|
+
while (this.locallyEnded.size > 256) this.locallyEnded.delete(this.locallyEnded.values().next().value);
|
|
86
|
+
this.resetMedia();
|
|
87
|
+
this.state.call = {...this.state.call, state: 'ended', reason: 'no_answer', endedAt: Date.now()};
|
|
88
|
+
this.emit();
|
|
89
|
+
}, Math.min(2147483647, Math.max(0, remaining)));
|
|
90
|
+
}
|
|
91
|
+
async listen() {
|
|
92
|
+
if (this.started) return; this.started = true;
|
|
93
|
+
this.watchdog = setInterval(() => {
|
|
94
|
+
// Match the service's ten-second endpoint lease. Six seconds left almost
|
|
95
|
+
// no recovery time after a 4.5-second request timeout plus the poll delay.
|
|
96
|
+
// Explicit authorization failures still stop media immediately in refresh.
|
|
97
|
+
const heldLease = this.localHeld && this.holdDeadline > Date.now();
|
|
98
|
+
const unconfirmedHold = this.pendingHold && Date.now() - this.pendingHold.at > 10000;
|
|
99
|
+
if ((this.pc || this.stream) && (unconfirmedHold || !heldLease && Date.now() - this.lastSuccess > 10000)) {
|
|
100
|
+
const id = this.state.call?.id; this.finishLocal('connection_lost');
|
|
101
|
+
if (id) this.request('end', { callId: id }).catch(() => {});
|
|
102
|
+
}
|
|
103
|
+
}, 500);
|
|
104
|
+
await this.poll();
|
|
105
|
+
}
|
|
106
|
+
// Refresh without scheduling a second poll loop (also used for native wake races).
|
|
107
|
+
// False means the cached snapshot is not authoritative for this attempt.
|
|
108
|
+
async refresh() {
|
|
109
|
+
if (this.refreshing) return this.refreshing;
|
|
110
|
+
const work = this.refreshState();
|
|
111
|
+
this.refreshing = work;
|
|
112
|
+
try { return await work; } finally { if (this.refreshing === work) this.refreshing = null; }
|
|
113
|
+
}
|
|
114
|
+
async refreshState() {
|
|
115
|
+
if (this.closed || Date.now() < this.pollNotBefore) return false;
|
|
116
|
+
const epoch = this.epoch;
|
|
117
|
+
try {
|
|
118
|
+
const state = await this.request('state');
|
|
119
|
+
if (this.closed) return false;
|
|
120
|
+
this.lastSuccess = Date.now();
|
|
121
|
+
this.pollFailures = 0; this.pollNotBefore = 0;
|
|
122
|
+
if (epoch === this.epoch) {
|
|
123
|
+
this.state.error = null;
|
|
124
|
+
if (!this.busy && !(state.version < (this.state.call?.version ?? -1))) {
|
|
125
|
+
this.state.voiceAvailable = state.voiceAvailable;
|
|
126
|
+
await this.apply(state.call);
|
|
127
|
+
if (this.pendingHold) await this.confirmHold(this.pendingHold);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (this.closed) return false;
|
|
133
|
+
const delay = Math.min(30000, Math.max(1000, this.pollMs) * 2 ** Math.min(this.pollFailures++, 5));
|
|
134
|
+
this.pollNotBefore = Date.now() + Math.max(delay, Number.isFinite(e.retryAfterMs) ? e.retryAfterMs : 0);
|
|
135
|
+
// Credential revocations remain authoritative even after a local action.
|
|
136
|
+
if (epoch === this.epoch || e.status === 401 || e.status === 403) {
|
|
137
|
+
this.state.error = e.message;
|
|
138
|
+
if (e.status === 401 || e.status === 403) { this.resetMedia(); this.closed = true; clearInterval(this.watchdog); }
|
|
139
|
+
this.emit();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
async poll() {
|
|
145
|
+
await this.refresh();
|
|
146
|
+
if (!this.closed) this.timer = setTimeout(() => this.poll(), Math.min(2147483647, Math.max(this.pollMs, this.pollNotBefore - Date.now())));
|
|
147
|
+
}
|
|
148
|
+
async apply(call) {
|
|
149
|
+
// A pending/late server poll cannot reopen a call the person already ended.
|
|
150
|
+
if (call && call.state !== 'ended' && this.locallyEnded.has(call.id)) {
|
|
151
|
+
// Reconcile a lost End response on subsequent polls, without reopening UI.
|
|
152
|
+
if (!this.expiredRings.has(call.id)) await this.endRemote(call.id).catch(() => {});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (call && this.state.call && call.version < this.state.call.version) return;
|
|
156
|
+
if (!call) { if (this.pc) this.resetMedia(); this.state.call = null; this.emit(); return; }
|
|
157
|
+
const wasHeld = this.state.call?.remoteHeld;
|
|
158
|
+
this.state.call = call;
|
|
159
|
+
if (call.state === 'ended' || !call.controlled) this.resetMedia();
|
|
160
|
+
else if (this.pc && this.mediaCallId === call.id) {
|
|
161
|
+
const pc = this.pc;
|
|
162
|
+
if (call.remoteDescription && !pc.remoteDescription) await this.rtc(pc.setRemoteDescription(call.remoteDescription), 'remote_description');
|
|
163
|
+
if (pc !== this.pc) return;
|
|
164
|
+
if (pc.remoteDescription) {
|
|
165
|
+
for (const candidate of call.remoteCandidates.slice(this.remoteCount)) {
|
|
166
|
+
await this.rtc(pc.addIceCandidate(candidate), 'remote_candidate'); if (pc !== this.pc) return; this.remoteCount++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (wasHeld && !call.remoteHeld && !this.localHeld) this.checkResumedTransport();
|
|
171
|
+
this.syncAudioTracks();
|
|
172
|
+
this.emit();
|
|
173
|
+
}
|
|
174
|
+
syncAudioTracks() {
|
|
175
|
+
const held = this.localHeld || this.state.call?.localHeld || this.state.call?.remoteHeld;
|
|
176
|
+
this.stream?.getAudioTracks().forEach(t => { t.enabled = !held && !this.state.muted; });
|
|
177
|
+
this.remoteStream?.getAudioTracks().forEach(t => { t.enabled = !held; });
|
|
178
|
+
}
|
|
179
|
+
/** Silence immediately; only a server-acknowledged hold relaxes the lease. */
|
|
180
|
+
async hold() {
|
|
181
|
+
const call = this.state.call;
|
|
182
|
+
if (!call?.controlled || call.state !== 'connected' || !this.pc) throw Error('call_not_connected');
|
|
183
|
+
this.localHeld = true; this.syncAudioTracks(); this.emit();
|
|
184
|
+
const revision = this.holdRevision = Math.max(this.holdRevision, call.holdRevision || 0) + 1;
|
|
185
|
+
const intent = this.pendingHold = {callId: call.id, held: true, revision, at: Date.now()};
|
|
186
|
+
await this.confirmHold(intent);
|
|
187
|
+
}
|
|
188
|
+
async confirmHold(intent) {
|
|
189
|
+
const {callId, revision} = intent;
|
|
190
|
+
const result = await this.retry('hold', {callId, held: true, revision});
|
|
191
|
+
if (this.mediaCallId !== callId || !this.localHeld || revision !== this.holdRevision) return;
|
|
192
|
+
if (this.pendingHold === intent) this.pendingHold = null;
|
|
193
|
+
this.holdDeadline = result.holdExpiresAt || 0;
|
|
194
|
+
await this.apply(result);
|
|
195
|
+
}
|
|
196
|
+
/** Revalidate before activating audio; a late response cannot restart a call. */
|
|
197
|
+
async resume(prepareAudio = async () => {}) {
|
|
198
|
+
const id = this.mediaCallId, revision = this.holdRevision;
|
|
199
|
+
if (!id || !this.localHeld) return;
|
|
200
|
+
if (!await this.refresh()) throw Error('call_state_unavailable');
|
|
201
|
+
if (this.mediaCallId !== id || !this.localHeld || revision !== this.holdRevision) return;
|
|
202
|
+
await prepareAudio();
|
|
203
|
+
if (this.mediaCallId !== id || !this.localHeld || revision !== this.holdRevision) return;
|
|
204
|
+
const nextRevision = ++this.holdRevision;
|
|
205
|
+
this.pendingHold = null;
|
|
206
|
+
const result = await this.retry('hold', {callId: id, held: false, revision: nextRevision});
|
|
207
|
+
if (this.mediaCallId !== id || nextRevision !== this.holdRevision) return;
|
|
208
|
+
this.localHeld = false; this.holdDeadline = 0; this.lastSuccess = Date.now();
|
|
209
|
+
await this.apply(result);
|
|
210
|
+
// A transport that failed while iOS suspended the app cannot be presented
|
|
211
|
+
// as a resumed conversation. End it cleanly rather than expose dead audio.
|
|
212
|
+
this.checkResumedTransport();
|
|
213
|
+
}
|
|
214
|
+
checkResumedTransport() {
|
|
215
|
+
const pc = this.pc;
|
|
216
|
+
if (!pc) return;
|
|
217
|
+
if (['failed', 'closed'].includes(pc.connectionState)) { this.mediaError(Error('media_disconnected')); return; }
|
|
218
|
+
if (pc.connectionState === 'disconnected') {
|
|
219
|
+
const timer = setTimeout(() => {
|
|
220
|
+
this.mediaTimers.delete(timer);
|
|
221
|
+
if (this.pc === pc && pc.connectionState === 'disconnected' && !this.localHeld && !this.state.call?.remoteHeld) this.mediaError(Error('media_disconnected'));
|
|
222
|
+
}, 10000);
|
|
223
|
+
this.mediaTimers.add(timer);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
resetMedia() {
|
|
227
|
+
this.epoch++;
|
|
228
|
+
for (const timer of this.mediaTimers) clearTimeout(timer); this.mediaTimers.clear();
|
|
229
|
+
for (const cancel of this.pendingRTC) cancel();
|
|
230
|
+
const pc = this.pc; this.pc = null;
|
|
231
|
+
if (pc) { pc.onicecandidate = pc.onconnectionstatechange = pc.ontrack = null; pc.close(); }
|
|
232
|
+
this.stream?.getTracks().forEach(t => t.stop()); this.stream = null;
|
|
233
|
+
this.remoteStream = null; this.localHeld = false; this.holdDeadline = 0; this.holdRevision = 0; this.pendingHold = null;
|
|
234
|
+
this.candidates = []; this.remoteCount = 0; this.mediaCallId = null; this.mediaConnectedAt = null; this.state.muted = false;
|
|
235
|
+
this.onRemoteStream(null);
|
|
236
|
+
}
|
|
237
|
+
async prepare(connectionId) {
|
|
238
|
+
this.resetMedia(); const epoch = this.epoch;
|
|
239
|
+
const ice = await this.request('ice', connectionId ? { connectionId } : {});
|
|
240
|
+
if (this.closed || epoch !== this.epoch) throw Error('call_cancelled');
|
|
241
|
+
if (this.forceRelay && !ice.relayAvailable) throw Error('relay_not_configured');
|
|
242
|
+
const stream = await this.rtc(this.getUserMedia({ isCurrent: () => !this.closed && epoch === this.epoch }).then(stream => {
|
|
243
|
+
if (this.closed || epoch !== this.epoch) stream.getTracks().forEach(t => t.stop());
|
|
244
|
+
return stream;
|
|
245
|
+
}), 'microphone', 30000);
|
|
246
|
+
if (this.closed || epoch !== this.epoch) { stream.getTracks().forEach(t => t.stop()); throw Error('call_cancelled'); }
|
|
247
|
+
if (!stream.getAudioTracks().length || stream.getVideoTracks().length) { stream.getTracks().forEach(t => t.stop()); throw Error('audio_only'); }
|
|
248
|
+
this.stream = stream;
|
|
249
|
+
const pc = this.pc = this.createPeerConnection({ iceServers: ice.iceServers, iceTransportPolicy: this.forceRelay ? 'relay' : 'all' });
|
|
250
|
+
this.candidates = []; this.sendTail = Promise.resolve();
|
|
251
|
+
for (const track of stream.getAudioTracks()) pc.addTrack(track, stream);
|
|
252
|
+
pc.ontrack = e => { if (this.pc === pc) { this.remoteStream = e.streams[0] || this.createRemoteStream(e.track); this.syncAudioTracks(); this.onRemoteStream(this.remoteStream); } };
|
|
253
|
+
pc.onicecandidate = e => {
|
|
254
|
+
if (!e.candidate || this.pc !== pc) return;
|
|
255
|
+
this.candidates.push(e.candidate.toJSON()); this.flushCandidates();
|
|
256
|
+
};
|
|
257
|
+
pc.onconnectionstatechange = () => {
|
|
258
|
+
if (this.pc !== pc || !this.mediaCallId) return;
|
|
259
|
+
if (pc.connectionState === 'connected') { this.reportMediaConnection(pc); this.emit(); }
|
|
260
|
+
if (pc.connectionState === 'failed' && !this.localHeld && !this.state.call?.remoteHeld) this.mediaError(Error('media_failed'));
|
|
261
|
+
if (pc.connectionState === 'disconnected') {
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
this.mediaTimers.delete(timer);
|
|
264
|
+
if (this.pc === pc && pc.connectionState === 'disconnected' && !this.localHeld && !this.state.call?.remoteHeld) this.mediaError(Error('media_disconnected'));
|
|
265
|
+
}, 10000);
|
|
266
|
+
this.mediaTimers.add(timer);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
return pc;
|
|
270
|
+
}
|
|
271
|
+
reportMediaConnection(pc) {
|
|
272
|
+
if (pc !== this.pc || pc.connectionState !== 'connected' || !this.mediaCallId || this.mediaConnectedAt != null) return;
|
|
273
|
+
const callId = this.mediaCallId;
|
|
274
|
+
this.mediaConnectedAt = Date.now();
|
|
275
|
+
this.emit();
|
|
276
|
+
// Acknowledge promptly; never let an old request alter a later call.
|
|
277
|
+
void this.retry('connected', { callId }).then(call => {
|
|
278
|
+
if (this.pc === pc && this.mediaCallId === callId && call?.id === callId) return this.apply(call);
|
|
279
|
+
}).catch(e => { if (this.pc === pc && this.mediaCallId === callId) this.mediaError(e); });
|
|
280
|
+
}
|
|
281
|
+
flushCandidates() {
|
|
282
|
+
if (!this.mediaCallId || !this.pc) return;
|
|
283
|
+
const callId = this.mediaCallId, pc = this.pc, candidates = this.candidates.splice(0);
|
|
284
|
+
for (const candidate of candidates) this.sendTail = this.sendTail.then(async () => {
|
|
285
|
+
if (this.pc === pc) await this.retry('candidate', { callId, candidate });
|
|
286
|
+
}).catch(e => { if (this.pc === pc) this.mediaError(e); });
|
|
287
|
+
}
|
|
288
|
+
mediaError(e) {
|
|
289
|
+
const callId = this.mediaCallId; this.finishLocal(e.message);
|
|
290
|
+
if (callId) this.request('failed', { callId }).catch(() => {});
|
|
291
|
+
}
|
|
292
|
+
finishLocal(error) {
|
|
293
|
+
const call = this.state.call;
|
|
294
|
+
this.resetMedia(); this.state.error = error;
|
|
295
|
+
if (call && call.state !== 'ended' && call.controlled) {
|
|
296
|
+
this.locallyEnded.add(call.id);
|
|
297
|
+
while (this.locallyEnded.size > 256) this.locallyEnded.delete(this.locallyEnded.values().next().value);
|
|
298
|
+
this.state.call = {...call, state: 'ended', reason: 'media_failed', endedAt: Date.now()};
|
|
299
|
+
}
|
|
300
|
+
this.emit();
|
|
301
|
+
}
|
|
302
|
+
async action(fn) {
|
|
303
|
+
if (this.busy || this.closed) throw Error('client_not_ready');
|
|
304
|
+
this.busy = true; this.state.error = null; this.emit();
|
|
305
|
+
try { return await fn(); }
|
|
306
|
+
catch (e) { this.mediaError(e); throw e; }
|
|
307
|
+
finally { this.busy = false; this.emit(); }
|
|
308
|
+
}
|
|
309
|
+
async history(query = {}) { return this.request('history', query); }
|
|
310
|
+
async availability(connectionId) { return this.request('availability', { connectionId }); }
|
|
311
|
+
async retry(operation, payload) {
|
|
312
|
+
try { return await this.request(operation, payload); }
|
|
313
|
+
catch (e) { if (e.status && e.status < 500) throw e; return this.request(operation, payload); }
|
|
314
|
+
}
|
|
315
|
+
async call(connectionId) {
|
|
316
|
+
if (this.state.call && this.state.call.state !== 'ended') throw Error('busy');
|
|
317
|
+
return this.action(async () => {
|
|
318
|
+
const pc = await this.prepare(connectionId); const epoch = this.epoch;
|
|
319
|
+
await this.rtc(pc.setLocalDescription(await this.rtc(pc.createOffer(), 'create_offer')), 'local_description');
|
|
320
|
+
if (this.epoch !== epoch || this.closed) throw Error('call_cancelled');
|
|
321
|
+
const payload = { ...(connectionId ? { connectionId } : {}), requestId: this.createId(), description: { type: 'offer', sdp: pc.localDescription.sdp } };
|
|
322
|
+
// Retrying this exact request is safe: the service deduplicates requestId.
|
|
323
|
+
const call = await this.retry('start', payload);
|
|
324
|
+
if (this.epoch !== epoch) { await this.retry('end', { callId: call.id }); return; }
|
|
325
|
+
this.state.call = call; this.mediaCallId = call.id; this.flushCandidates(); this.reportMediaConnection(pc); this.emit();
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async answer() {
|
|
329
|
+
const call = this.state.call;
|
|
330
|
+
if (!call || call.role !== 'callee' || call.state !== 'ringing' || !call.controlled) throw Error('no_incoming_call');
|
|
331
|
+
return this.action(async () => {
|
|
332
|
+
const pc = await this.prepare(call.connectionId), epoch = this.epoch;
|
|
333
|
+
await this.rtc(pc.setRemoteDescription(call.remoteDescription), 'remote_description');
|
|
334
|
+
await this.rtc(pc.setLocalDescription(await this.rtc(pc.createAnswer(), 'create_answer')), 'local_description');
|
|
335
|
+
if (this.epoch !== epoch || this.closed) throw Error('call_cancelled');
|
|
336
|
+
const result = await this.retry('answer', { callId: call.id, description: { type: 'answer', sdp: pc.localDescription.sdp } });
|
|
337
|
+
if (this.epoch !== epoch) { await this.retry('end', { callId: call.id }); return; }
|
|
338
|
+
this.state.call = result; this.mediaCallId = call.id; this.flushCandidates(); this.reportMediaConnection(pc); await this.apply(result);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
async endRemote(callId) {
|
|
342
|
+
if (this.endRequests.has(callId)) return this.endRequests.get(callId);
|
|
343
|
+
const pending = this.retry('end', { callId }); this.endRequests.set(callId, pending);
|
|
344
|
+
try { return await pending; } finally { if (this.endRequests.get(callId) === pending) this.endRequests.delete(callId); }
|
|
345
|
+
}
|
|
346
|
+
async hangUp() {
|
|
347
|
+
const call = this.state.call; this.resetMedia();
|
|
348
|
+
if (call && call.state !== 'ended' && call.controlled) {
|
|
349
|
+
this.locallyEnded.add(call.id);
|
|
350
|
+
while (this.locallyEnded.size > 256) this.locallyEnded.delete(this.locallyEnded.values().next().value);
|
|
351
|
+
// Stop the local screen/audio immediately; server acknowledgement can be
|
|
352
|
+
// slow. The remote end remains retried using this same immutable call ID.
|
|
353
|
+
this.state.call = { ...call, state: 'ended', endedAt: Date.now(), reason: 'hangup' };
|
|
354
|
+
this.emit();
|
|
355
|
+
try {
|
|
356
|
+
const ended = await this.endRemote(call.id);
|
|
357
|
+
if (this.state.call?.id === call.id) this.state.call = ended;
|
|
358
|
+
} catch (e) { if (this.state.call?.id === call.id) this.state.error = e.message; }
|
|
359
|
+
}
|
|
360
|
+
this.emit();
|
|
361
|
+
}
|
|
362
|
+
mute(muted = true) { this.state.muted = muted; this.syncAudioTracks(); this.emit(); }
|
|
363
|
+
async dispose() {
|
|
364
|
+
this.closed = true; clearTimeout(this.timer); clearInterval(this.watchdog);
|
|
365
|
+
clearTimeout(this.ringTimer);
|
|
366
|
+
await this.hangUp();
|
|
367
|
+
// Older sandbox hosts may not have close; media is already released.
|
|
368
|
+
await this.request('close').catch(() => {});
|
|
369
|
+
}
|
|
370
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
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.2",
|
|
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
|
+
"scripts": {
|
|
51
|
+
"build": "node build.mjs",
|
|
52
|
+
"test": "npm run build && node --test test/client.test.mjs test/package.test.mjs",
|
|
53
|
+
"test:integration": "node --test test/*.test.mjs",
|
|
54
|
+
"prepack": "npm run build && node ../../scripts/generate-package-sbom.mjs",
|
|
55
|
+
"prepublishOnly": "npm test"
|
|
56
|
+
}
|
|
57
|
+
}
|