@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 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
- # Temporary Holding Version
1
+ # Robono Calling SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 };
@@ -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
+ }
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }