@furious.luke/argus-js 0.2.1 → 0.3.1
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/README.md +31 -2
- package/dist/index.cjs +257 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -3
- package/dist/index.d.ts +66 -3
- package/dist/index.js +257 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -18,9 +18,29 @@ type SignalMessage = {
|
|
|
18
18
|
} | {
|
|
19
19
|
type: "connection_state";
|
|
20
20
|
state: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: "media_stall";
|
|
23
|
+
track: string;
|
|
24
|
+
frame_age_ms: number;
|
|
25
|
+
} | {
|
|
26
|
+
type: "media_resumed";
|
|
27
|
+
track: string;
|
|
28
|
+
duration_ms: number;
|
|
29
|
+
} | {
|
|
30
|
+
type: "media_track_ended";
|
|
31
|
+
track: string;
|
|
32
|
+
reason?: string;
|
|
33
|
+
} | {
|
|
34
|
+
type: "recovery_event";
|
|
35
|
+
event: "recovery_started" | "recovery_retry" | "recovery_failed";
|
|
36
|
+
track: string;
|
|
37
|
+
action?: "sender_restart" | "ice_restart";
|
|
38
|
+
reason?: "capture_ended" | "automatic_recovery_failed";
|
|
21
39
|
} | {
|
|
22
40
|
type: "error";
|
|
23
41
|
error: string;
|
|
42
|
+
} | {
|
|
43
|
+
type: "resumed";
|
|
24
44
|
} | {
|
|
25
45
|
type: "accepted";
|
|
26
46
|
} | {
|
|
@@ -38,10 +58,24 @@ type SignalMessage = {
|
|
|
38
58
|
interface PublisherCallbacks {
|
|
39
59
|
/** Called when the WebRTC peer connection state changes. */
|
|
40
60
|
onConnectionStateChange?: (state: RTCPeerConnectionState) => void;
|
|
41
|
-
/** Called when a fatal error occurs
|
|
61
|
+
/** Called when a fatal error occurs, including when signaling cannot resume before its deadline. */
|
|
42
62
|
onError?: (error: Error) => void;
|
|
43
63
|
/** Called when the browser has successfully connected to the media server. */
|
|
44
64
|
onConnected?: () => void;
|
|
65
|
+
/** Called for each transition in automatic media recovery. */
|
|
66
|
+
onRecoveryStateChange?: (event: PublisherRecoveryEvent) => void;
|
|
67
|
+
/** Called when recovery requires the host application to obtain a new screen share. */
|
|
68
|
+
onRecoveryRequired?: (event: PublisherRecoveryEvent) => void;
|
|
69
|
+
}
|
|
70
|
+
type PublisherRecoveryState = "recovering" | "recovered" | "failed";
|
|
71
|
+
type PublisherRecoveryAction = "sender_restart" | "ice_restart";
|
|
72
|
+
type PublisherRecoveryFailureReason = "capture_ended" | "automatic_recovery_failed";
|
|
73
|
+
/** A transition in the publisher's fixed automatic media-recovery ladder. */
|
|
74
|
+
interface PublisherRecoveryEvent {
|
|
75
|
+
state: PublisherRecoveryState;
|
|
76
|
+
track: string;
|
|
77
|
+
action?: PublisherRecoveryAction;
|
|
78
|
+
reason?: PublisherRecoveryFailureReason;
|
|
45
79
|
}
|
|
46
80
|
/**
|
|
47
81
|
* TURN and read-token info delivered in the gateway `ready` message.
|
|
@@ -51,6 +85,7 @@ interface GatewayReadyInfo {
|
|
|
51
85
|
turn_urls?: string[];
|
|
52
86
|
turn_username?: string;
|
|
53
87
|
turn_credential?: string;
|
|
88
|
+
/** One-hour token used for frame reads and resuming signaling in the selected region. */
|
|
54
89
|
read_token?: string;
|
|
55
90
|
}
|
|
56
91
|
/**
|
|
@@ -63,6 +98,8 @@ interface PublisherOptions {
|
|
|
63
98
|
token: string;
|
|
64
99
|
/** Optional extra ICE servers (e.g. STUN). TURN is supplied by the winning gateway. */
|
|
65
100
|
iceServers?: RTCIceServer[];
|
|
101
|
+
/** How long to retry a dropped signaling connection to the selected gateway. Defaults to 20 seconds. */
|
|
102
|
+
signalingReconnectTimeoutMs?: number;
|
|
66
103
|
/** Callbacks for lifecycle events. */
|
|
67
104
|
callbacks?: PublisherCallbacks;
|
|
68
105
|
}
|
|
@@ -97,8 +134,18 @@ declare class Publisher {
|
|
|
97
134
|
private pendingRemoteCandidates;
|
|
98
135
|
private localStream;
|
|
99
136
|
private readToken;
|
|
137
|
+
private selectedGatewayURL;
|
|
138
|
+
private stopped;
|
|
139
|
+
private reconnecting;
|
|
140
|
+
private reconnectGeneration;
|
|
141
|
+
private resumeSocket;
|
|
142
|
+
private recoveryGeneration;
|
|
143
|
+
private recoveringMedia;
|
|
144
|
+
private recoveryRequired;
|
|
145
|
+
private recoveryAction;
|
|
146
|
+
private trackEndHandlers;
|
|
100
147
|
constructor(opts: PublisherOptions);
|
|
101
|
-
/** The read token
|
|
148
|
+
/** The read token used for frame fetches and signaling resume in the selected region. */
|
|
102
149
|
get frameReadToken(): string | null;
|
|
103
150
|
/**
|
|
104
151
|
* Starts the publisher: races all gateways to find the fastest, completes
|
|
@@ -116,7 +163,23 @@ declare class Publisher {
|
|
|
116
163
|
/** Returns true if the peer connection is in the "connected" state. */
|
|
117
164
|
get isConnected(): boolean;
|
|
118
165
|
private raceGateways;
|
|
166
|
+
private installSignaling;
|
|
167
|
+
private resumeSignaling;
|
|
168
|
+
private openResumeSocket;
|
|
169
|
+
private wait;
|
|
170
|
+
private terminateWithError;
|
|
119
171
|
private handleSignal;
|
|
172
|
+
private beginMediaRecovery;
|
|
173
|
+
private restartSenders;
|
|
174
|
+
private renegotiate;
|
|
175
|
+
private completeMediaRecovery;
|
|
176
|
+
private failMediaRecovery;
|
|
177
|
+
private cancelMediaRecovery;
|
|
178
|
+
private isCurrentRecovery;
|
|
179
|
+
private emitRecoveryTransition;
|
|
180
|
+
private sendRecoveryDiagnostic;
|
|
181
|
+
private watchStreamTracks;
|
|
182
|
+
private unwatchStreamTracks;
|
|
120
183
|
/** Waits for ICE gathering to reach the "complete" state. */
|
|
121
184
|
private gatherComplete;
|
|
122
185
|
}
|
|
@@ -238,4 +301,4 @@ declare function captureCamera(opts?: CaptureCameraOptions): Promise<MediaStream
|
|
|
238
301
|
*/
|
|
239
302
|
declare function captureScreen(opts?: CaptureScreenOptions): Promise<MediaStream>;
|
|
240
303
|
|
|
241
|
-
export { type CaptureCameraOptions, type CaptureScreenOptions, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type SignalMessage, captureCamera, captureScreen };
|
|
304
|
+
export { type CaptureCameraOptions, type CaptureScreenOptions, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type PublisherRecoveryAction, type PublisherRecoveryEvent, type PublisherRecoveryFailureReason, type PublisherRecoveryState, type SignalMessage, captureCamera, captureScreen };
|
package/dist/index.d.ts
CHANGED
|
@@ -18,9 +18,29 @@ type SignalMessage = {
|
|
|
18
18
|
} | {
|
|
19
19
|
type: "connection_state";
|
|
20
20
|
state: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: "media_stall";
|
|
23
|
+
track: string;
|
|
24
|
+
frame_age_ms: number;
|
|
25
|
+
} | {
|
|
26
|
+
type: "media_resumed";
|
|
27
|
+
track: string;
|
|
28
|
+
duration_ms: number;
|
|
29
|
+
} | {
|
|
30
|
+
type: "media_track_ended";
|
|
31
|
+
track: string;
|
|
32
|
+
reason?: string;
|
|
33
|
+
} | {
|
|
34
|
+
type: "recovery_event";
|
|
35
|
+
event: "recovery_started" | "recovery_retry" | "recovery_failed";
|
|
36
|
+
track: string;
|
|
37
|
+
action?: "sender_restart" | "ice_restart";
|
|
38
|
+
reason?: "capture_ended" | "automatic_recovery_failed";
|
|
21
39
|
} | {
|
|
22
40
|
type: "error";
|
|
23
41
|
error: string;
|
|
42
|
+
} | {
|
|
43
|
+
type: "resumed";
|
|
24
44
|
} | {
|
|
25
45
|
type: "accepted";
|
|
26
46
|
} | {
|
|
@@ -38,10 +58,24 @@ type SignalMessage = {
|
|
|
38
58
|
interface PublisherCallbacks {
|
|
39
59
|
/** Called when the WebRTC peer connection state changes. */
|
|
40
60
|
onConnectionStateChange?: (state: RTCPeerConnectionState) => void;
|
|
41
|
-
/** Called when a fatal error occurs
|
|
61
|
+
/** Called when a fatal error occurs, including when signaling cannot resume before its deadline. */
|
|
42
62
|
onError?: (error: Error) => void;
|
|
43
63
|
/** Called when the browser has successfully connected to the media server. */
|
|
44
64
|
onConnected?: () => void;
|
|
65
|
+
/** Called for each transition in automatic media recovery. */
|
|
66
|
+
onRecoveryStateChange?: (event: PublisherRecoveryEvent) => void;
|
|
67
|
+
/** Called when recovery requires the host application to obtain a new screen share. */
|
|
68
|
+
onRecoveryRequired?: (event: PublisherRecoveryEvent) => void;
|
|
69
|
+
}
|
|
70
|
+
type PublisherRecoveryState = "recovering" | "recovered" | "failed";
|
|
71
|
+
type PublisherRecoveryAction = "sender_restart" | "ice_restart";
|
|
72
|
+
type PublisherRecoveryFailureReason = "capture_ended" | "automatic_recovery_failed";
|
|
73
|
+
/** A transition in the publisher's fixed automatic media-recovery ladder. */
|
|
74
|
+
interface PublisherRecoveryEvent {
|
|
75
|
+
state: PublisherRecoveryState;
|
|
76
|
+
track: string;
|
|
77
|
+
action?: PublisherRecoveryAction;
|
|
78
|
+
reason?: PublisherRecoveryFailureReason;
|
|
45
79
|
}
|
|
46
80
|
/**
|
|
47
81
|
* TURN and read-token info delivered in the gateway `ready` message.
|
|
@@ -51,6 +85,7 @@ interface GatewayReadyInfo {
|
|
|
51
85
|
turn_urls?: string[];
|
|
52
86
|
turn_username?: string;
|
|
53
87
|
turn_credential?: string;
|
|
88
|
+
/** One-hour token used for frame reads and resuming signaling in the selected region. */
|
|
54
89
|
read_token?: string;
|
|
55
90
|
}
|
|
56
91
|
/**
|
|
@@ -63,6 +98,8 @@ interface PublisherOptions {
|
|
|
63
98
|
token: string;
|
|
64
99
|
/** Optional extra ICE servers (e.g. STUN). TURN is supplied by the winning gateway. */
|
|
65
100
|
iceServers?: RTCIceServer[];
|
|
101
|
+
/** How long to retry a dropped signaling connection to the selected gateway. Defaults to 20 seconds. */
|
|
102
|
+
signalingReconnectTimeoutMs?: number;
|
|
66
103
|
/** Callbacks for lifecycle events. */
|
|
67
104
|
callbacks?: PublisherCallbacks;
|
|
68
105
|
}
|
|
@@ -97,8 +134,18 @@ declare class Publisher {
|
|
|
97
134
|
private pendingRemoteCandidates;
|
|
98
135
|
private localStream;
|
|
99
136
|
private readToken;
|
|
137
|
+
private selectedGatewayURL;
|
|
138
|
+
private stopped;
|
|
139
|
+
private reconnecting;
|
|
140
|
+
private reconnectGeneration;
|
|
141
|
+
private resumeSocket;
|
|
142
|
+
private recoveryGeneration;
|
|
143
|
+
private recoveringMedia;
|
|
144
|
+
private recoveryRequired;
|
|
145
|
+
private recoveryAction;
|
|
146
|
+
private trackEndHandlers;
|
|
100
147
|
constructor(opts: PublisherOptions);
|
|
101
|
-
/** The read token
|
|
148
|
+
/** The read token used for frame fetches and signaling resume in the selected region. */
|
|
102
149
|
get frameReadToken(): string | null;
|
|
103
150
|
/**
|
|
104
151
|
* Starts the publisher: races all gateways to find the fastest, completes
|
|
@@ -116,7 +163,23 @@ declare class Publisher {
|
|
|
116
163
|
/** Returns true if the peer connection is in the "connected" state. */
|
|
117
164
|
get isConnected(): boolean;
|
|
118
165
|
private raceGateways;
|
|
166
|
+
private installSignaling;
|
|
167
|
+
private resumeSignaling;
|
|
168
|
+
private openResumeSocket;
|
|
169
|
+
private wait;
|
|
170
|
+
private terminateWithError;
|
|
119
171
|
private handleSignal;
|
|
172
|
+
private beginMediaRecovery;
|
|
173
|
+
private restartSenders;
|
|
174
|
+
private renegotiate;
|
|
175
|
+
private completeMediaRecovery;
|
|
176
|
+
private failMediaRecovery;
|
|
177
|
+
private cancelMediaRecovery;
|
|
178
|
+
private isCurrentRecovery;
|
|
179
|
+
private emitRecoveryTransition;
|
|
180
|
+
private sendRecoveryDiagnostic;
|
|
181
|
+
private watchStreamTracks;
|
|
182
|
+
private unwatchStreamTracks;
|
|
120
183
|
/** Waits for ICE gathering to reach the "complete" state. */
|
|
121
184
|
private gatherComplete;
|
|
122
185
|
}
|
|
@@ -238,4 +301,4 @@ declare function captureCamera(opts?: CaptureCameraOptions): Promise<MediaStream
|
|
|
238
301
|
*/
|
|
239
302
|
declare function captureScreen(opts?: CaptureScreenOptions): Promise<MediaStream>;
|
|
240
303
|
|
|
241
|
-
export { type CaptureCameraOptions, type CaptureScreenOptions, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type SignalMessage, captureCamera, captureScreen };
|
|
304
|
+
export { type CaptureCameraOptions, type CaptureScreenOptions, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type PublisherRecoveryAction, type PublisherRecoveryEvent, type PublisherRecoveryFailureReason, type PublisherRecoveryState, type SignalMessage, captureCamera, captureScreen };
|
package/dist/index.js
CHANGED
|
@@ -45,6 +45,12 @@ function parseSignal(data) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// src/publisher.ts
|
|
48
|
+
var defaultSignalingReconnectTimeoutMs = 2e4;
|
|
49
|
+
var signalingResumeAttemptTimeoutMs = 3e3;
|
|
50
|
+
var signalingResumeMaxBackoffMs = 3e3;
|
|
51
|
+
var senderRestartPauseMs = 100;
|
|
52
|
+
var senderRecoveryWaitMs = 4e3;
|
|
53
|
+
var iceRecoveryWaitMs = 8e3;
|
|
48
54
|
var Publisher = class {
|
|
49
55
|
opts;
|
|
50
56
|
sig = null;
|
|
@@ -53,10 +59,20 @@ var Publisher = class {
|
|
|
53
59
|
pendingRemoteCandidates = [];
|
|
54
60
|
localStream = null;
|
|
55
61
|
readToken = null;
|
|
62
|
+
selectedGatewayURL = null;
|
|
63
|
+
stopped = true;
|
|
64
|
+
reconnecting = false;
|
|
65
|
+
reconnectGeneration = 0;
|
|
66
|
+
resumeSocket = null;
|
|
67
|
+
recoveryGeneration = 0;
|
|
68
|
+
recoveringMedia = false;
|
|
69
|
+
recoveryRequired = false;
|
|
70
|
+
recoveryAction = null;
|
|
71
|
+
trackEndHandlers = /* @__PURE__ */ new Map();
|
|
56
72
|
constructor(opts) {
|
|
57
73
|
this.opts = opts;
|
|
58
74
|
}
|
|
59
|
-
/** The read token
|
|
75
|
+
/** The read token used for frame fetches and signaling resume in the selected region. */
|
|
60
76
|
get frameReadToken() {
|
|
61
77
|
return this.readToken;
|
|
62
78
|
}
|
|
@@ -67,8 +83,12 @@ var Publisher = class {
|
|
|
67
83
|
* use onConnected for that).
|
|
68
84
|
*/
|
|
69
85
|
async start(stream) {
|
|
86
|
+
this.stopped = false;
|
|
87
|
+
this.recoveryRequired = false;
|
|
70
88
|
this.localStream = stream;
|
|
71
|
-
|
|
89
|
+
this.watchStreamTracks(stream);
|
|
90
|
+
const { ws, readyInfo, gatewayURL } = await this.raceGateways();
|
|
91
|
+
this.selectedGatewayURL = gatewayURL;
|
|
72
92
|
if (readyInfo.read_token) {
|
|
73
93
|
this.readToken = readyInfo.read_token;
|
|
74
94
|
}
|
|
@@ -103,17 +123,17 @@ var Publisher = class {
|
|
|
103
123
|
const offer = await this.pc.createOffer();
|
|
104
124
|
await this.pc.setLocalDescription(offer);
|
|
105
125
|
await this.gatherComplete();
|
|
106
|
-
|
|
107
|
-
this.sig.onMessage = (msg) => this.handleSignal(msg);
|
|
108
|
-
this.sig.onClose = () => this.opts.callbacks?.onError?.(new Error("signaling closed"));
|
|
109
|
-
this.sig.onError = (err) => this.opts.callbacks?.onError?.(err);
|
|
126
|
+
const signaling = this.installSignaling(ws);
|
|
110
127
|
const local = this.pc.localDescription;
|
|
111
128
|
if (!local) throw new Error("local description missing after gather");
|
|
112
|
-
|
|
129
|
+
signaling.send({ type: "offer", sdp: local.sdp, sdp_type: "offer" });
|
|
113
130
|
}
|
|
114
131
|
/** Replaces the currently published stream with a new one. */
|
|
115
132
|
async replaceStream(stream) {
|
|
116
133
|
if (!this.pc) throw new Error("publisher not started");
|
|
134
|
+
this.cancelMediaRecovery();
|
|
135
|
+
this.recoveryRequired = false;
|
|
136
|
+
this.unwatchStreamTracks();
|
|
117
137
|
const senders = this.pc.getSenders();
|
|
118
138
|
for (const sender of senders) {
|
|
119
139
|
if (sender.track) {
|
|
@@ -124,21 +144,20 @@ var Publisher = class {
|
|
|
124
144
|
this.pc.addTrack(track, stream);
|
|
125
145
|
}
|
|
126
146
|
this.localStream = stream;
|
|
127
|
-
|
|
128
|
-
await this.
|
|
129
|
-
await this.gatherComplete();
|
|
130
|
-
const local = this.pc.localDescription;
|
|
131
|
-
if (!local) throw new Error("local description missing");
|
|
132
|
-
this.sig?.send({
|
|
133
|
-
type: "offer",
|
|
134
|
-
sdp: local.sdp,
|
|
135
|
-
sdp_type: "offer"
|
|
136
|
-
});
|
|
147
|
+
this.watchStreamTracks(stream);
|
|
148
|
+
await this.renegotiate(false);
|
|
137
149
|
}
|
|
138
150
|
/** Stops publishing and tears down the peer connection. */
|
|
139
151
|
stop() {
|
|
152
|
+
this.stopped = true;
|
|
153
|
+
this.cancelMediaRecovery();
|
|
154
|
+
this.reconnectGeneration++;
|
|
155
|
+
this.reconnecting = false;
|
|
156
|
+
this.resumeSocket?.close();
|
|
157
|
+
this.resumeSocket = null;
|
|
140
158
|
this.sig?.close();
|
|
141
159
|
this.sig = null;
|
|
160
|
+
this.unwatchStreamTracks();
|
|
142
161
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
143
162
|
this.localStream = null;
|
|
144
163
|
this.pc?.close();
|
|
@@ -146,6 +165,7 @@ var Publisher = class {
|
|
|
146
165
|
this.hasAnswer = false;
|
|
147
166
|
this.pendingRemoteCandidates = [];
|
|
148
167
|
this.readToken = null;
|
|
168
|
+
this.selectedGatewayURL = null;
|
|
149
169
|
}
|
|
150
170
|
/** Returns the current RTCPeerConnection, or null if not started. */
|
|
151
171
|
get peerConnection() {
|
|
@@ -200,7 +220,7 @@ var Publisher = class {
|
|
|
200
220
|
} else if (accepted && msg.type === "ready") {
|
|
201
221
|
settled = true;
|
|
202
222
|
closeAll(ws);
|
|
203
|
-
resolve({ ws, readyInfo: msg });
|
|
223
|
+
resolve({ ws, readyInfo: msg, gatewayURL });
|
|
204
224
|
}
|
|
205
225
|
} catch {
|
|
206
226
|
}
|
|
@@ -210,6 +230,113 @@ var Publisher = class {
|
|
|
210
230
|
}
|
|
211
231
|
});
|
|
212
232
|
}
|
|
233
|
+
installSignaling(ws) {
|
|
234
|
+
const channel = SignalingChannel.wrap(ws);
|
|
235
|
+
this.sig = channel;
|
|
236
|
+
channel.onMessage = (msg) => this.handleSignal(msg);
|
|
237
|
+
channel.onClose = () => {
|
|
238
|
+
if (this.sig !== channel || this.stopped) return;
|
|
239
|
+
this.sig = null;
|
|
240
|
+
void this.resumeSignaling();
|
|
241
|
+
};
|
|
242
|
+
channel.onError = () => {
|
|
243
|
+
};
|
|
244
|
+
return channel;
|
|
245
|
+
}
|
|
246
|
+
async resumeSignaling() {
|
|
247
|
+
if (this.reconnecting || this.stopped) return;
|
|
248
|
+
if (!this.selectedGatewayURL || !this.readToken) {
|
|
249
|
+
this.terminateWithError(new Error("signaling closed and cannot be resumed"));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
this.reconnecting = true;
|
|
253
|
+
const generation = ++this.reconnectGeneration;
|
|
254
|
+
const timeout = this.opts.signalingReconnectTimeoutMs ?? defaultSignalingReconnectTimeoutMs;
|
|
255
|
+
const deadline = Date.now() + Math.max(0, timeout);
|
|
256
|
+
let backoffMs = 0;
|
|
257
|
+
while (!this.stopped && generation === this.reconnectGeneration && Date.now() <= deadline) {
|
|
258
|
+
if (backoffMs > 0) {
|
|
259
|
+
const waitMs = Math.min(backoffMs, Math.max(0, deadline - Date.now()));
|
|
260
|
+
if (waitMs === 0) break;
|
|
261
|
+
await this.wait(waitMs);
|
|
262
|
+
if (this.stopped || generation !== this.reconnectGeneration) return;
|
|
263
|
+
}
|
|
264
|
+
const remaining = deadline - Date.now();
|
|
265
|
+
if (remaining < 0) break;
|
|
266
|
+
try {
|
|
267
|
+
const ws = await this.openResumeSocket(Math.min(signalingResumeAttemptTimeoutMs, Math.max(1, remaining)));
|
|
268
|
+
if (this.stopped || generation !== this.reconnectGeneration) {
|
|
269
|
+
ws.close();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.resumeSocket = null;
|
|
273
|
+
this.reconnecting = false;
|
|
274
|
+
this.installSignaling(ws);
|
|
275
|
+
return;
|
|
276
|
+
} catch {
|
|
277
|
+
backoffMs = backoffMs === 0 ? 250 : Math.min(backoffMs * 2, signalingResumeMaxBackoffMs);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!this.stopped && generation === this.reconnectGeneration) {
|
|
281
|
+
this.reconnecting = false;
|
|
282
|
+
this.terminateWithError(new Error("unable to resume signaling with the selected gateway"));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
openResumeSocket(timeoutMs) {
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
const u = new URL(this.selectedGatewayURL);
|
|
288
|
+
u.searchParams.set("token", this.readToken);
|
|
289
|
+
const ws = new WebSocket(u.toString());
|
|
290
|
+
this.resumeSocket = ws;
|
|
291
|
+
let settled = false;
|
|
292
|
+
const timer = setTimeout(() => fail(), timeoutMs);
|
|
293
|
+
const fail = () => {
|
|
294
|
+
if (settled) return;
|
|
295
|
+
settled = true;
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
if (this.resumeSocket === ws) this.resumeSocket = null;
|
|
298
|
+
ws.onmessage = null;
|
|
299
|
+
ws.onerror = null;
|
|
300
|
+
ws.onclose = null;
|
|
301
|
+
ws.close();
|
|
302
|
+
reject(new Error("signaling resume attempt failed"));
|
|
303
|
+
};
|
|
304
|
+
ws.onmessage = (ev) => {
|
|
305
|
+
try {
|
|
306
|
+
const msg = JSON.parse(ev.data);
|
|
307
|
+
if (msg.type !== "resumed" || settled) return;
|
|
308
|
+
settled = true;
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
resolve(ws);
|
|
311
|
+
} catch {
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
ws.onerror = fail;
|
|
315
|
+
ws.onclose = fail;
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
wait(ms) {
|
|
319
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
320
|
+
}
|
|
321
|
+
terminateWithError(err) {
|
|
322
|
+
this.stopped = true;
|
|
323
|
+
this.cancelMediaRecovery();
|
|
324
|
+
this.reconnectGeneration++;
|
|
325
|
+
this.resumeSocket?.close();
|
|
326
|
+
this.resumeSocket = null;
|
|
327
|
+
this.sig?.close();
|
|
328
|
+
this.sig = null;
|
|
329
|
+
this.pc?.close();
|
|
330
|
+
this.pc = null;
|
|
331
|
+
this.unwatchStreamTracks();
|
|
332
|
+
this.localStream?.getTracks().forEach((track) => track.stop());
|
|
333
|
+
this.localStream = null;
|
|
334
|
+
this.hasAnswer = false;
|
|
335
|
+
this.pendingRemoteCandidates = [];
|
|
336
|
+
this.readToken = null;
|
|
337
|
+
this.selectedGatewayURL = null;
|
|
338
|
+
this.opts.callbacks?.onError?.(err);
|
|
339
|
+
}
|
|
213
340
|
handleSignal(msg) {
|
|
214
341
|
switch (msg.type) {
|
|
215
342
|
case "answer": {
|
|
@@ -249,11 +376,123 @@ var Publisher = class {
|
|
|
249
376
|
case "connection_state": {
|
|
250
377
|
break;
|
|
251
378
|
}
|
|
379
|
+
case "media_stall":
|
|
380
|
+
case "media_track_ended": {
|
|
381
|
+
void this.beginMediaRecovery(msg.track);
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
case "media_resumed": {
|
|
385
|
+
this.completeMediaRecovery(msg.track);
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
252
388
|
case "error": {
|
|
253
389
|
this.opts.callbacks?.onError?.(new Error(msg.error));
|
|
254
390
|
break;
|
|
255
391
|
}
|
|
392
|
+
case "resumed":
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async beginMediaRecovery(trackType) {
|
|
397
|
+
if (this.stopped || this.recoveringMedia || this.recoveryRequired) return;
|
|
398
|
+
const tracks = this.localStream?.getVideoTracks() ?? [];
|
|
399
|
+
const liveTracks = tracks.filter((track) => track.readyState !== "ended");
|
|
400
|
+
if (liveTracks.length === 0) {
|
|
401
|
+
this.failMediaRecovery(trackType, "capture_ended");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
this.recoveringMedia = true;
|
|
405
|
+
const generation = ++this.recoveryGeneration;
|
|
406
|
+
this.recoveryAction = "sender_restart";
|
|
407
|
+
this.emitRecoveryTransition({ state: "recovering", track: trackType, action: "sender_restart" });
|
|
408
|
+
this.sendRecoveryDiagnostic("recovery_started", trackType, "sender_restart");
|
|
409
|
+
await this.restartSenders(liveTracks, generation);
|
|
410
|
+
if (!this.isCurrentRecovery(generation)) return;
|
|
411
|
+
await this.wait(senderRecoveryWaitMs);
|
|
412
|
+
if (!this.isCurrentRecovery(generation)) return;
|
|
413
|
+
this.recoveryAction = "ice_restart";
|
|
414
|
+
this.emitRecoveryTransition({ state: "recovering", track: trackType, action: "ice_restart" });
|
|
415
|
+
this.sendRecoveryDiagnostic("recovery_retry", trackType, "ice_restart");
|
|
416
|
+
try {
|
|
417
|
+
await this.renegotiate(true);
|
|
418
|
+
} catch {
|
|
419
|
+
}
|
|
420
|
+
await this.wait(iceRecoveryWaitMs);
|
|
421
|
+
if (!this.isCurrentRecovery(generation)) return;
|
|
422
|
+
this.failMediaRecovery(trackType, "automatic_recovery_failed");
|
|
423
|
+
}
|
|
424
|
+
async restartSenders(tracks, generation) {
|
|
425
|
+
const live = new Set(tracks);
|
|
426
|
+
const senders = this.pc?.getSenders().filter(
|
|
427
|
+
(sender) => sender.track && live.has(sender.track)
|
|
428
|
+
) ?? [];
|
|
429
|
+
if (senders.length === 0) return;
|
|
430
|
+
const originals = senders.map((sender) => ({ sender, track: sender.track }));
|
|
431
|
+
try {
|
|
432
|
+
await Promise.all(originals.map(({ sender }) => sender.replaceTrack(null)));
|
|
433
|
+
await this.wait(senderRestartPauseMs);
|
|
434
|
+
if (!this.isCurrentRecovery(generation)) return;
|
|
435
|
+
await Promise.all(originals.map(({ sender, track }) => sender.replaceTrack(track)));
|
|
436
|
+
await this.renegotiate(false);
|
|
437
|
+
} catch {
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async renegotiate(iceRestart) {
|
|
441
|
+
const pc = this.pc;
|
|
442
|
+
const signaling = this.sig;
|
|
443
|
+
if (!pc || !signaling) throw new Error("publisher signaling is unavailable");
|
|
444
|
+
if (iceRestart) pc.restartIce?.();
|
|
445
|
+
const offer = await pc.createOffer(iceRestart ? { iceRestart: true } : void 0);
|
|
446
|
+
await pc.setLocalDescription(offer);
|
|
447
|
+
await this.gatherComplete();
|
|
448
|
+
const local = pc.localDescription;
|
|
449
|
+
if (!local) throw new Error("local description missing");
|
|
450
|
+
this.hasAnswer = false;
|
|
451
|
+
this.pendingRemoteCandidates = [];
|
|
452
|
+
signaling.send({ type: "offer", sdp: local.sdp, sdp_type: "offer" });
|
|
453
|
+
}
|
|
454
|
+
completeMediaRecovery(trackType) {
|
|
455
|
+
if (!this.recoveringMedia) return;
|
|
456
|
+
const action = this.recoveryAction ?? void 0;
|
|
457
|
+
this.cancelMediaRecovery();
|
|
458
|
+
this.emitRecoveryTransition({ state: "recovered", track: trackType, action });
|
|
459
|
+
}
|
|
460
|
+
failMediaRecovery(trackType, reason) {
|
|
461
|
+
if (this.stopped || this.recoveryRequired) return;
|
|
462
|
+
this.recoveryRequired = true;
|
|
463
|
+
const action = this.recoveryAction ?? void 0;
|
|
464
|
+
this.cancelMediaRecovery();
|
|
465
|
+
const event = { state: "failed", track: trackType, action, reason };
|
|
466
|
+
this.emitRecoveryTransition(event);
|
|
467
|
+
this.opts.callbacks?.onRecoveryRequired?.(event);
|
|
468
|
+
this.sendRecoveryDiagnostic("recovery_failed", trackType, action, reason);
|
|
469
|
+
}
|
|
470
|
+
cancelMediaRecovery() {
|
|
471
|
+
this.recoveryGeneration++;
|
|
472
|
+
this.recoveringMedia = false;
|
|
473
|
+
this.recoveryAction = null;
|
|
474
|
+
}
|
|
475
|
+
isCurrentRecovery(generation) {
|
|
476
|
+
return !this.stopped && this.recoveringMedia && generation === this.recoveryGeneration;
|
|
477
|
+
}
|
|
478
|
+
emitRecoveryTransition(event) {
|
|
479
|
+
this.opts.callbacks?.onRecoveryStateChange?.(event);
|
|
480
|
+
}
|
|
481
|
+
sendRecoveryDiagnostic(event, track, action, reason) {
|
|
482
|
+
this.sig?.send({ type: "recovery_event", event, track, action, reason });
|
|
483
|
+
}
|
|
484
|
+
watchStreamTracks(stream) {
|
|
485
|
+
for (const track of stream.getVideoTracks()) {
|
|
486
|
+
const handler = () => this.failMediaRecovery("screen", "capture_ended");
|
|
487
|
+
track.addEventListener("ended", handler);
|
|
488
|
+
this.trackEndHandlers.set(track, handler);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
unwatchStreamTracks() {
|
|
492
|
+
for (const [track, handler] of this.trackEndHandlers) {
|
|
493
|
+
track.removeEventListener("ended", handler);
|
|
256
494
|
}
|
|
495
|
+
this.trackEndHandlers.clear();
|
|
257
496
|
}
|
|
258
497
|
/** Waits for ICE gathering to reach the "complete" state. */
|
|
259
498
|
gatherComplete() {
|