@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 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 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.
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.1.2",
6
- "documentNamespace": "http://spdx.org/spdxdocs/@robono%2fcalling-0.1.2-***",
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-25T10:32:11.266Z",
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.1.2"
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.1.2",
20
- "versionInfo": "0.1.2",
19
+ "SPDXID": "SPDXRef-Package-robono.calling-0.2.0",
20
+ "versionInfo": "0.2.0",
21
21
  "packageFileName": "",
22
- "description": "One-to-one WebRTC voice calling for connected Robono Bridge networks.",
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.1.2"
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.1.2",
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
+ }
@@ -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 ? 'Incoming voice call' : 'Outgoing voice call',
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 ? '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 };
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 { 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;
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
- /** 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 = {}) => {
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
- const timer = setTimeout(() => controller.abort(), 4500);
6
- try {
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
- } finally { clearTimeout(timer); }
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 = () => navigator.mediaDevices.getUserMedia({ audio: true, video: false }),
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.state = { voiceAvailable: false, call: null, muted: false, error: null };
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.state.error = e.message;
138
- if (e.status === 401 || e.status === 403) { this.resetMedia(); this.closed = true; clearInterval(this.watchdog); }
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
- await this.refresh();
146
- if (!this.closed) this.timer = setTimeout(() => this.poll(), Math.min(2147483647, Math.max(this.pollMs, this.pollNotBefore - Date.now())));
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
- const stream = await this.rtc(this.getUserMedia({ isCurrent: () => !this.closed && epoch === this.epoch }).then(stream => {
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 (!stream.getAudioTracks().length || stream.getVideoTracks().length) { stream.getTracks().forEach(t => t.stop()); throw Error('audio_only'); }
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.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); } };
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
+ }
@@ -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.1.2",
21
- "description": "One-to-one WebRTC voice calling for connected Robono Bridge networks.",
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
  }