@robono/calling 0.1.2 → 0.2.0
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 +20 -0
- package/README.md +38 -1
- package/SBOM.spdx.json +9 -9
- package/dist/events.js +38 -0
- package/dist/groups.d.ts +7 -0
- package/dist/groups.js +153 -0
- package/dist/history.js +4 -4
- package/dist/index.d.ts +15 -8
- package/dist/index.js +95 -36
- package/dist/media-profile.js +18 -0
- package/dist/sharing.js +46 -0
- package/dist/telemetry.js +24 -0
- package/package.json +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Add bounded, best-effort transport counters for operational relay visibility. Reports contain no media or network addresses and are not used for billing.
|
|
6
|
+
|
|
7
|
+
Voice and video calls, group calls, screen sharing, participant-based usage reporting and shared-budget call admission. Existing one-to-one voice APIs remain available.
|
|
8
|
+
|
|
9
|
+
- Keep group calls alive during successful signaling, with bounded parallel ICE
|
|
10
|
+
submission and retry recovery. Unrelated requests and stale responses cannot
|
|
11
|
+
extend an active call's authorization lease.
|
|
12
|
+
- Preserve native remote streams so video tracks remain attached to mobile video views.
|
|
13
|
+
- Reuse the original session description after a lost response; ICE gathering cannot change the retried payload.
|
|
14
|
+
- Negotiate group peers independently and prevent pre-start state polling from clearing a new call’s local media.
|
|
15
|
+
- Cap transmitted video when native cameras capture above the selected resolution; reapply the same limits when sharing a screen or restoring the camera.
|
|
16
|
+
- Enforce request deadlines even when native networking ignores cancellation, including stalled response bodies, so signaling can recover instead of remaining stuck.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## Unreleased
|
|
20
|
+
|
|
21
|
+
- Optional video, group voice/video (two to eight members), separate screen-sharing permissions, and per-participant history. Group signaling preserves isolated peer legs and current relationship permissions. Screen sharing replaces video tracks without changing microphone transport; restored cameras keep their prior choice. Group invitation deadlines and expired-session cleanup stop stale UI and media.
|
|
22
|
+
|
|
3
23
|
## 0.1.2
|
|
4
24
|
|
|
5
25
|
- Add an optional availability reason while preserving voiceAvailable and existing call-control behavior.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Robono Calling SDK
|
|
2
2
|
|
|
3
|
-
Add
|
|
3
|
+
Add voice and video calls within your app or between people connected across Robono Bridge networks, with optional group calls and screen sharing. 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
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -127,3 +127,40 @@ Unanswered calls expire 30 seconds after creation. Stop ringing at `ringRemainin
|
|
|
127
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
128
|
|
|
129
129
|
Call `client.resume(prepareAudio)` only when the OS permits audio to return, or when the person explicitly requests resume. The client first refreshes authorization and call state, then awaits your `prepareAudio` callback, acknowledges resume and restores tracks. The callback must reject if another call still owns audio. A stale response, remote hangup, expired session or ended call cannot restart capture. The optional mobile package supplies this wiring and a Resume call control. No voicemail is involved.
|
|
130
|
+
|
|
131
|
+
### Optional video
|
|
132
|
+
|
|
133
|
+
Check `availability(connectionId).videoAvailable` separately from `voiceAvailable`. Start a video call with `client.call(connectionId, {kind: 'video'})`. Ordinary `client.call(connectionId)` remains voice-only. The host's media adapter receives `{kind, isCurrent}`; a video call requires one audio track and one video track. Use `onLocalStream` and `onRemoteStream` to render previews, `setCamera(enabled)` to control outgoing video, and `setVideoForeground(active)` when application visibility changes. Disposal stops all captured tracks.
|
|
134
|
+
|
|
135
|
+
Both apps and the relationship must allow video. Voice-only sessions do not receive video invitations. When using your own WebRTC client, send `mediaKinds: ['voice', 'video']` with `/state`, `kind: 'video'` with `/ice` and `/start`, and supply one audio and one video SDP media section. Voice requests omit `kind` or use `voice`. No data channel or additional camera tracks are admitted. Media travels directly when possible, using the configured relay fallback when needed.
|
|
136
|
+
|
|
137
|
+
Final call history includes `kind`; older records without it are voice calls. Usage records distinguish voice and video and retain connection duration, not the media itself.
|
|
138
|
+
|
|
139
|
+
## Usage protection and operational visibility
|
|
140
|
+
|
|
141
|
+
The service applies the same protections to own-network and cross-network calls.
|
|
142
|
+
Preserve the request ID when retrying a lost call-start response. Honor
|
|
143
|
+
`Retry-After`; a deliberate new call needs a new request ID. Call status and
|
|
144
|
+
hangup have separate limits so a start loop does not block cleanup.
|
|
145
|
+
|
|
146
|
+
The SDK sends bounded, best-effort transport byte counters every fifteen seconds
|
|
147
|
+
while connected. They contain no audio, video, transcripts, IP addresses or ICE
|
|
148
|
+
candidates. Counters are approximate and are not used to calculate charges.
|
|
149
|
+
|
|
150
|
+
Use the Server SDK from your backend for `monitoring()` and revision-numbered
|
|
151
|
+
`setUserPermissions()`. See [limits, costs and alerts](https://robono.com/docs#calling-reliability).
|
|
152
|
+
|
|
153
|
+
## Event-based listening
|
|
154
|
+
|
|
155
|
+
Use `@robono/calling@0.1.3` with `@robono/calling-react-native@0.2.2` when using the optional mobile interface. Existing custom interfaces remain supported. See https://robono.com/docs#calling-events for the lifecycle and recovery contract.
|
|
156
|
+
|
|
157
|
+
- Foreground, idle: an authenticated WebSocket listens for incoming calls. No periodic state polling. A ping/pong every 20 seconds maintains presence.
|
|
158
|
+
- Active calls: events trigger state reconciliation immediately; a state health check runs every 3 seconds. The optional native runtime also provides a three-second background recovery check when device timers are suspended; simultaneous refreshes share one request.
|
|
159
|
+
- Idle in background: call `setForeground(false)`. The event socket and state checks stop. Your backend delivers Robono's signed incoming-call events through your app's APNs/FCM notifications. A WebSocket does not replace push delivery.
|
|
160
|
+
- Return to foreground: call `setForeground(true)`. The socket reconnects and current state is checked. Do not create a new call to recover a listening session. The optional controller forwards this lifecycle method to the engine.
|
|
161
|
+
- Event reconnection: delays start at 1 second, double to 30 seconds (plus up to 20% jitter), honor Retry-After, and stop after eight retries. Foreground re-entry can restart recovery. State recovery stops after six consecutive failed idle checks.
|
|
162
|
+
- Expired idle sessions: `onSessionExpired` requests new admission without presenting a failed-call screen. Dispose the expired client and create a new one with a new session from your backend. Use bounded backoff for admission failures. Authorization revocation is not a retryable session expiration. An active call cannot be transferred to a new session.
|
|
163
|
+
|
|
164
|
+
`createHttpTransport` supplies the event subscription automatically. If wrapping it, preserve `request.subscribe = transport.subscribe`. A custom request without `subscribe`, or `{events:false}`, retains the compatibility polling interval (`pollMs`, default 1000 ms).
|
|
165
|
+
|
|
166
|
+
The 0.1.2 engine had polling and retry backoff but did not include this event transport. No native permissions or rebuild are added by this JavaScript patch to an app already using the native calling package.
|
package/SBOM.spdx.json
CHANGED
|
@@ -2,24 +2,24 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "@robono/calling@0.
|
|
6
|
-
"documentNamespace": "http://spdx.org/spdxdocs/@robono%2fcalling-0.
|
|
5
|
+
"name": "@robono/calling@0.2.0",
|
|
6
|
+
"documentNamespace": "http://spdx.org/spdxdocs/@robono%2fcalling-0.2.0-***",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-10-
|
|
8
|
+
"created": "2026-10-28T21:07:59.755Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Tool: npm/cli-11.6.2"
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
13
|
"documentDescribes": [
|
|
14
|
-
"SPDXRef-Package-robono.calling-0.
|
|
14
|
+
"SPDXRef-Package-robono.calling-0.2.0"
|
|
15
15
|
],
|
|
16
16
|
"packages": [
|
|
17
17
|
{
|
|
18
18
|
"name": "@robono/calling",
|
|
19
|
-
"SPDXID": "SPDXRef-Package-robono.calling-0.
|
|
20
|
-
"versionInfo": "0.
|
|
19
|
+
"SPDXID": "SPDXRef-Package-robono.calling-0.2.0",
|
|
20
|
+
"versionInfo": "0.2.0",
|
|
21
21
|
"packageFileName": "",
|
|
22
|
-
"description": "
|
|
22
|
+
"description": "WebRTC voice, video, group calling and screen sharing within apps and across Robono Bridge networks.",
|
|
23
23
|
"primaryPackagePurpose": "LIBRARY",
|
|
24
24
|
"downloadLocation": "NOASSERTION",
|
|
25
25
|
"filesAnalyzed": false,
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
{
|
|
30
30
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
31
31
|
"referenceType": "purl",
|
|
32
|
-
"referenceLocator": "pkg:npm/%40robono/calling@0.
|
|
32
|
+
"referenceLocator": "pkg:npm/%40robono/calling@0.2.0"
|
|
33
33
|
}
|
|
34
34
|
]
|
|
35
35
|
}
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"relationships": [
|
|
38
38
|
{
|
|
39
39
|
"spdxElementId": "SPDXRef-DOCUMENT",
|
|
40
|
-
"relatedSpdxElement": "SPDXRef-Package-robono.calling-0.
|
|
40
|
+
"relatedSpdxElement": "SPDXRef-Package-robono.calling-0.2.0",
|
|
41
41
|
"relationshipType": "DESCRIBES"
|
|
42
42
|
}
|
|
43
43
|
]
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Authenticated event hints with bounded reconnects. No idle state polling. */
|
|
2
|
+
export function subscribeCallingEvents({baseUrl,token,WebSocketImpl}, {onEvent,onStatus=()=>{},onError=()=>{}}) {
|
|
3
|
+
let socket,timer,deadline,stable,stale,closed=false,paused=false,failures=0,retryAfter=0,terminal=false;
|
|
4
|
+
const url=baseUrl.replace(/^http/, 'ws').replace(/\/$/,'')+'/events';
|
|
5
|
+
function clear(){clearTimeout(timer);clearTimeout(deadline);clearTimeout(stable);clearTimeout(stale);}
|
|
6
|
+
function disconnect(){const previous=socket;socket=null;if(previous){previous.onclose=null;previous.onmessage=null;previous.onerror=()=>{};previous.close();}}
|
|
7
|
+
function retry(){
|
|
8
|
+
clear();if(closed||paused||terminal)return;
|
|
9
|
+
if(failures>=8){onStatus('disconnected');return;}
|
|
10
|
+
const delay=Math.max(retryAfter,Math.min(30000,1000*2**failures++)*(1+Math.random()*.2));retryAfter=0;
|
|
11
|
+
onStatus('reconnecting');timer=setTimeout(connect,delay);
|
|
12
|
+
}
|
|
13
|
+
function touch(){clearTimeout(stale);stale=setTimeout(()=>{disconnect();retry();},50000);}
|
|
14
|
+
function connect(){
|
|
15
|
+
if(closed||paused||terminal)return;
|
|
16
|
+
try{socket=new WebSocketImpl(url);}catch{retry();return;}
|
|
17
|
+
const ws=socket;onStatus('connecting');
|
|
18
|
+
deadline=setTimeout(()=>{disconnect();retry();},10000);
|
|
19
|
+
ws.onopen=()=>{if(ws===socket)ws.send(JSON.stringify({type:'authenticate',token}));};
|
|
20
|
+
ws.onmessage=event=>{
|
|
21
|
+
if(ws!==socket)return;
|
|
22
|
+
let data;try{data=JSON.parse(event.data);}catch{return;}
|
|
23
|
+
if(data.type==='ready'){clearTimeout(deadline);touch();stable=setTimeout(()=>{failures=0;},60000);onStatus('listening');onEvent();}
|
|
24
|
+
else if(data.type==='changed')onEvent();
|
|
25
|
+
else if(data.type==='ping'){touch();ws.send(JSON.stringify({type:'pong'}));}
|
|
26
|
+
else if(data.type==='error'){
|
|
27
|
+
retryAfter=Math.max(0,Number(data.retryAfterMs)||0);
|
|
28
|
+
if(data.status===401||data.status===403){terminal=true;clear();disconnect();onStatus(data.status===403?'access_revoked':'session_expired');onError(Object.assign(Error(data.error),{status:data.status}));}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
ws.onerror=()=>{};ws.onclose=()=>{if(ws!==socket)return;socket=null;retry();};
|
|
32
|
+
}
|
|
33
|
+
connect();
|
|
34
|
+
return {
|
|
35
|
+
setEnabled(enabled){if(closed||terminal)return;if(enabled&&!paused&&socket)return;paused=!enabled;clear();disconnect();if(enabled){failures=0;connect();}else onStatus('paused');},
|
|
36
|
+
close(){closed=true;clear();disconnect();},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/dist/groups.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type {Request,EventStatus} from './index.js';
|
|
2
|
+
export interface GroupCall {screenSharingAvailable?:boolean;sharing?:boolean[];id:string;connectionId:string;group:true;kind:'voice'|'video';version:number;state:'ringing'|'connecting'|'connected'|'ended';reason?:string;createdAt:number;connectedAt:number|null;endedAt:number|null;ringRemainingMs:number;holdExpiresAt:number|null;self:number;controlled:boolean;memberState:'invited'|'joined'|'left'|'declined';localHeld:boolean;members:Array<{id:number;appId:string;userId:string;state:string;held:boolean;connectedAt:number|null}>;peers:Array<{id:string;participant:number;offerer:boolean;remoteDescription:RTCSessionDescriptionInit|null;remoteCandidates:RTCIceCandidateInit[];connected:boolean}>}
|
|
3
|
+
export interface GroupSnapshot {listeningStatus?:EventStatus;screenSharing?:boolean;screenSharePending?:boolean;displayStream?:MediaStream|null;call:GroupCall|null;muted:boolean;cameraEnabled:boolean;localHeld:boolean;localStream:MediaStream|null;remoteStreams:Record<number,MediaStream>;error:string|null;preparing:boolean;connectedAt:number|null}
|
|
4
|
+
export class RobonoGroupCallingClient {
|
|
5
|
+
constructor(options:{videoQuality?:'480p'|'720p';request:Request;onSessionExpired?:(error:Error)=>void;onChange?:(state:GroupSnapshot)=>void;getDisplayMedia?:(context?:{isCurrent:()=>boolean})=>Promise<MediaStream>;getUserMedia?:(context:{kind:'voice'|'video';videoQuality?:'480p'|'720p';isCurrent:()=>boolean})=>Promise<MediaStream>;createPeerConnection?:(config:RTCConfiguration)=>RTCPeerConnection;createRemoteStream?:(track:MediaStreamTrack)=>MediaStream;createId?:()=>string;forceRelay?:boolean;pollMs?:number});
|
|
6
|
+
availability(connectionId:string,kind?:'voice'|'video'):Promise<{available:boolean;maxParticipants:number}>;startScreenSharing():Promise<void>;stopScreenSharing():Promise<void>;state:GroupSnapshot;closed:boolean;listen():Promise<void>;refresh():Promise<boolean>;call(connectionId:string,options?:{kind?:'voice'|'video'}):Promise<void>;answer():Promise<void>;leave():Promise<void>;hold():Promise<void>;resume(prepareAudio?:()=>Promise<void>):Promise<void>;mute(value?:boolean):void;setCamera(value:boolean):void;setForeground(value:boolean):void;setVideoForeground(value:boolean):void;dispose():Promise<void>;
|
|
7
|
+
}
|
package/dist/groups.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {createTransportTelemetry} from './telemetry.js';
|
|
2
|
+
import {mediaProfile,videoConstraints,limitSender} from './media-profile.js';
|
|
3
|
+
import {createScreenSharing} from './sharing.js';
|
|
4
|
+
/** Optional multiparty engine. It shares admission/transport with direct calls, not their UI. */
|
|
5
|
+
export class RobonoGroupCallingClient {
|
|
6
|
+
constructor({request,onSessionExpired=()=>{},onChange=()=>{},videoQuality='720p',getUserMedia=({kind,videoQuality})=>navigator.mediaDevices.getUserMedia({audio:true,video:kind==='video'?videoConstraints(videoQuality):false}),createPeerConnection=c=>new RTCPeerConnection(c),createRemoteStream=track=>new MediaStream([track]),createId=()=>crypto.randomUUID(),forceRelay=false,pollMs=1000,getDisplayMedia=()=>navigator.mediaDevices.getDisplayMedia({video:true,audio:false})}={}){
|
|
7
|
+
Object.assign(this,{onSessionExpired,onChange,getUserMedia,createPeerConnection,createRemoteStream,createId,forceRelay,pollMs});
|
|
8
|
+
this.request=async(operation,input)=>{
|
|
9
|
+
const epoch=this.epoch,result=await request(operation,input);
|
|
10
|
+
// Negotiation also proves the call is authorized and the server is reachable.
|
|
11
|
+
// Availability checks and late replies from a previous call must not extend it.
|
|
12
|
+
if(!this.closed&&epoch===this.epoch&&['state','start','join','description','candidate','connected','hold','sharing'].some(name=>operation==='groups/'+name))this.lastSuccess=Date.now();
|
|
13
|
+
return result;
|
|
14
|
+
};
|
|
15
|
+
this.request.subscribe=request.subscribe;
|
|
16
|
+
this.videoQuality=mediaProfile(videoQuality).quality;
|
|
17
|
+
this.reportTransport=createTransportTelemetry(request);this.peers=new Map();this.pendingRTC=new Set();this.state={call:null,muted:false,cameraEnabled:true,localHeld:false,localStream:null,remoteStreams:{},error:null,preparing:false,connectedAt:null};
|
|
18
|
+
this.sharing=createScreenSharing({limitVideo:sender=>limitSender(sender,this.ice?.videoQuality==='480p'?'480p':this.videoQuality),request:input=>this.request('groups/sharing',input),call:()=>this.state.call,peers:()=>[...this.peers.values()].map(p=>p.pc),stream:()=>this.stream,capture:getDisplayMedia,emit:()=>this.emit(),fail:e=>{this.state.error=e.message;this.emit();}});
|
|
19
|
+
this.closed=false;this.epoch=0;this.foreground=true;this.failures=0;this.notBefore=0;this.lastSuccess=Date.now();this.holdRevision=0;
|
|
20
|
+
}
|
|
21
|
+
rtc(promise,phase){return new Promise((resolve,reject)=>{let finished=false;const finish=(fn,value)=>{if(finished)return;finished=true;clearTimeout(timer);this.pendingRTC.delete(cancel);fn(value);};const cancel=()=>finish(reject,Error('call_cancelled'));const timer=setTimeout(()=>finish(reject,Error(phase+'_timeout')),8000);this.pendingRTC.add(cancel);Promise.resolve(promise).then(v=>finish(resolve,v),e=>finish(reject,e));});}
|
|
22
|
+
emit(){if(this.events&&this.state.call&&this.state.call.state!=='ended'&&!this.timer&&!this.polling&&!this.closed)this.timer=setTimeout(()=>this.poll(),3000);this.scheduleRingDeadline();Object.assign(this.state,this.sharing.state());this.onChange({...this.state,remoteStreams:{...this.state.remoteStreams}});}
|
|
23
|
+
scheduleRingDeadline(){
|
|
24
|
+
const call=this.state.call;
|
|
25
|
+
if(!call?.controlled||call.state==='ended'||!(call.memberState==='invited'||call.state==='ringing')){clearTimeout(this.ringTimer);this.ringCallId=null;return;}
|
|
26
|
+
if(!Number.isFinite(call.ringRemainingMs))return;
|
|
27
|
+
const remaining=Math.max(0,call.ringRemainingMs),deadline=Date.now()+remaining;
|
|
28
|
+
if(this.ringCallId===call.id&&this.ringDeadline<=deadline)return;
|
|
29
|
+
clearTimeout(this.ringTimer);this.ringCallId=call.id;this.ringDeadline=deadline;
|
|
30
|
+
this.ringTimer=setTimeout(()=>{
|
|
31
|
+
const current=this.state.call;if(current?.id!==call.id||current.state==='ended'||!(current.memberState==='invited'||current.state==='ringing'))return;
|
|
32
|
+
this.ended??=new Set();this.ended.add(call.id);while(this.ended.size>256)this.ended.delete(this.ended.values().next().value);
|
|
33
|
+
this.reset();this.state.call={...current,state:'ended',reason:'no_answer',endedAt:Date.now()};this.emit();
|
|
34
|
+
},Math.min(2147483647,remaining));
|
|
35
|
+
}
|
|
36
|
+
syncTracks(){for(const t of this.stream?.getTracks()??[])t.enabled=!this.state.localHeld&&(t.kind==='audio'?!this.state.muted:this.state.cameraEnabled&&this.foreground);}
|
|
37
|
+
mute(value=true){this.state.muted=value;this.syncTracks();this.emit();}
|
|
38
|
+
setCamera(value){this.state.cameraEnabled=value;this.syncTracks();this.emit();}
|
|
39
|
+
setVideoForeground(value){this.foreground=value;this.syncTracks();}
|
|
40
|
+
async availability(connectionId,kind='voice'){if(this.closed)throw Error('calling_client_closed');return this.request('groups/availability',{connectionId,kind});}
|
|
41
|
+
async listen(){if(this.started||this.closed)return;this.started=true;this.watch=setInterval(()=>{if(!this.state.preparing&&this.state.call&&this.stream&&(!(this.state.localHeld&&this.holdDeadline>Date.now())&&Date.now()-this.lastSuccess>10000)){void this.leave().catch(()=>{});}},500);if(this.request.subscribe)this.events=this.request.subscribe({onEvent:()=>this.wake(),onStatus:status=>{this.state.listeningStatus=status;this.emit();},onError:e=>this.sessionError(e)});await this.poll();}
|
|
42
|
+
sessionError(e){const active=this.state.call&&this.state.call.state!=='ended';this.state.error=active?e.message:null;this.state.listeningStatus=e.status===403?'access_revoked':'session_expired';this.reset();this.closed=true;clearTimeout(this.timer);clearInterval(this.watch);this.events?.close();this.emit();if(e.status===401)this.onSessionExpired(e);}
|
|
43
|
+
wake(){if(this.closed)return;clearTimeout(this.timer);if(this.polling||this.state.preparing){this.eventPending=true;return;}void this.poll();}
|
|
44
|
+
setForeground(value){this.foreground=value;this.setVideoForeground(value);this.events?.setEnabled(value||!!this.state.call&&this.state.call.state!=='ended');if(value)this.wake();else if(!this.state.call||this.state.call.state==='ended')clearTimeout(this.timer);}
|
|
45
|
+
async poll(){
|
|
46
|
+
if(this.closed||this.polling)return;this.polling=true;this.timer=null;
|
|
47
|
+
try{await this.refresh();this.reportTransport(this.state.call?.state==='connected'?this.state.call.id:null,[...this.peers].map(([id,p])=>[id,p.pc]));}finally{this.polling=false;}
|
|
48
|
+
if(this.closed)return;const active=this.state.call&&this.state.call.state!=='ended';
|
|
49
|
+
if(!this.foreground&&!active){this.events?.setEnabled(false);return;}
|
|
50
|
+
const pending=this.eventPending;this.eventPending=false;
|
|
51
|
+
if(active||!this.events||this.failures>0&&this.failures<6||pending)this.timer=setTimeout(()=>this.poll(),Math.min(2147483647,Math.max(pending?0:this.events?3000:this.pollMs,this.notBefore-Date.now())));
|
|
52
|
+
}
|
|
53
|
+
async refresh(){
|
|
54
|
+
if(this.closed||this.state.preparing||Date.now()<this.notBefore)return false;
|
|
55
|
+
if(this.refreshing)return this.refreshing;
|
|
56
|
+
const work=this.refreshState();this.refreshing=work;try{return await work;}finally{if(this.refreshing===work)this.refreshing=null;}
|
|
57
|
+
}
|
|
58
|
+
async refreshState(){
|
|
59
|
+
const epoch=this.epoch;
|
|
60
|
+
try{const result=await this.request('groups/state',{});if(this.closed)return false;this.failures=0;this.notBefore=0;
|
|
61
|
+
if(epoch!==this.epoch||this.state.preparing)return false;
|
|
62
|
+
await this.apply(result.call);this.state.error=null;this.emit();return true;
|
|
63
|
+
}catch(e){if(this.closed||epoch!==this.epoch||this.state.preparing)return false;this.notBefore=Date.now()+Math.max(Number(e.retryAfterMs)||0,Math.min(30000,1000*2**Math.min(this.failures++,5)));this.state.error=this.state.call&&this.state.call.state!=='ended'?e.message:null;
|
|
64
|
+
if(e.status===401||e.status===403){this.sessionError(e);return false;}
|
|
65
|
+
if(e.status===401){this.closed=true;clearTimeout(this.timer);clearInterval(this.watch);}
|
|
66
|
+
if([401,403].includes(e.status)||/_timeout$/.test(e.message)){const call=this.state.call;this.reset();this.state.call=null;if(call)void this.request('groups/leave',{callId:call.id}).catch(()=>{});}
|
|
67
|
+
this.emit();return false;}
|
|
68
|
+
}
|
|
69
|
+
async capture(kind,epoch){
|
|
70
|
+
const stream=await this.getUserMedia({kind,videoQuality:this.ice?.videoQuality==='480p'?'480p':this.videoQuality,isCurrent:()=>epoch===this.epoch&&!this.closed});
|
|
71
|
+
if(epoch!==this.epoch||this.closed){stream.getTracks().forEach(t=>t.stop());throw Error('call_cancelled');}
|
|
72
|
+
if(!stream.getAudioTracks().length||kind==='video'&&!stream.getVideoTracks().length){stream.getTracks().forEach(t=>t.stop());throw Error('media_unavailable');}
|
|
73
|
+
this.stream=stream;this.state.localStream=stream;this.syncTracks();
|
|
74
|
+
}
|
|
75
|
+
async call(connectionId,{kind='voice'}={}){
|
|
76
|
+
if(this.state.preparing||this.stream||this.closed)throw Error('busy');
|
|
77
|
+
const epoch=++this.epoch;this.state.preparing=true;this.state.error=null;this.state.connectedAt=null;this.emit();
|
|
78
|
+
try{
|
|
79
|
+
this.ice=await this.request('groups/ice',{connectionId,kind});await this.capture(kind,epoch);
|
|
80
|
+
if(epoch!==this.epoch)throw Error('call_cancelled');
|
|
81
|
+
const input={connectionId,kind,requestId:this.createId()};
|
|
82
|
+
// Same request ID on a lost response; never start a second group call.
|
|
83
|
+
let call;try{call=await this.request('groups/start',input);}catch(e){if(e.status&&e.status<500)throw e;call=await this.request('groups/start',input);}
|
|
84
|
+
if(epoch!==this.epoch){await this.request('groups/leave',{callId:call.id});return;}
|
|
85
|
+
await this.apply(call);
|
|
86
|
+
}catch(e){this.reset();this.state.call=null;this.state.error=e.message;throw e;}
|
|
87
|
+
finally{this.state.preparing=false;this.emit();if(this.eventPending){this.eventPending=false;this.wake();}}
|
|
88
|
+
}
|
|
89
|
+
async answer(){
|
|
90
|
+
const call=this.state.call;if(!call||call.memberState!=='invited'||!call.controlled||this.state.preparing)throw Error('call_unavailable');
|
|
91
|
+
const epoch=++this.epoch;this.state.preparing=true;this.emit();
|
|
92
|
+
try{this.ice=await this.request('groups/ice',{callId:call.id,kind:call.kind});await this.capture(call.kind,epoch);
|
|
93
|
+
if(epoch!==this.epoch)throw Error('call_cancelled');
|
|
94
|
+
let joined;try{joined=await this.request('groups/join',{callId:call.id});}catch(e){if(e.status&&e.status<500)throw e;joined=await this.request('groups/join',{callId:call.id});}
|
|
95
|
+
if(epoch!==this.epoch){await this.request('groups/leave',{callId:call.id});return;}
|
|
96
|
+
await this.apply(joined);
|
|
97
|
+
}catch(e){this.reset();this.state.call=null;void this.request('groups/leave',{callId:call.id}).catch(()=>{});this.state.error=e.message;throw e;}finally{this.state.preparing=false;this.emit();if(this.eventPending){this.eventPending=false;this.wake();}}
|
|
98
|
+
}
|
|
99
|
+
async apply(call){
|
|
100
|
+
if(call?.id&&this.ended?.has(call.id))return;
|
|
101
|
+
this.state.call=call;this.sharing.sync();
|
|
102
|
+
if(!call||call.state==='ended'||!call.controlled||['left','declined'].includes(call.memberState)){this.reset();this.emit();return;}
|
|
103
|
+
if(call.memberState!=='joined'||!this.stream){this.emit();return;}
|
|
104
|
+
const epoch=this.epoch;
|
|
105
|
+
for(const [id,p] of this.peers)if(!call.peers.some(x=>x.id===id)){p.pc.close();this.peers.delete(id);delete this.state.remoteStreams[p.participant];}
|
|
106
|
+
// Each peer negotiates independently; a slow leg must not hold up the others.
|
|
107
|
+
const negotiations=await Promise.allSettled(call.peers.map(async remote=>{
|
|
108
|
+
if(epoch!==this.epoch)return;
|
|
109
|
+
let p=this.peers.get(remote.id);
|
|
110
|
+
if(!p){
|
|
111
|
+
const pc=this.createPeerConnection({iceServers:this.ice?.iceServers??[],iceTransportPolicy:this.forceRelay?'relay':'all'});
|
|
112
|
+
p={pc,participant:remote.participant,remoteCount:0,candidates:[],localSent:false,connectedSent:false};this.peers.set(remote.id,p);
|
|
113
|
+
for(const track of this.stream.getTracks()){const sender=pc.addTrack(track.kind==='video'?this.sharing.track()||track:track,this.stream);if(track.kind==='video')await limitSender(sender,this.ice?.videoQuality==='480p'?'480p':this.videoQuality);}
|
|
114
|
+
pc.onicecandidate=e=>{if(e.candidate)p.candidates.push(e.candidate.toJSON?.()??e.candidate);};
|
|
115
|
+
pc.ontrack=e=>{if(epoch!==this.epoch)return;let stream=this.state.remoteStreams[p.participant];if(e.streams?.[0])stream=e.streams[0];else if(!stream)stream=this.createRemoteStream(e.track);else if(!stream.getTracks().some(t=>t.id===e.track.id))stream.addTrack(e.track);this.state.remoteStreams[p.participant]=stream;this.emit();};
|
|
116
|
+
pc.onconnectionstatechange=()=>{if(epoch!==this.epoch)return;if(pc.connectionState==='connected'){this.state.connectedAt??=Date.now();this.emit();}else if(pc.connectionState==='failed'){void this.request('groups/failed',{callId:call.id,peerId:remote.id}).catch(()=>{});this.reset();this.state.call=null;this.state.error='media_failed';this.emit();}};
|
|
117
|
+
}
|
|
118
|
+
const {pc}=p;
|
|
119
|
+
if(remote.offerer&&!pc.localDescription){const offer=await this.rtc(pc.createOffer(),'offer');if(epoch!==this.epoch)return;await this.rtc(pc.setLocalDescription(offer),'local_description');}
|
|
120
|
+
if(remote.remoteDescription&&!pc.remoteDescription){await this.rtc(pc.setRemoteDescription(remote.remoteDescription),'remote_description');if(epoch!==this.epoch)return;if(!remote.offerer){const answer=await this.rtc(pc.createAnswer(),'answer');if(epoch!==this.epoch)return;await this.rtc(pc.setLocalDescription(answer),'local_description');}}
|
|
121
|
+
if(epoch!==this.epoch)return;
|
|
122
|
+
if(pc.localDescription&&!p.localSent){p.description??={type:pc.localDescription.type,sdp:pc.localDescription.sdp};await this.request('groups/description',{callId:call.id,peerId:remote.id,description:p.description});p.localSent=true;}
|
|
123
|
+
await this.flushCandidates(p,call.id,remote.id,epoch);if(epoch!==this.epoch)return;
|
|
124
|
+
if(pc.remoteDescription)while(p.remoteCount<remote.remoteCandidates.length){await this.rtc(pc.addIceCandidate(remote.remoteCandidates[p.remoteCount]),'candidate');if(epoch!==this.epoch)return;p.remoteCount++;}
|
|
125
|
+
if(pc.connectionState==='connected'&&!p.connectedSent){await this.request('groups/connected',{callId:call.id,peerId:remote.id});p.connectedSent=true;}
|
|
126
|
+
}));
|
|
127
|
+
const failure=negotiations.find(result=>result.status==='rejected');if(failure)throw failure.reason;
|
|
128
|
+
this.emit();
|
|
129
|
+
}
|
|
130
|
+
async flushCandidates(peer,callId,peerId,epoch){
|
|
131
|
+
if(peer.sendingCandidates)return peer.sendingCandidates;
|
|
132
|
+
const work=Promise.resolve().then(async()=>{
|
|
133
|
+
while(peer.candidates.length&&epoch===this.epoch&&!this.closed){
|
|
134
|
+
// A bounded batch avoids one network round trip per ICE address. The server
|
|
135
|
+
// deduplicates retries, including a response lost after accepting a candidate.
|
|
136
|
+
const batch=peer.candidates.slice(0,4);
|
|
137
|
+
const results=await Promise.allSettled(batch.map(candidate=>this.request('groups/candidate',{callId,peerId,candidate})));
|
|
138
|
+
if(epoch!==this.epoch||this.closed)return;
|
|
139
|
+
results.forEach((result,index)=>{if(result.status==='fulfilled'){const i=peer.candidates.indexOf(batch[index]);if(i>=0)peer.candidates.splice(i,1);}});
|
|
140
|
+
const failed=results.find(result=>result.status==='rejected');if(failed)throw failed.reason;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
peer.sendingCandidates=work;
|
|
144
|
+
try{return await work;}finally{if(peer.sendingCandidates===work)peer.sendingCandidates=null;}
|
|
145
|
+
}
|
|
146
|
+
async startScreenSharing(){const result=await this.sharing.start();if(result)await this.apply(result);}
|
|
147
|
+
stopScreenSharing(){return this.sharing.stop();}
|
|
148
|
+
async hold(){if(!this.state.call||this.state.localHeld)return;this.state.localHeld=true;this.syncTracks();this.emit();await this.sharing.stop().catch(()=>{});try{await this.request('groups/hold',{callId:this.state.call.id,held:true,revision:++this.holdRevision});this.holdDeadline=this.state.call.holdExpiresAt||Date.now()+300000;}catch(e){await this.leave();throw e;}}
|
|
149
|
+
async resume(prepareAudio){if(!this.state.localHeld)return;await prepareAudio?.();await this.request('groups/hold',{callId:this.state.call.id,held:false,revision:++this.holdRevision});this.lastSuccess=Date.now();this.state.localHeld=false;this.syncTracks();this.emit();}
|
|
150
|
+
async leave(){const call=this.state.call;++this.epoch;this.ended??=new Set();if(call)this.ended.add(call.id);while(this.ended.size>256)this.ended.delete(this.ended.values().next().value);this.reset();this.state.call=null;this.state.error=null;this.emit();if(call)await this.request('groups/leave',{callId:call.id});}
|
|
151
|
+
reset(){clearTimeout(this.ringTimer);this.ringCallId=null;this.sharing.reset();++this.epoch;for(const cancel of this.pendingRTC)cancel();this.pendingRTC.clear();this.holdDeadline=0;for(const p of this.peers.values())p.pc.close();this.peers.clear();this.stream?.getTracks().forEach(t=>t.stop());this.stream=null;Object.assign(this.state,{localStream:null,remoteStreams:{},localHeld:false,connectedAt:null});this.holdRevision=0;}
|
|
152
|
+
async dispose(){this.events?.close();if(this.closed)return;this.closed=true;clearTimeout(this.timer);clearInterval(this.watch);try{await this.leave();}finally{await this.request('close',{}).catch(()=>{});}}
|
|
153
|
+
}
|
package/dist/history.js
CHANGED
|
@@ -18,10 +18,10 @@ export function mergeCallHistory(saved, page) {
|
|
|
18
18
|
return { cursor: page.nextCursor, entries: [...byId.values()].sort((a,b) => a.sequence - b.sequence) };
|
|
19
19
|
}
|
|
20
20
|
export function describeCall(entry) {
|
|
21
|
-
const incoming = entry.role === 'callee';
|
|
22
|
-
if (entry.connectedAt != null) return { label: incoming ?
|
|
21
|
+
const incoming = entry.role === 'callee', kind = entry.kind === 'video' ? 'video' : 'voice';
|
|
22
|
+
if (entry.connectedAt != null) return { label: incoming ? `Incoming ${kind} call` : `Outgoing ${kind} call`,
|
|
23
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 ?
|
|
25
|
-
if (['cancelled', 'no_answer'].includes(entry.reason)) return { label: incoming ?
|
|
24
|
+
if (entry.reason === 'declined') return { label: incoming ? `Declined ${kind} call` : 'Call declined', detail: '', missed: incoming };
|
|
25
|
+
if (['cancelled', 'no_answer'].includes(entry.reason)) return { label: incoming ? `Missed ${kind} call` : entry.reason === 'cancelled' ? `Cancelled ${kind} call` : 'No answer', detail: '', missed: incoming };
|
|
26
26
|
return { label: 'Call couldn’t connect', detail: '', missed: incoming };
|
|
27
27
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
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 }
|
|
1
|
+
export interface CallHistoryEntry { id: string; sequence: number; connectionId: string; kind?: 'voice' | 'video'; role: 'caller' | 'callee'; reason: string; createdAt: number; answeredAt: number | null; connectedAt: number | null; endedAt: number; durationMs: number }
|
|
2
2
|
export interface CallHistoryPage { entries: CallHistoryEntry[]; nextCursor: number; hasMore: boolean }
|
|
3
3
|
export type CallState = 'ringing' | 'connecting' | 'connected' | 'ended';
|
|
4
|
-
export interface Call {
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
export
|
|
4
|
+
export interface Call {
|
|
5
|
+
self?:number;screenSharingAvailable?:boolean;sharing?:boolean[]; id: string; version: number; connectionId: string; kind?: 'voice' | 'video'; 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 }
|
|
6
|
+
export interface Snapshot { listeningStatus?: EventStatus;screenSharing?:boolean;screenSharePending?:boolean;displayStream?:MediaStream|null; voiceAvailable: boolean; videoAvailable?: boolean; cameraEnabled?: 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 }
|
|
7
|
+
export interface EventSubscription { setEnabled(enabled: boolean): void; close(): void }
|
|
8
|
+
export type EventStatus = 'connecting' | 'listening' | 'reconnecting' | 'disconnected' | 'paused' | 'session_expired' | 'access_revoked';
|
|
9
|
+
export interface Request { (operation: string, payload?: Record<string, unknown>): Promise<any>; subscribe?: (callbacks: {onEvent: () => void; onStatus?: (status: EventStatus) => void; onError?: (error: Error & {status?: number}) => void}) => EventSubscription }
|
|
10
|
+
export function createHttpTransport(options: { baseUrl: string; token: string; fetcher?: typeof fetch; events?: boolean; WebSocketImpl?: typeof WebSocket }): Request;
|
|
8
11
|
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 });
|
|
12
|
+
constructor(options: { videoQuality?: '480p' | '720p'; request: Request; onSessionExpired?: (error: Error) => void; onChange?: (state: Snapshot) => void; onLocalStream?: (stream: MediaStream | null) => void; onRemoteStream?: (stream: MediaStream | null) => void; getDisplayMedia?:(context?:{isCurrent:()=>boolean})=>Promise<MediaStream>;getUserMedia?: (context?: { kind?: 'voice' | 'video'; videoQuality?: '480p'|'720p'; isCurrent: () => boolean }) => Promise<MediaStream>; createPeerConnection?: (config: RTCConfiguration) => RTCPeerConnection; pollMs?: number; forceRelay?: boolean; createId?: () => string; createRemoteStream?: (track: MediaStreamTrack) => MediaStream });
|
|
10
13
|
state: Snapshot;
|
|
11
14
|
readonly closed: boolean;
|
|
12
15
|
listen(): Promise<void>;
|
|
13
16
|
refresh(): Promise<boolean>;
|
|
14
|
-
call(connectionId?: string): Promise<void>;
|
|
17
|
+
call(connectionId?: string, options?: {kind?: 'voice' | 'video'}): Promise<void>;
|
|
15
18
|
history(query?: { after?: number; limit?: number; connectionId?: string }): Promise<CallHistoryPage>;
|
|
16
|
-
availability(connectionId: string): Promise<{ voiceAvailable: boolean; reason?: 'ready' | 'calling_not_enabled' | 'contact_unavailable' }>;
|
|
19
|
+
availability(connectionId: string): Promise<{ voiceAvailable: boolean; videoAvailable?: boolean; reason?: 'ready' | 'calling_not_enabled' | 'contact_unavailable'; videoReason?: 'ready' | 'calling_not_enabled' | 'contact_unavailable' }>;
|
|
17
20
|
answer(): Promise<void>;
|
|
18
21
|
hangUp(): Promise<void>;
|
|
19
22
|
hold(): Promise<void>;
|
|
20
23
|
resume(prepareAudio?: () => Promise<void>): Promise<void>;
|
|
24
|
+
setForeground(value: boolean): void;
|
|
25
|
+
setVideoForeground(value: boolean): void;
|
|
26
|
+
startScreenSharing():Promise<void>;stopScreenSharing():Promise<void>;
|
|
27
|
+
setCamera(enabled: boolean): void;
|
|
21
28
|
mute(muted?: boolean): void;
|
|
22
29
|
dispose(): Promise<void>;
|
|
23
30
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {subscribeCallingEvents} from './events.js';
|
|
2
|
+
import {createTransportTelemetry} from './telemetry.js';
|
|
3
|
+
import {mediaProfile,videoConstraints,limitSender} from './media-profile.js';
|
|
4
|
+
import {createScreenSharing} from './sharing.js';
|
|
5
|
+
/** Voice and video calling client. Media and identity adapters are supplied by the host. */
|
|
6
|
+
export function createHttpTransport({ baseUrl, token, fetcher = fetch, events = true, WebSocketImpl = globalThis.WebSocket }) {
|
|
7
|
+
const request = async (operation, payload = {}) => {
|
|
4
8
|
const controller = new AbortController();
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
let timer;
|
|
10
|
+
const work = (async () => {
|
|
7
11
|
const response = await fetcher(`${baseUrl}/${operation}`, {
|
|
8
12
|
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
9
13
|
body: JSON.stringify(payload), signal: controller.signal, redirect: 'error',
|
|
@@ -29,23 +33,36 @@ export function createHttpTransport({ baseUrl, token, fetcher = fetch }) {
|
|
|
29
33
|
throw e;
|
|
30
34
|
}
|
|
31
35
|
return data;
|
|
32
|
-
}
|
|
36
|
+
})();
|
|
37
|
+
// Native fetch cancellation may abort the socket without settling its promise.
|
|
38
|
+
// Bound the complete operation ourselves, including response-body decoding.
|
|
39
|
+
const deadline = new Promise((_, reject) => { timer = setTimeout(() => {
|
|
40
|
+
controller.abort(); reject(Error('request_timeout'));
|
|
41
|
+
}, 4500); });
|
|
42
|
+
try { return await Promise.race([work, deadline]); } finally { clearTimeout(timer); }
|
|
33
43
|
};
|
|
44
|
+
if(events && WebSocketImpl) request.subscribe = callbacks => subscribeCallingEvents({baseUrl,token,WebSocketImpl},callbacks);
|
|
45
|
+
return request;
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
export class RobonoCallingClient {
|
|
37
|
-
constructor({ request, onChange = () => {}, onRemoteStream = () => {},
|
|
38
|
-
getUserMedia =
|
|
49
|
+
constructor({ request, onSessionExpired = () => {}, onChange = () => {}, onRemoteStream = () => {}, onLocalStream = () => {},
|
|
50
|
+
getUserMedia = context => navigator.mediaDevices.getUserMedia({ audio: true, video: context?.kind === 'video' ? videoConstraints(context.videoQuality) : false }),
|
|
51
|
+
videoQuality = '720p',
|
|
39
52
|
createPeerConnection = config => new RTCPeerConnection(config), pollMs = 1000, forceRelay = false, createId = () => crypto.randomUUID(),
|
|
53
|
+
getDisplayMedia = ()=>navigator.mediaDevices.getDisplayMedia({video:true,audio:false}),
|
|
40
54
|
createRemoteStream = track => new MediaStream([track]) } = {}) {
|
|
41
|
-
Object.assign(this, { request, onChange, onRemoteStream, getUserMedia, createPeerConnection, pollMs, forceRelay, createId, createRemoteStream });
|
|
42
|
-
this.
|
|
55
|
+
Object.assign(this, { request, onSessionExpired, onChange, onRemoteStream, onLocalStream, getUserMedia, createPeerConnection, pollMs, forceRelay, createId, createRemoteStream });
|
|
56
|
+
this.videoQuality=mediaProfile(videoQuality).quality;
|
|
57
|
+
this.state = { voiceAvailable: false, call: null, muted: false, cameraEnabled: true, error: null };
|
|
43
58
|
this.pc = null; this.stream = null; this.epoch = 0; this.busy = false; this.closed = false;
|
|
44
59
|
this.lastSuccess = Date.now(); this.started = false; this.remoteCount = 0;
|
|
45
60
|
this.pendingRTC = new Set(); this.mediaTimers = new Set();
|
|
46
61
|
this.locallyEnded = new Set(); this.endRequests = new Map();
|
|
47
62
|
this.expiredRings = new Set();
|
|
48
|
-
this.pollFailures = 0; this.pollNotBefore = 0;
|
|
63
|
+
this.pollFailures = 0; this.pollNotBefore = 0; this.foreground = true;
|
|
64
|
+
this.sharing=createScreenSharing({limitVideo:sender=>limitSender(sender,this.activeVideoQuality??this.videoQuality),request:input=>this.retry('sharing',input),call:()=>this.state.call,peers:()=>this.pc?[this.pc]:[],stream:()=>this.stream,capture:getDisplayMedia,emit:()=>this.emit(),fail:e=>{this.state.error=e.message;this.emit();}});
|
|
65
|
+
this.reportTransport=createTransportTelemetry(request);
|
|
49
66
|
this.localHeld = false; this.holdRevision = 0; this.holdDeadline = 0;
|
|
50
67
|
}
|
|
51
68
|
rtc(promise, phase, timeoutMs = 8000) {
|
|
@@ -58,12 +75,13 @@ export class RobonoCallingClient {
|
|
|
58
75
|
}
|
|
59
76
|
emit() {
|
|
60
77
|
const call = this.state.call;
|
|
78
|
+
if(this.events && call && call.state!=='ended' && !this.timer && !this.polling && !this.closed)this.timer=setTimeout(()=>this.poll(),3000);
|
|
61
79
|
this.scheduleRingDeadline(call);
|
|
62
80
|
// The local WebRTC transport, not a later server poll, determines when this
|
|
63
81
|
// device can communicate. Keep the server snapshot separate for sequencing.
|
|
64
82
|
const connected = this.mediaConnectedAt != null && this.mediaCallId === call?.id &&
|
|
65
83
|
call.controlled && ['connecting', 'connected'].includes(call.state);
|
|
66
|
-
this.onChange({ ...this.state, preparing: this.busy, localHeld: this.localHeld,
|
|
84
|
+
this.onChange({ ...this.state,...this.sharing.state(), preparing: this.busy, localHeld: this.localHeld,
|
|
67
85
|
call: connected ? { ...call, state: this.pc?.connectionState === 'disconnected' ? 'connecting' : 'connected', connectedAt: this.mediaConnectedAt } : call });
|
|
68
86
|
}
|
|
69
87
|
scheduleRingDeadline(call) {
|
|
@@ -96,13 +114,26 @@ export class RobonoCallingClient {
|
|
|
96
114
|
// Explicit authorization failures still stop media immediately in refresh.
|
|
97
115
|
const heldLease = this.localHeld && this.holdDeadline > Date.now();
|
|
98
116
|
const unconfirmedHold = this.pendingHold && Date.now() - this.pendingHold.at > 10000;
|
|
99
|
-
if ((this.pc || this.stream) && (unconfirmedHold || !heldLease && Date.now() - this.lastSuccess > 10000)) {
|
|
117
|
+
if (!this.busy && this.mediaCallId && (this.pc || this.stream) && (unconfirmedHold || !heldLease && Date.now() - this.lastSuccess > 10000)) {
|
|
100
118
|
const id = this.state.call?.id; this.finishLocal('connection_lost');
|
|
101
119
|
if (id) this.request('end', { callId: id }).catch(() => {});
|
|
102
120
|
}
|
|
103
121
|
}, 500);
|
|
122
|
+
if(this.request.subscribe) this.events=this.request.subscribe({
|
|
123
|
+
onEvent:()=>this.wake(), onStatus:status=>{this.state.listeningStatus=status;this.emit();},
|
|
124
|
+
onError:e=>this.sessionError(e),
|
|
125
|
+
});
|
|
104
126
|
await this.poll();
|
|
105
127
|
}
|
|
128
|
+
sessionError(e) {
|
|
129
|
+
const active=this.state.call && this.state.call.state!=='ended';
|
|
130
|
+
this.state.error=active?e.message:null;this.state.listeningStatus=e.status===403?'access_revoked':'session_expired';
|
|
131
|
+
this.resetMedia();this.closed=true;clearTimeout(this.timer);clearInterval(this.watchdog);this.events?.close();this.emit();
|
|
132
|
+
if(e.status===401)this.onSessionExpired(e);
|
|
133
|
+
}
|
|
134
|
+
wake(){if(this.closed)return;clearTimeout(this.timer);if(this.polling||this.busy){this.eventPending=true;return;}void this.poll();}
|
|
135
|
+
setForeground(value){this.foreground=value;this.setVideoForeground(value);this.events?.setEnabled(value||!!this.state.call&&this.state.call.state!=='ended');if(value)this.wake();else if(!this.state.call||this.state.call.state==='ended')clearTimeout(this.timer);}
|
|
136
|
+
|
|
106
137
|
// Refresh without scheduling a second poll loop (also used for native wake races).
|
|
107
138
|
// False means the cached snapshot is not authoritative for this attempt.
|
|
108
139
|
async refresh() {
|
|
@@ -115,14 +146,14 @@ export class RobonoCallingClient {
|
|
|
115
146
|
if (this.closed || Date.now() < this.pollNotBefore) return false;
|
|
116
147
|
const epoch = this.epoch;
|
|
117
148
|
try {
|
|
118
|
-
const state = await this.request('state');
|
|
149
|
+
const state = await this.request('state', {mediaKinds:['voice','video']});
|
|
119
150
|
if (this.closed) return false;
|
|
120
151
|
this.lastSuccess = Date.now();
|
|
121
152
|
this.pollFailures = 0; this.pollNotBefore = 0;
|
|
122
153
|
if (epoch === this.epoch) {
|
|
123
154
|
this.state.error = null;
|
|
124
155
|
if (!this.busy && !(state.version < (this.state.call?.version ?? -1))) {
|
|
125
|
-
this.state.voiceAvailable = state.voiceAvailable;
|
|
156
|
+
this.state.voiceAvailable = state.voiceAvailable; this.state.videoAvailable = state.videoAvailable === true;
|
|
126
157
|
await this.apply(state.call);
|
|
127
158
|
if (this.pendingHold) await this.confirmHold(this.pendingHold);
|
|
128
159
|
return true;
|
|
@@ -134,16 +165,26 @@ export class RobonoCallingClient {
|
|
|
134
165
|
this.pollNotBefore = Date.now() + Math.max(delay, Number.isFinite(e.retryAfterMs) ? e.retryAfterMs : 0);
|
|
135
166
|
// Credential revocations remain authoritative even after a local action.
|
|
136
167
|
if (epoch === this.epoch || e.status === 401 || e.status === 403) {
|
|
137
|
-
this.
|
|
138
|
-
|
|
168
|
+
if(e.status===401||e.status===403){this.sessionError(e);return false;}
|
|
169
|
+
this.state.error = this.state.call && this.state.call.state!=='ended' ? e.message : null;
|
|
139
170
|
this.emit();
|
|
140
171
|
}
|
|
141
172
|
}
|
|
142
173
|
return false;
|
|
143
174
|
}
|
|
144
175
|
async poll() {
|
|
145
|
-
|
|
146
|
-
|
|
176
|
+
if(this.closed||this.polling)return;
|
|
177
|
+
this.polling=true;this.timer=null;
|
|
178
|
+
try{await this.refresh();
|
|
179
|
+
this.reportTransport(this.state.call?.state==='connected'?this.state.call.id:null,this.pc?[['direct',this.pc]]:[]);
|
|
180
|
+
}finally{this.polling=false;}
|
|
181
|
+
if(this.closed)return;
|
|
182
|
+
const active=this.state.call&&this.state.call.state!=='ended';
|
|
183
|
+
if(!this.foreground&&!active){this.events?.setEnabled(false);return;}
|
|
184
|
+
const eventPending=this.eventPending;this.eventPending=false;
|
|
185
|
+
const retry=this.pollFailures>0&&this.pollFailures<6;
|
|
186
|
+
const delay=Math.max(eventPending?0:this.events?3000:this.pollMs,this.pollNotBefore-Date.now());
|
|
187
|
+
if(active||!this.events||retry||eventPending)this.timer=setTimeout(()=>this.poll(),Math.min(2147483647,delay));
|
|
147
188
|
}
|
|
148
189
|
async apply(call) {
|
|
149
190
|
// A pending/late server poll cannot reopen a call the person already ended.
|
|
@@ -168,12 +209,13 @@ export class RobonoCallingClient {
|
|
|
168
209
|
}
|
|
169
210
|
}
|
|
170
211
|
if (wasHeld && !call.remoteHeld && !this.localHeld) this.checkResumedTransport();
|
|
171
|
-
this.syncAudioTracks();
|
|
212
|
+
this.sharing.sync();this.syncAudioTracks();
|
|
172
213
|
this.emit();
|
|
173
214
|
}
|
|
174
215
|
syncAudioTracks() {
|
|
175
216
|
const held = this.localHeld || this.state.call?.localHeld || this.state.call?.remoteHeld;
|
|
176
217
|
this.stream?.getAudioTracks().forEach(t => { t.enabled = !held && !this.state.muted; });
|
|
218
|
+
this.stream?.getVideoTracks().forEach(t => { t.enabled = !held && this.videoForeground !== false && this.state.cameraEnabled; });
|
|
177
219
|
this.remoteStream?.getAudioTracks().forEach(t => { t.enabled = !held; });
|
|
178
220
|
}
|
|
179
221
|
/** Silence immediately; only a server-acknowledged hold relaxes the lease. */
|
|
@@ -181,6 +223,7 @@ export class RobonoCallingClient {
|
|
|
181
223
|
const call = this.state.call;
|
|
182
224
|
if (!call?.controlled || call.state !== 'connected' || !this.pc) throw Error('call_not_connected');
|
|
183
225
|
this.localHeld = true; this.syncAudioTracks(); this.emit();
|
|
226
|
+
await this.sharing.stop().catch(()=>{});
|
|
184
227
|
const revision = this.holdRevision = Math.max(this.holdRevision, call.holdRevision || 0) + 1;
|
|
185
228
|
const intent = this.pendingHold = {callId: call.id, held: true, revision, at: Date.now()};
|
|
186
229
|
await this.confirmHold(intent);
|
|
@@ -224,6 +267,7 @@ export class RobonoCallingClient {
|
|
|
224
267
|
}
|
|
225
268
|
}
|
|
226
269
|
resetMedia() {
|
|
270
|
+
this.sharing.reset();
|
|
227
271
|
this.epoch++;
|
|
228
272
|
for (const timer of this.mediaTimers) clearTimeout(timer); this.mediaTimers.clear();
|
|
229
273
|
for (const cancel of this.pendingRTC) cancel();
|
|
@@ -231,25 +275,31 @@ export class RobonoCallingClient {
|
|
|
231
275
|
if (pc) { pc.onicecandidate = pc.onconnectionstatechange = pc.ontrack = null; pc.close(); }
|
|
232
276
|
this.stream?.getTracks().forEach(t => t.stop()); this.stream = null;
|
|
233
277
|
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);
|
|
278
|
+
this.candidates = []; this.remoteCount = 0; this.mediaCallId = null; this.mediaConnectedAt = null; this.state.muted = false; this.state.cameraEnabled = true;
|
|
279
|
+
this.onRemoteStream(null); this.onLocalStream(null);
|
|
236
280
|
}
|
|
237
|
-
async prepare(connectionId) {
|
|
281
|
+
async prepare(connectionId, kind = 'voice') {
|
|
238
282
|
this.resetMedia(); const epoch = this.epoch;
|
|
239
|
-
const ice = await this.request('ice', connectionId ? { connectionId } : {});
|
|
283
|
+
const ice = await this.request('ice', { ...(connectionId ? { connectionId } : {}), kind });
|
|
240
284
|
if (this.closed || epoch !== this.epoch) throw Error('call_cancelled');
|
|
241
285
|
if (this.forceRelay && !ice.relayAvailable) throw Error('relay_not_configured');
|
|
242
|
-
|
|
286
|
+
this.activeVideoQuality=ice.videoQuality==='480p'?'480p':this.videoQuality;
|
|
287
|
+
const stream = await this.rtc(this.getUserMedia({ kind, videoQuality: ice.videoQuality === '480p' ? '480p' : this.videoQuality, isCurrent: () => !this.closed && epoch === this.epoch }).then(stream => {
|
|
243
288
|
if (this.closed || epoch !== this.epoch) stream.getTracks().forEach(t => t.stop());
|
|
244
289
|
return stream;
|
|
245
290
|
}), 'microphone', 30000);
|
|
246
291
|
if (this.closed || epoch !== this.epoch) { stream.getTracks().forEach(t => t.stop()); throw Error('call_cancelled'); }
|
|
247
|
-
if (
|
|
248
|
-
this.stream = stream;
|
|
292
|
+
if (stream.getAudioTracks().length !== 1 || stream.getVideoTracks().length !== (kind === 'video' ? 1 : 0)) { stream.getTracks().forEach(t => t.stop()); throw Error(kind === 'video' ? 'camera_required' : 'audio_only'); }
|
|
293
|
+
this.stream = stream; this.syncAudioTracks(); this.onLocalStream(stream);
|
|
249
294
|
const pc = this.pc = this.createPeerConnection({ iceServers: ice.iceServers, iceTransportPolicy: this.forceRelay ? 'relay' : 'all' });
|
|
250
295
|
this.candidates = []; this.sendTail = Promise.resolve();
|
|
251
|
-
for (const track of stream.
|
|
252
|
-
pc.ontrack = e => { if (this.pc === pc) {
|
|
296
|
+
for (const track of stream.getTracks()){const sender=pc.addTrack(track, stream);if(track.kind==='video')await limitSender(sender,ice.videoQuality==='480p'?'480p':this.videoQuality);}
|
|
297
|
+
pc.ontrack = e => { if (this.pc === pc) {
|
|
298
|
+
if (e.streams?.[0]) this.remoteStream = e.streams[0];
|
|
299
|
+
else if (!this.remoteStream) this.remoteStream = this.createRemoteStream(e.track);
|
|
300
|
+
else if (!this.remoteStream.getTracks().some(t => t.id === e.track.id)) this.remoteStream.addTrack(e.track);
|
|
301
|
+
this.syncAudioTracks(); this.onRemoteStream(this.remoteStream);
|
|
302
|
+
} };
|
|
253
303
|
pc.onicecandidate = e => {
|
|
254
304
|
if (!e.candidate || this.pc !== pc) return;
|
|
255
305
|
this.candidates.push(e.candidate.toJSON()); this.flushCandidates();
|
|
@@ -304,7 +354,7 @@ export class RobonoCallingClient {
|
|
|
304
354
|
this.busy = true; this.state.error = null; this.emit();
|
|
305
355
|
try { return await fn(); }
|
|
306
356
|
catch (e) { this.mediaError(e); throw e; }
|
|
307
|
-
finally { this.busy = false; this.emit(); }
|
|
357
|
+
finally { this.busy = false; this.emit(); if(this.eventPending){this.eventPending=false;this.wake();} }
|
|
308
358
|
}
|
|
309
359
|
async history(query = {}) { return this.request('history', query); }
|
|
310
360
|
async availability(connectionId) { return this.request('availability', { connectionId }); }
|
|
@@ -312,15 +362,16 @@ export class RobonoCallingClient {
|
|
|
312
362
|
try { return await this.request(operation, payload); }
|
|
313
363
|
catch (e) { if (e.status && e.status < 500) throw e; return this.request(operation, payload); }
|
|
314
364
|
}
|
|
315
|
-
async call(connectionId) {
|
|
365
|
+
async call(connectionId, {kind = 'voice'} = {}) {
|
|
366
|
+
if (!['voice','video'].includes(kind)) throw Error('invalid_call_kind');
|
|
316
367
|
if (this.state.call && this.state.call.state !== 'ended') throw Error('busy');
|
|
317
368
|
return this.action(async () => {
|
|
318
|
-
const pc = await this.prepare(connectionId); const epoch = this.epoch;
|
|
369
|
+
const pc = await this.prepare(connectionId, kind); const epoch = this.epoch;
|
|
319
370
|
await this.rtc(pc.setLocalDescription(await this.rtc(pc.createOffer(), 'create_offer')), 'local_description');
|
|
320
371
|
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 } };
|
|
372
|
+
const payload = { ...(connectionId ? { connectionId } : {}), kind, requestId: this.createId(), description: { type: 'offer', sdp: pc.localDescription.sdp } };
|
|
322
373
|
// Retrying this exact request is safe: the service deduplicates requestId.
|
|
323
|
-
const call = await this.retry('start', payload);
|
|
374
|
+
const call = await this.retry('start', payload); this.lastSuccess = Date.now();
|
|
324
375
|
if (this.epoch !== epoch) { await this.retry('end', { callId: call.id }); return; }
|
|
325
376
|
this.state.call = call; this.mediaCallId = call.id; this.flushCandidates(); this.reportMediaConnection(pc); this.emit();
|
|
326
377
|
});
|
|
@@ -329,11 +380,11 @@ export class RobonoCallingClient {
|
|
|
329
380
|
const call = this.state.call;
|
|
330
381
|
if (!call || call.role !== 'callee' || call.state !== 'ringing' || !call.controlled) throw Error('no_incoming_call');
|
|
331
382
|
return this.action(async () => {
|
|
332
|
-
const pc = await this.prepare(call.connectionId), epoch = this.epoch;
|
|
383
|
+
const pc = await this.prepare(call.connectionId, call.kind || 'voice'), epoch = this.epoch;
|
|
333
384
|
await this.rtc(pc.setRemoteDescription(call.remoteDescription), 'remote_description');
|
|
334
385
|
await this.rtc(pc.setLocalDescription(await this.rtc(pc.createAnswer(), 'create_answer')), 'local_description');
|
|
335
386
|
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 } });
|
|
387
|
+
const result = await this.retry('answer', { callId: call.id, description: { type: 'answer', sdp: pc.localDescription.sdp } }); this.lastSuccess = Date.now();
|
|
337
388
|
if (this.epoch !== epoch) { await this.retry('end', { callId: call.id }); return; }
|
|
338
389
|
this.state.call = result; this.mediaCallId = call.id; this.flushCandidates(); this.reportMediaConnection(pc); await this.apply(result);
|
|
339
390
|
});
|
|
@@ -359,8 +410,16 @@ export class RobonoCallingClient {
|
|
|
359
410
|
}
|
|
360
411
|
this.emit();
|
|
361
412
|
}
|
|
413
|
+
async startScreenSharing(){const result=await this.sharing.start();if(result&&this.state.call?.id===result.id)this.state.call=result;this.emit();}
|
|
414
|
+
stopScreenSharing(){return this.sharing.stop();}
|
|
415
|
+
setVideoForeground(value) { this.videoForeground = value; this.syncAudioTracks(); }
|
|
416
|
+
setCamera(enabled) {
|
|
417
|
+
if (typeof enabled !== 'boolean' || !this.stream?.getVideoTracks().length) return;
|
|
418
|
+
this.state.cameraEnabled = enabled; this.syncAudioTracks(); this.emit();
|
|
419
|
+
}
|
|
362
420
|
mute(muted = true) { this.state.muted = muted; this.syncAudioTracks(); this.emit(); }
|
|
363
421
|
async dispose() {
|
|
422
|
+
this.events?.close();
|
|
364
423
|
this.closed = true; clearTimeout(this.timer); clearInterval(this.watchdog);
|
|
365
424
|
clearTimeout(this.ringTimer);
|
|
366
425
|
await this.hangUp();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Shared capture and per-peer send limits. Resolution is a ceiling, not a promise. */
|
|
2
|
+
export function mediaProfile(quality='720p') {
|
|
3
|
+
if(!['480p','720p'].includes(quality))throw Error('invalid_video_quality');
|
|
4
|
+
return quality==='480p'?{quality,width:854,height:480,frameRate:24,maxBitrate:600000}:{quality,width:1280,height:720,frameRate:24,maxBitrate:1500000};
|
|
5
|
+
}
|
|
6
|
+
export function videoConstraints(quality){const p=mediaProfile(quality);return {facingMode:'user',width:{ideal:p.width,max:p.width},height:{ideal:p.height,max:p.height},frameRate:{ideal:p.frameRate,max:p.frameRate}};}
|
|
7
|
+
export async function limitSender(sender,quality){
|
|
8
|
+
// Some hosts supply a custom transport. A real RTCRtpSender supports these APIs.
|
|
9
|
+
if(!sender?.getParameters||!sender?.setParameters)return;
|
|
10
|
+
const profile=mediaProfile(quality),settings=sender.track?.getSettings?.()??{};
|
|
11
|
+
// Native cameras may select a supported capture size above the requested one.
|
|
12
|
+
// Limit what is transmitted, including portrait and replacement display tracks.
|
|
13
|
+
const width=Number(settings.width)||0,height=Number(settings.height)||0;
|
|
14
|
+
const scale=Math.max(1,Math.max(width,height)/profile.width,Math.min(width,height)/profile.height);
|
|
15
|
+
const p=sender.getParameters();if(!p.encodings?.length)p.encodings=[{}];
|
|
16
|
+
for(const encoding of p.encodings){encoding.maxBitrate=profile.maxBitrate;encoding.maxFramerate=profile.frameRate;encoding.scaleResolutionDownBy=scale;}
|
|
17
|
+
await sender.setParameters(p);
|
|
18
|
+
}
|
package/dist/sharing.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Replaces the existing video track. Microphone routing and call transport stay intact. */
|
|
2
|
+
export function createScreenSharing({request,call,peers,stream,capture,emit,fail,limitVideo=async()=>{}}) {
|
|
3
|
+
let display=null,camera=null,generation=0,revision=0,pending=null,stopping=null,active=false,replaceTail=Promise.resolve();
|
|
4
|
+
const stopTracks=s=>s?.getTracks().forEach(t=>{t.onended=null;t.stop();});
|
|
5
|
+
const state=()=>({screenSharing:active,screenSharePending:!!pending||!!stopping,displayStream:display});
|
|
6
|
+
const replace=track=>{const targets=[...peers()];const work=replaceTail.catch(()=>{}).then(async()=>{for(const pc of targets){const sender=pc.getSenders().find(s=>s.track?.kind==='video');if(!sender)throw Error('video_track_required');await sender.replaceTrack(track);await limitVideo(sender);}});replaceTail=work;return work;};
|
|
7
|
+
const stop=(notify=true)=>{
|
|
8
|
+
if(stopping)return stopping;
|
|
9
|
+
if(!display&&!pending&&!active)return Promise.resolve();
|
|
10
|
+
++generation;const old=display,restore=camera,c=call();display=null;camera=null;active=false;
|
|
11
|
+
stopTracks(old);
|
|
12
|
+
const work=Promise.resolve().then(async()=>{
|
|
13
|
+
// Serialize against an in-flight replacement; never restore over a newer share.
|
|
14
|
+
if(restore&&stream()?.getVideoTracks().includes(restore))try{await replace(restore);}catch(e){fail(e);}
|
|
15
|
+
if(notify&&c?.id&&c.state!=='ended')await request({callId:c.id,sharing:false,revision:++revision});
|
|
16
|
+
}).finally(()=>{if(stopping===work){stopping=null;emit();}});
|
|
17
|
+
stopping=work;emit();return work;
|
|
18
|
+
};
|
|
19
|
+
return {state,
|
|
20
|
+
async start(){
|
|
21
|
+
const c=call();if(pending||stopping||active)throw Error('screen_share_busy');
|
|
22
|
+
if(!c||c.kind!=='video'||c.state!=='connected'||!c.controlled||!c.screenSharingAvailable||c.localHeld||c.remoteHeld)throw Error('screen_share_unavailable');
|
|
23
|
+
if(c.sharing?.some(Boolean))throw Error('screen_already_shared');
|
|
24
|
+
if(!capture)throw Error('screen_capture_adapter_required');
|
|
25
|
+
const epoch=++generation,token={};pending=token;emit();let captured;
|
|
26
|
+
try{
|
|
27
|
+
// Invoked immediately from the person's tap; browsers require user activation.
|
|
28
|
+
captured=await capture({isCurrent:()=>epoch===generation&&call()?.id===c.id});
|
|
29
|
+
if(epoch!==generation||call()?.id!==c.id){stopTracks(captured);throw Error('call_cancelled');}
|
|
30
|
+
const track=captured.getVideoTracks()[0];if(!track||track.readyState==='ended')throw Error('screen_capture_unavailable');
|
|
31
|
+
// Never accidentally include system sound or another microphone track.
|
|
32
|
+
captured.getAudioTracks().forEach(t=>{t.stop();captured.removeTrack(t);});
|
|
33
|
+
const granted=await request({callId:c.id,sharing:true,revision:++revision});
|
|
34
|
+
if(epoch!==generation||call()?.id!==c.id){stopTracks(captured);await request({callId:c.id,sharing:false,revision:++revision});throw Error('call_cancelled');}
|
|
35
|
+
camera=stream()?.getVideoTracks()[0];if(!camera)throw Error('video_track_required');
|
|
36
|
+
display=captured;await replace(track);
|
|
37
|
+
if(epoch!==generation){stopTracks(captured);return;}
|
|
38
|
+
active=true;track.onended=()=>{void stop().catch(fail);};emit();return granted;
|
|
39
|
+
}catch(e){stopTracks(captured);if(epoch===generation)await stop().catch(fail);throw e;}
|
|
40
|
+
finally{if(pending===token){pending=null;emit();}}
|
|
41
|
+
},stop,
|
|
42
|
+
sync(){const c=call();if(active&&(!c?.screenSharingAvailable||!c.sharing?.[c.self]||c.localHeld||c.remoteHeld||c.state==='ended'))void stop(false).catch(fail);},
|
|
43
|
+
reset(){++generation;stopTracks(display);display=camera=null;active=false;pending=stopping=null;revision=0;},
|
|
44
|
+
track(){return active?display?.getVideoTracks()[0]:null;}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Transport counters only. Never sends candidates, addresses, media, or transcripts. */
|
|
2
|
+
export function createTransportTelemetry(request){
|
|
3
|
+
let after=0,busy=false;
|
|
4
|
+
return function report(callId,peers){
|
|
5
|
+
if(!callId||busy||Date.now()<after||!peers.length)return;
|
|
6
|
+
after=Date.now()+15000;busy=true;
|
|
7
|
+
void (async()=>{
|
|
8
|
+
const samples=[];
|
|
9
|
+
for(const [id,pc] of peers){
|
|
10
|
+
if(typeof pc?.getStats!=='function'||pc.connectionState!=='connected')continue;
|
|
11
|
+
let timer;const stats=await Promise.race([pc.getStats(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('stats_timeout')),1000);})]).finally(()=>clearTimeout(timer));
|
|
12
|
+
const rows=[];stats.forEach(r=>rows.push(r));const byId=new Map(rows.map(r=>[r.id,r]));
|
|
13
|
+
const sample={id,sentBytes:0,receivedBytes:0,relaySentBytes:0,relayReceivedBytes:0};
|
|
14
|
+
for(const r of rows)if(r.type==='candidate-pair'&&r.state==='succeeded'){
|
|
15
|
+
const sent=Number.isSafeInteger(r.bytesSent)&&r.bytesSent>=0?r.bytesSent:0,received=Number.isSafeInteger(r.bytesReceived)&&r.bytesReceived>=0?r.bytesReceived:0;
|
|
16
|
+
sample.sentBytes+=sent;sample.receivedBytes+=received;
|
|
17
|
+
if([r.localCandidateId,r.remoteCandidateId].some(key=>byId.get(key)?.candidateType==='relay')){sample.relaySentBytes+=sent;sample.relayReceivedBytes+=received;}
|
|
18
|
+
}
|
|
19
|
+
samples.push(sample);
|
|
20
|
+
}
|
|
21
|
+
if(samples.length)await request('telemetry',{callId,samples});
|
|
22
|
+
})().catch(()=>{}).finally(()=>{busy=false;});
|
|
23
|
+
};
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"provenance": true
|
|
18
18
|
},
|
|
19
19
|
"name": "@robono/calling",
|
|
20
|
-
"version": "0.
|
|
21
|
-
"description": "
|
|
20
|
+
"version": "0.2.0",
|
|
21
|
+
"description": "WebRTC voice, video, group calling and screen sharing within apps and across Robono Bridge networks.",
|
|
22
22
|
"homepage": "https://robono.com/docs#calling",
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"./history": {
|
|
36
36
|
"types": "./dist/history.d.ts",
|
|
37
37
|
"import": "./dist/history.js"
|
|
38
|
+
},
|
|
39
|
+
"./groups": {
|
|
40
|
+
"types": "./dist/groups.d.ts",
|
|
41
|
+
"import": "./dist/groups.js"
|
|
38
42
|
}
|
|
39
43
|
},
|
|
40
44
|
"files": [
|
|
@@ -49,8 +53,7 @@
|
|
|
49
53
|
},
|
|
50
54
|
"scripts": {
|
|
51
55
|
"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",
|
|
56
|
+
"test": "npm run build && node --test test/client.test.mjs test/package.test.mjs test/group-client.test.mjs test/sharing.test.mjs test/telemetry.test.mjs test/events.test.mjs",
|
|
54
57
|
"prepack": "npm run build && node ../../scripts/generate-package-sbom.mjs",
|
|
55
58
|
"prepublishOnly": "npm test"
|
|
56
59
|
}
|