@strevokit/meeting-sdk 0.0.0-stage → 0.8.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 +96 -0
- package/LICENSE +215 -0
- package/README.md +173 -2
- package/dist/index.d.ts +249 -0
- package/dist/index.js +3935 -0
- package/dist/index.js.map +7 -0
- package/docs/api-reference.md +361 -0
- package/docs/backend-api.md +370 -0
- package/docs/examples/backend-integration.md +103 -0
- package/docs/examples/react.tsx +144 -0
- package/docs/examples/vanilla.html +101 -0
- package/package.json +62 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,3935 @@
|
|
|
1
|
+
// src/internal/env.ts
|
|
2
|
+
function subscribeNetworkChange(callback) {
|
|
3
|
+
const scope = globalThis;
|
|
4
|
+
const targets = [];
|
|
5
|
+
if (typeof scope.addEventListener === "function") {
|
|
6
|
+
targets.push({ target: globalThis, type: "online" });
|
|
7
|
+
}
|
|
8
|
+
const connection = scope.navigator?.connection;
|
|
9
|
+
if (connection && typeof connection.addEventListener === "function" && typeof connection.removeEventListener === "function") {
|
|
10
|
+
targets.push({ target: connection, type: "change" });
|
|
11
|
+
}
|
|
12
|
+
for (const { target, type } of targets) target.addEventListener(type, callback);
|
|
13
|
+
return () => {
|
|
14
|
+
for (const { target, type } of targets) target.removeEventListener(type, callback);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
var override;
|
|
18
|
+
function defaultEnvironment() {
|
|
19
|
+
const scope = globalThis;
|
|
20
|
+
return {
|
|
21
|
+
WebSocket: typeof scope.WebSocket === "function" ? scope.WebSocket : void 0,
|
|
22
|
+
mediaDevices: scope.navigator?.mediaDevices,
|
|
23
|
+
RTCPeerConnection: typeof scope.RTCPeerConnection === "function" ? scope.RTCPeerConnection : void 0,
|
|
24
|
+
MediaStream: typeof scope.MediaStream === "function" ? scope.MediaStream : void 0,
|
|
25
|
+
setTimeout: (callback, ms) => globalThis.setTimeout(callback, ms),
|
|
26
|
+
clearTimeout: (handle) => globalThis.clearTimeout(handle),
|
|
27
|
+
now: () => Date.now(),
|
|
28
|
+
onNetworkChange: subscribeNetworkChange
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function currentEnvironment() {
|
|
32
|
+
return override ?? defaultEnvironment();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ../signaling-protocol/dist/version.js
|
|
36
|
+
var PROTOCOL_VERSION = 1;
|
|
37
|
+
var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze([1]);
|
|
38
|
+
function isSupportedVersion(value) {
|
|
39
|
+
return typeof value === "number" && Number.isInteger(value) && SUPPORTED_PROTOCOL_VERSIONS.includes(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ../signaling-protocol/dist/limits.js
|
|
43
|
+
var MAX_MESSAGE_BYTES = 65536;
|
|
44
|
+
var MAX_SDP_BYTES = 49152;
|
|
45
|
+
var MAX_ICE_CANDIDATE_LENGTH = 1024;
|
|
46
|
+
var MAX_DISPLAY_NAME_CODE_POINTS = 255;
|
|
47
|
+
var MAX_REASON_LENGTH = 64;
|
|
48
|
+
var MAX_SDP_MID_LENGTH = 64;
|
|
49
|
+
var MAX_USERNAME_FRAGMENT_LENGTH = 256;
|
|
50
|
+
var MAX_SDP_MLINE_INDEX = 1023;
|
|
51
|
+
var MAX_ICE_SERVERS = 4;
|
|
52
|
+
var MAX_ICE_SERVER_URLS = 4;
|
|
53
|
+
var MAX_ICE_SERVER_URL_LENGTH = 256;
|
|
54
|
+
var MAX_ICE_SERVER_USERNAME_LENGTH = 256;
|
|
55
|
+
var MAX_ICE_SERVER_CREDENTIAL_LENGTH = 256;
|
|
56
|
+
var MAX_QUALITY_CODEC_LENGTH = 64;
|
|
57
|
+
var MAX_QUALITY_CANDIDATE_TYPE_LENGTH = 32;
|
|
58
|
+
var MAX_QUALITY_FRAME_DIMENSION = 16384;
|
|
59
|
+
|
|
60
|
+
// ../signaling-protocol/dist/rejection.js
|
|
61
|
+
var REJECTION_CODES = Object.freeze([
|
|
62
|
+
"invalid_envelope",
|
|
63
|
+
"unsupported_version",
|
|
64
|
+
"unknown_type",
|
|
65
|
+
"direction_not_allowed",
|
|
66
|
+
"invalid_payload"
|
|
67
|
+
]);
|
|
68
|
+
var REJECTION_DETAILS = Object.freeze([
|
|
69
|
+
"message_too_large",
|
|
70
|
+
"not_text",
|
|
71
|
+
"invalid_json",
|
|
72
|
+
"not_an_object",
|
|
73
|
+
"invalid_ulid",
|
|
74
|
+
"payload_not_object",
|
|
75
|
+
"missing_field",
|
|
76
|
+
"wrong_type",
|
|
77
|
+
"too_long",
|
|
78
|
+
"out_of_range",
|
|
79
|
+
"invalid_format"
|
|
80
|
+
]);
|
|
81
|
+
var CODE_MESSAGES = {
|
|
82
|
+
invalid_envelope: "Message envelope is invalid.",
|
|
83
|
+
unsupported_version: "Protocol version is missing or not supported.",
|
|
84
|
+
unknown_type: "Message type is not part of the protocol.",
|
|
85
|
+
direction_not_allowed: "Message type is not allowed in this direction.",
|
|
86
|
+
invalid_payload: "Message payload is invalid."
|
|
87
|
+
};
|
|
88
|
+
var DETAIL_MESSAGES = {
|
|
89
|
+
message_too_large: "Message exceeds the maximum size.",
|
|
90
|
+
not_text: "Message frame must be UTF-8 text.",
|
|
91
|
+
invalid_json: "Message frame is not valid JSON.",
|
|
92
|
+
not_an_object: "Message must be a JSON object.",
|
|
93
|
+
invalid_ulid: "Identifier must be a canonical ULID.",
|
|
94
|
+
payload_not_object: "Payload must be a JSON object.",
|
|
95
|
+
missing_field: "A required field is missing.",
|
|
96
|
+
wrong_type: "A field has the wrong type.",
|
|
97
|
+
too_long: "A field is too long.",
|
|
98
|
+
out_of_range: "A field is out of range.",
|
|
99
|
+
invalid_format: "A field has an invalid format."
|
|
100
|
+
};
|
|
101
|
+
function reject(code, detail, field) {
|
|
102
|
+
const rejection = {
|
|
103
|
+
code,
|
|
104
|
+
message: detail ? DETAIL_MESSAGES[detail] : CODE_MESSAGES[code]
|
|
105
|
+
};
|
|
106
|
+
if (field !== void 0)
|
|
107
|
+
rejection.field = field;
|
|
108
|
+
if (detail !== void 0)
|
|
109
|
+
rejection.detail = detail;
|
|
110
|
+
return rejection;
|
|
111
|
+
}
|
|
112
|
+
var SignalingProtocolError = class extends Error {
|
|
113
|
+
rejection;
|
|
114
|
+
constructor(rejection) {
|
|
115
|
+
super(`${rejection.code}: ${rejection.message}`);
|
|
116
|
+
this.name = "SignalingProtocolError";
|
|
117
|
+
this.rejection = rejection;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ../signaling-protocol/dist/primitives.js
|
|
122
|
+
var ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
123
|
+
var REASON_CODE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
|
124
|
+
var TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?Z$/;
|
|
125
|
+
function isCanonicalUlid(value) {
|
|
126
|
+
return typeof value === "string" && ULID_PATTERN.test(value);
|
|
127
|
+
}
|
|
128
|
+
function utf8ByteLength(value) {
|
|
129
|
+
let bytes = 0;
|
|
130
|
+
for (let i = 0; i < value.length; i++) {
|
|
131
|
+
const unit = value.charCodeAt(i);
|
|
132
|
+
if (unit < 128) {
|
|
133
|
+
bytes += 1;
|
|
134
|
+
} else if (unit < 2048) {
|
|
135
|
+
bytes += 2;
|
|
136
|
+
} else if (unit >= 55296 && unit <= 56319 && i + 1 < value.length) {
|
|
137
|
+
const next = value.charCodeAt(i + 1);
|
|
138
|
+
if (next >= 56320 && next <= 57343) {
|
|
139
|
+
bytes += 4;
|
|
140
|
+
i++;
|
|
141
|
+
} else {
|
|
142
|
+
bytes += 3;
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
bytes += 3;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return bytes;
|
|
149
|
+
}
|
|
150
|
+
function codePointLength(value) {
|
|
151
|
+
let count = 0;
|
|
152
|
+
for (let i = 0; i < value.length; i++) {
|
|
153
|
+
const unit = value.charCodeAt(i);
|
|
154
|
+
if (unit >= 55296 && unit <= 56319 && i + 1 < value.length) {
|
|
155
|
+
const next = value.charCodeAt(i + 1);
|
|
156
|
+
if (next >= 56320 && next <= 57343)
|
|
157
|
+
i++;
|
|
158
|
+
}
|
|
159
|
+
count++;
|
|
160
|
+
}
|
|
161
|
+
return count;
|
|
162
|
+
}
|
|
163
|
+
function isReasonCode(value) {
|
|
164
|
+
return typeof value === "string" && REASON_CODE_PATTERN.test(value);
|
|
165
|
+
}
|
|
166
|
+
function isRfc3339UtcTimestamp(value) {
|
|
167
|
+
if (typeof value !== "string")
|
|
168
|
+
return false;
|
|
169
|
+
const match = TIMESTAMP_PATTERN.exec(value);
|
|
170
|
+
if (!match)
|
|
171
|
+
return false;
|
|
172
|
+
const [year, month, day, hour, minute, second] = match.slice(1, 7).map((part) => Number(part));
|
|
173
|
+
if (month < 1 || month > 12 || day < 1)
|
|
174
|
+
return false;
|
|
175
|
+
if (hour > 23 || minute > 59 || second > 59)
|
|
176
|
+
return false;
|
|
177
|
+
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
178
|
+
return day <= daysInMonth;
|
|
179
|
+
}
|
|
180
|
+
function isPlainObject(value) {
|
|
181
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const proto = Object.getPrototypeOf(value);
|
|
185
|
+
return proto === Object.prototype || proto === null;
|
|
186
|
+
}
|
|
187
|
+
function readOwn(obj, key) {
|
|
188
|
+
return Object.hasOwn(obj, key) ? obj[key] : void 0;
|
|
189
|
+
}
|
|
190
|
+
function hasOwn(obj, key) {
|
|
191
|
+
return Object.hasOwn(obj, key);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ../signaling-protocol/dist/catalog.js
|
|
195
|
+
var CATALOG = {
|
|
196
|
+
"participant.join": { family: "participant", direction: "client_to_server" },
|
|
197
|
+
"participant.joined": { family: "participant", direction: "server_to_client" },
|
|
198
|
+
"participant.leave": { family: "participant", direction: "client_to_server" },
|
|
199
|
+
"participant.left": { family: "participant", direction: "server_to_client" },
|
|
200
|
+
"participant.reconnected": { family: "participant", direction: "server_to_client" },
|
|
201
|
+
"webrtc.offer": { family: "webrtc", direction: "both" },
|
|
202
|
+
"webrtc.answer": { family: "webrtc", direction: "both" },
|
|
203
|
+
"webrtc.ice_candidate": { family: "webrtc", direction: "both" },
|
|
204
|
+
"webrtc.connected": { family: "webrtc", direction: "client_to_server" },
|
|
205
|
+
"webrtc.disconnected": { family: "webrtc", direction: "client_to_server" },
|
|
206
|
+
"webrtc.failed": { family: "webrtc", direction: "client_to_server" },
|
|
207
|
+
"webrtc.quality_sample": { family: "webrtc", direction: "client_to_server" },
|
|
208
|
+
"webrtc.ice_restart": { family: "webrtc", direction: "both" },
|
|
209
|
+
"webrtc.ice_restart_result": { family: "webrtc", direction: "client_to_server" },
|
|
210
|
+
"media.camera.started": { family: "media", direction: "both" },
|
|
211
|
+
"media.camera.stopped": { family: "media", direction: "both" },
|
|
212
|
+
"media.microphone.started": { family: "media", direction: "both" },
|
|
213
|
+
"media.microphone.stopped": { family: "media", direction: "both" },
|
|
214
|
+
"media.screen.started": { family: "media", direction: "both" },
|
|
215
|
+
"media.screen.stopped": { family: "media", direction: "both" },
|
|
216
|
+
"meeting.started": { family: "meeting", direction: "server_to_client" },
|
|
217
|
+
"meeting.ended": { family: "meeting", direction: "server_to_client" },
|
|
218
|
+
"meeting.terminated": { family: "meeting", direction: "server_to_client" },
|
|
219
|
+
"meeting.end": { family: "meeting", direction: "client_to_server" },
|
|
220
|
+
"session.authenticated": { family: "session", direction: "server_to_client" },
|
|
221
|
+
"session.welcome": { family: "session", direction: "server_to_client" },
|
|
222
|
+
"session.rejected": { family: "session", direction: "server_to_client" },
|
|
223
|
+
"session.ice_servers": { family: "session", direction: "server_to_client" }
|
|
224
|
+
};
|
|
225
|
+
for (const entry of Object.values(CATALOG))
|
|
226
|
+
Object.freeze(entry);
|
|
227
|
+
var MESSAGE_CATALOG = Object.freeze(CATALOG);
|
|
228
|
+
var MESSAGE_TYPES = Object.freeze(Object.keys(CATALOG));
|
|
229
|
+
function isMessageType(value) {
|
|
230
|
+
return typeof value === "string" && Object.hasOwn(CATALOG, value);
|
|
231
|
+
}
|
|
232
|
+
function isDirectionAllowed(type, direction) {
|
|
233
|
+
const allowed = MESSAGE_CATALOG[type].direction;
|
|
234
|
+
return allowed === "both" || allowed === direction;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ../signaling-protocol/dist/rolePolicy.js
|
|
238
|
+
var ALL_ROLES = Object.freeze(["host", "participant"]);
|
|
239
|
+
var ROLE_POLICY = Object.freeze({
|
|
240
|
+
"media.screen.started": Object.freeze(["host"]),
|
|
241
|
+
"media.screen.stopped": Object.freeze(["host"]),
|
|
242
|
+
"meeting.end": Object.freeze(["host"])
|
|
243
|
+
});
|
|
244
|
+
function allowedRoles(type) {
|
|
245
|
+
return ROLE_POLICY[type] ?? ALL_ROLES;
|
|
246
|
+
}
|
|
247
|
+
function isRoleAllowed(type, role2) {
|
|
248
|
+
return allowedRoles(type).includes(role2);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ../signaling-protocol/dist/payloads.js
|
|
252
|
+
var PayloadFailure = class {
|
|
253
|
+
rejection;
|
|
254
|
+
constructor(rejection) {
|
|
255
|
+
this.rejection = rejection;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
function fail(detail, path) {
|
|
259
|
+
throw new PayloadFailure(reject("invalid_payload", detail, path));
|
|
260
|
+
}
|
|
261
|
+
function required(obj, key, path) {
|
|
262
|
+
if (!hasOwn(obj, key))
|
|
263
|
+
fail("missing_field", path);
|
|
264
|
+
return obj[key];
|
|
265
|
+
}
|
|
266
|
+
function requiredString(obj, key, path) {
|
|
267
|
+
const value = required(obj, key, path);
|
|
268
|
+
if (typeof value !== "string")
|
|
269
|
+
fail("wrong_type", path);
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
function participantRef(obj, key, path, ctx, allowSelf) {
|
|
273
|
+
const value = requiredString(obj, key, path);
|
|
274
|
+
if (!isCanonicalUlid(value))
|
|
275
|
+
fail("invalid_ulid", path);
|
|
276
|
+
if (!allowSelf && value === ctx.participantId)
|
|
277
|
+
fail("invalid_format", path);
|
|
278
|
+
return value;
|
|
279
|
+
}
|
|
280
|
+
function reasonCode(obj, key, path) {
|
|
281
|
+
const value = requiredString(obj, key, path);
|
|
282
|
+
if (value.length > MAX_REASON_LENGTH)
|
|
283
|
+
fail("too_long", path);
|
|
284
|
+
if (!isReasonCode(value))
|
|
285
|
+
fail("invalid_format", path);
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
function timestamp(obj, key, path) {
|
|
289
|
+
const value = requiredString(obj, key, path);
|
|
290
|
+
if (!isRfc3339UtcTimestamp(value))
|
|
291
|
+
fail("invalid_format", path);
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
function sdp(obj) {
|
|
295
|
+
const path = "payload.sdp";
|
|
296
|
+
const value = requiredString(obj, "sdp", path);
|
|
297
|
+
if (value.length === 0)
|
|
298
|
+
fail("invalid_format", path);
|
|
299
|
+
if (value.length > MAX_SDP_BYTES)
|
|
300
|
+
fail("too_long", path);
|
|
301
|
+
if (value.length * 3 > MAX_SDP_BYTES && utf8ByteLength(value) > MAX_SDP_BYTES) {
|
|
302
|
+
fail("too_long", path);
|
|
303
|
+
}
|
|
304
|
+
return value;
|
|
305
|
+
}
|
|
306
|
+
function nullableString(obj, key, path, maxLength) {
|
|
307
|
+
const value = obj[key];
|
|
308
|
+
if (value === null)
|
|
309
|
+
return null;
|
|
310
|
+
if (typeof value !== "string")
|
|
311
|
+
fail("wrong_type", path);
|
|
312
|
+
if (value.length > maxLength)
|
|
313
|
+
fail("too_long", path);
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
function iceCandidate(obj) {
|
|
317
|
+
const base = "payload.candidate";
|
|
318
|
+
const raw = required(obj, "candidate", base);
|
|
319
|
+
if (!isPlainObject(raw))
|
|
320
|
+
fail("wrong_type", base);
|
|
321
|
+
const candidate = requiredString(raw, "candidate", `${base}.candidate`);
|
|
322
|
+
if (candidate.length > MAX_ICE_CANDIDATE_LENGTH)
|
|
323
|
+
fail("too_long", `${base}.candidate`);
|
|
324
|
+
required(raw, "sdpMid", `${base}.sdpMid`);
|
|
325
|
+
const sdpMid = nullableString(raw, "sdpMid", `${base}.sdpMid`, MAX_SDP_MID_LENGTH);
|
|
326
|
+
const indexPath = `${base}.sdpMLineIndex`;
|
|
327
|
+
const index = required(raw, "sdpMLineIndex", indexPath);
|
|
328
|
+
let sdpMLineIndex = null;
|
|
329
|
+
if (index !== null) {
|
|
330
|
+
if (typeof index !== "number")
|
|
331
|
+
fail("wrong_type", indexPath);
|
|
332
|
+
if (!Number.isInteger(index) || index < 0 || index > MAX_SDP_MLINE_INDEX) {
|
|
333
|
+
fail("out_of_range", indexPath);
|
|
334
|
+
}
|
|
335
|
+
sdpMLineIndex = index;
|
|
336
|
+
}
|
|
337
|
+
if (candidate.length > 0 && sdpMid === null && sdpMLineIndex === null) {
|
|
338
|
+
fail("invalid_format", base);
|
|
339
|
+
}
|
|
340
|
+
const value = { candidate, sdpMid, sdpMLineIndex };
|
|
341
|
+
if (hasOwn(raw, "usernameFragment")) {
|
|
342
|
+
value.usernameFragment = nullableString(raw, "usernameFragment", `${base}.usernameFragment`, MAX_USERNAME_FRAGMENT_LENGTH);
|
|
343
|
+
}
|
|
344
|
+
return value;
|
|
345
|
+
}
|
|
346
|
+
var empty = () => ({});
|
|
347
|
+
var sessionDescription = (p, ctx) => ({
|
|
348
|
+
targetParticipantId: participantRef(p, "targetParticipantId", "payload.targetParticipantId", ctx, false),
|
|
349
|
+
sdp: sdp(p)
|
|
350
|
+
});
|
|
351
|
+
var peer = (p, ctx) => ({
|
|
352
|
+
peerParticipantId: participantRef(p, "peerParticipantId", "payload.peerParticipantId", ctx, false)
|
|
353
|
+
});
|
|
354
|
+
function role(obj) {
|
|
355
|
+
const value = requiredString(obj, "role", "payload.role");
|
|
356
|
+
if (value !== "host" && value !== "participant")
|
|
357
|
+
fail("invalid_format", "payload.role");
|
|
358
|
+
return value;
|
|
359
|
+
}
|
|
360
|
+
function optionalDisplayName(p, value) {
|
|
361
|
+
if (hasOwn(p, "displayName")) {
|
|
362
|
+
const path = "payload.displayName";
|
|
363
|
+
const displayName = p.displayName;
|
|
364
|
+
if (typeof displayName !== "string")
|
|
365
|
+
fail("wrong_type", path);
|
|
366
|
+
if (displayName.length === 0)
|
|
367
|
+
fail("invalid_format", path);
|
|
368
|
+
if (codePointLength(displayName) > MAX_DISPLAY_NAME_CODE_POINTS)
|
|
369
|
+
fail("too_long", path);
|
|
370
|
+
value.displayName = displayName;
|
|
371
|
+
}
|
|
372
|
+
return value;
|
|
373
|
+
}
|
|
374
|
+
var ICE_SERVER_URL_SCHEMES = ["stun:", "stuns:", "turn:", "turns:"];
|
|
375
|
+
function optionalBoundedString(obj, key, path, maxLength, value) {
|
|
376
|
+
if (!hasOwn(obj, key))
|
|
377
|
+
return;
|
|
378
|
+
const raw = obj[key];
|
|
379
|
+
if (typeof raw !== "string")
|
|
380
|
+
fail("wrong_type", path);
|
|
381
|
+
if (raw.length > maxLength)
|
|
382
|
+
fail("too_long", path);
|
|
383
|
+
value[key] = raw;
|
|
384
|
+
}
|
|
385
|
+
function iceServer(raw, path) {
|
|
386
|
+
if (!isPlainObject(raw))
|
|
387
|
+
fail("wrong_type", path);
|
|
388
|
+
const urlsPath = `${path}.urls`;
|
|
389
|
+
const urls = required(raw, "urls", urlsPath);
|
|
390
|
+
if (!Array.isArray(urls))
|
|
391
|
+
fail("wrong_type", urlsPath);
|
|
392
|
+
if (urls.length === 0 || urls.length > MAX_ICE_SERVER_URLS)
|
|
393
|
+
fail("out_of_range", urlsPath);
|
|
394
|
+
const checked = urls.map((url, index) => {
|
|
395
|
+
const urlPath = `${urlsPath}.${index}`;
|
|
396
|
+
if (typeof url !== "string")
|
|
397
|
+
fail("wrong_type", urlPath);
|
|
398
|
+
if (url.length > MAX_ICE_SERVER_URL_LENGTH)
|
|
399
|
+
fail("too_long", urlPath);
|
|
400
|
+
if (!ICE_SERVER_URL_SCHEMES.some((scheme) => url.startsWith(scheme) && url.length > scheme.length)) {
|
|
401
|
+
fail("invalid_format", urlPath);
|
|
402
|
+
}
|
|
403
|
+
return url;
|
|
404
|
+
});
|
|
405
|
+
const value = { urls: checked };
|
|
406
|
+
optionalBoundedString(raw, "username", `${path}.username`, MAX_ICE_SERVER_USERNAME_LENGTH, value);
|
|
407
|
+
optionalBoundedString(raw, "credential", `${path}.credential`, MAX_ICE_SERVER_CREDENTIAL_LENGTH, value);
|
|
408
|
+
return value;
|
|
409
|
+
}
|
|
410
|
+
function iceServers(obj) {
|
|
411
|
+
const path = "payload.iceServers";
|
|
412
|
+
const servers = required(obj, "iceServers", path);
|
|
413
|
+
if (!Array.isArray(servers))
|
|
414
|
+
fail("wrong_type", path);
|
|
415
|
+
if (servers.length > MAX_ICE_SERVERS)
|
|
416
|
+
fail("too_long", path);
|
|
417
|
+
return servers.map((server, index) => iceServer(server, `${path}.${index}`));
|
|
418
|
+
}
|
|
419
|
+
function sessionWelcome(p) {
|
|
420
|
+
const value = optionalDisplayName(p, {
|
|
421
|
+
role: role(p),
|
|
422
|
+
joinedAt: timestamp(p, "joinedAt", "payload.joinedAt")
|
|
423
|
+
});
|
|
424
|
+
if (hasOwn(p, "mediaMode")) {
|
|
425
|
+
const path = "payload.mediaMode";
|
|
426
|
+
const mediaMode = p.mediaMode;
|
|
427
|
+
if (typeof mediaMode !== "string")
|
|
428
|
+
fail("wrong_type", path);
|
|
429
|
+
if (mediaMode !== "p2p" && mediaMode !== "sfu")
|
|
430
|
+
fail("invalid_format", path);
|
|
431
|
+
value.mediaMode = mediaMode;
|
|
432
|
+
}
|
|
433
|
+
if (hasOwn(p, "iceServers"))
|
|
434
|
+
value.iceServers = iceServers(p);
|
|
435
|
+
if (hasOwn(p, "iceServersExpireAt")) {
|
|
436
|
+
value.iceServersExpireAt = timestamp(p, "iceServersExpireAt", "payload.iceServersExpireAt");
|
|
437
|
+
}
|
|
438
|
+
return value;
|
|
439
|
+
}
|
|
440
|
+
function optionalNonNegativeNumber(obj, key, path, value) {
|
|
441
|
+
if (!hasOwn(obj, key))
|
|
442
|
+
return;
|
|
443
|
+
const raw = obj[key];
|
|
444
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
445
|
+
fail("wrong_type", path);
|
|
446
|
+
if (raw < 0)
|
|
447
|
+
fail("out_of_range", path);
|
|
448
|
+
value[key] = raw;
|
|
449
|
+
}
|
|
450
|
+
function optionalUnitInterval(obj, key, path, value) {
|
|
451
|
+
if (!hasOwn(obj, key))
|
|
452
|
+
return;
|
|
453
|
+
const raw = obj[key];
|
|
454
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
455
|
+
fail("wrong_type", path);
|
|
456
|
+
if (raw < 0 || raw > 1)
|
|
457
|
+
fail("out_of_range", path);
|
|
458
|
+
value[key] = raw;
|
|
459
|
+
}
|
|
460
|
+
function optionalNonNegativeInt(obj, key, path, value) {
|
|
461
|
+
if (!hasOwn(obj, key))
|
|
462
|
+
return;
|
|
463
|
+
const raw = obj[key];
|
|
464
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
465
|
+
fail("wrong_type", path);
|
|
466
|
+
if (!Number.isInteger(raw) || raw < 0)
|
|
467
|
+
fail("out_of_range", path);
|
|
468
|
+
value[key] = raw;
|
|
469
|
+
}
|
|
470
|
+
function optionalPositiveInt(obj, key, path, max, value) {
|
|
471
|
+
if (!hasOwn(obj, key))
|
|
472
|
+
return;
|
|
473
|
+
const raw = obj[key];
|
|
474
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
475
|
+
fail("wrong_type", path);
|
|
476
|
+
if (!Number.isInteger(raw) || raw < 1 || raw > max)
|
|
477
|
+
fail("out_of_range", path);
|
|
478
|
+
value[key] = raw;
|
|
479
|
+
}
|
|
480
|
+
function looksLikeSdpOrCandidateDump(value) {
|
|
481
|
+
if (value.includes("\r\n") || value.includes("\n"))
|
|
482
|
+
return true;
|
|
483
|
+
if (/^v=0/i.test(value))
|
|
484
|
+
return true;
|
|
485
|
+
if (/^candidate:/i.test(value))
|
|
486
|
+
return true;
|
|
487
|
+
if (/\bIN IP[46]\b/i.test(value))
|
|
488
|
+
return true;
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
function optionalShortToken(obj, key, path, maxLength, value) {
|
|
492
|
+
if (!hasOwn(obj, key))
|
|
493
|
+
return;
|
|
494
|
+
const raw = obj[key];
|
|
495
|
+
if (typeof raw !== "string")
|
|
496
|
+
fail("wrong_type", path);
|
|
497
|
+
if (raw.length === 0 || raw.length > maxLength)
|
|
498
|
+
fail(raw.length === 0 ? "invalid_format" : "too_long", path);
|
|
499
|
+
if (looksLikeSdpOrCandidateDump(raw))
|
|
500
|
+
fail("invalid_format", path);
|
|
501
|
+
if (!/^[a-zA-Z0-9._+/-]+$/.test(raw))
|
|
502
|
+
fail("invalid_format", path);
|
|
503
|
+
value[key] = raw;
|
|
504
|
+
}
|
|
505
|
+
var qualitySample = (p, ctx) => {
|
|
506
|
+
const value = {
|
|
507
|
+
...peer(p, ctx),
|
|
508
|
+
connectionState: reasonCode(p, "connectionState", "payload.connectionState"),
|
|
509
|
+
sampledAt: timestamp(p, "sampledAt", "payload.sampledAt")
|
|
510
|
+
};
|
|
511
|
+
optionalNonNegativeNumber(p, "rttMs", "payload.rttMs", value);
|
|
512
|
+
optionalNonNegativeNumber(p, "jitterMs", "payload.jitterMs", value);
|
|
513
|
+
optionalUnitInterval(p, "packetLossRatio", "payload.packetLossRatio", value);
|
|
514
|
+
optionalNonNegativeNumber(p, "sendBitrateBps", "payload.sendBitrateBps", value);
|
|
515
|
+
optionalNonNegativeNumber(p, "receiveBitrateBps", "payload.receiveBitrateBps", value);
|
|
516
|
+
optionalShortToken(p, "iceLocalCandidateType", "payload.iceLocalCandidateType", MAX_QUALITY_CANDIDATE_TYPE_LENGTH, value);
|
|
517
|
+
optionalShortToken(p, "iceRemoteCandidateType", "payload.iceRemoteCandidateType", MAX_QUALITY_CANDIDATE_TYPE_LENGTH, value);
|
|
518
|
+
for (const key of ["path", "localPath", "remotePath"]) {
|
|
519
|
+
if (hasOwn(p, key))
|
|
520
|
+
value[key] = reasonCode(p, key, `payload.${key}`);
|
|
521
|
+
}
|
|
522
|
+
optionalNonNegativeInt(p, "framesDropped", "payload.framesDropped", value);
|
|
523
|
+
optionalPositiveInt(p, "frameWidth", "payload.frameWidth", MAX_QUALITY_FRAME_DIMENSION, value);
|
|
524
|
+
optionalPositiveInt(p, "frameHeight", "payload.frameHeight", MAX_QUALITY_FRAME_DIMENSION, value);
|
|
525
|
+
optionalShortToken(p, "codec", "payload.codec", MAX_QUALITY_CODEC_LENGTH, value);
|
|
526
|
+
if (hasOwn(p, "incomplete")) {
|
|
527
|
+
if (typeof p.incomplete !== "boolean")
|
|
528
|
+
fail("wrong_type", "payload.incomplete");
|
|
529
|
+
value.incomplete = p.incomplete;
|
|
530
|
+
}
|
|
531
|
+
return value;
|
|
532
|
+
};
|
|
533
|
+
var VALIDATORS = {
|
|
534
|
+
"participant.join": empty,
|
|
535
|
+
"participant.joined": (p) => optionalDisplayName(p, { role: role(p) }),
|
|
536
|
+
"participant.leave": empty,
|
|
537
|
+
"participant.left": (p) => ({ reason: reasonCode(p, "reason", "payload.reason") }),
|
|
538
|
+
"participant.reconnected": empty,
|
|
539
|
+
"webrtc.offer": sessionDescription,
|
|
540
|
+
"webrtc.answer": sessionDescription,
|
|
541
|
+
"webrtc.ice_candidate": (p, ctx) => ({
|
|
542
|
+
targetParticipantId: participantRef(p, "targetParticipantId", "payload.targetParticipantId", ctx, false),
|
|
543
|
+
candidate: iceCandidate(p)
|
|
544
|
+
}),
|
|
545
|
+
"webrtc.connected": (p, ctx) => {
|
|
546
|
+
const value = peer(p, ctx);
|
|
547
|
+
for (const key of ["path", "localPath", "remotePath"]) {
|
|
548
|
+
if (hasOwn(p, key))
|
|
549
|
+
value[key] = reasonCode(p, key, `payload.${key}`);
|
|
550
|
+
}
|
|
551
|
+
return value;
|
|
552
|
+
},
|
|
553
|
+
"webrtc.disconnected": peer,
|
|
554
|
+
"webrtc.failed": (p, ctx) => ({
|
|
555
|
+
...peer(p, ctx),
|
|
556
|
+
reason: reasonCode(p, "reason", "payload.reason")
|
|
557
|
+
}),
|
|
558
|
+
"webrtc.quality_sample": qualitySample,
|
|
559
|
+
"webrtc.ice_restart": (p, ctx) => ({
|
|
560
|
+
targetParticipantId: participantRef(p, "targetParticipantId", "payload.targetParticipantId", ctx, false)
|
|
561
|
+
}),
|
|
562
|
+
"webrtc.ice_restart_result": (p, ctx) => ({
|
|
563
|
+
...peer(p, ctx),
|
|
564
|
+
outcome: reasonCode(p, "outcome", "payload.outcome")
|
|
565
|
+
}),
|
|
566
|
+
"media.camera.started": empty,
|
|
567
|
+
"media.camera.stopped": empty,
|
|
568
|
+
"media.microphone.started": empty,
|
|
569
|
+
"media.microphone.stopped": empty,
|
|
570
|
+
"media.screen.started": empty,
|
|
571
|
+
"media.screen.stopped": empty,
|
|
572
|
+
"meeting.started": (p) => ({ startedAt: timestamp(p, "startedAt", "payload.startedAt") }),
|
|
573
|
+
"meeting.ended": (p, ctx) => {
|
|
574
|
+
const value = { endedAt: timestamp(p, "endedAt", "payload.endedAt") };
|
|
575
|
+
if (hasOwn(p, "endedByParticipantId")) {
|
|
576
|
+
value.endedByParticipantId = participantRef(p, "endedByParticipantId", "payload.endedByParticipantId", ctx, true);
|
|
577
|
+
}
|
|
578
|
+
return value;
|
|
579
|
+
},
|
|
580
|
+
"meeting.terminated": (p) => ({
|
|
581
|
+
terminatedAt: timestamp(p, "terminatedAt", "payload.terminatedAt"),
|
|
582
|
+
reason: reasonCode(p, "reason", "payload.reason")
|
|
583
|
+
}),
|
|
584
|
+
"meeting.end": empty,
|
|
585
|
+
"session.authenticated": (p) => ({ role: role(p) }),
|
|
586
|
+
"session.welcome": sessionWelcome,
|
|
587
|
+
"session.rejected": (p) => {
|
|
588
|
+
const value = { code: reasonCode(p, "code", "payload.code") };
|
|
589
|
+
if (hasOwn(p, "detail"))
|
|
590
|
+
value.detail = reasonCode(p, "detail", "payload.detail");
|
|
591
|
+
return value;
|
|
592
|
+
},
|
|
593
|
+
"session.ice_servers": (p) => ({
|
|
594
|
+
iceServers: iceServers(p),
|
|
595
|
+
expiresAt: timestamp(p, "expiresAt", "payload.expiresAt")
|
|
596
|
+
})
|
|
597
|
+
};
|
|
598
|
+
function validatePayload(type, payload, ctx) {
|
|
599
|
+
try {
|
|
600
|
+
return { ok: true, value: VALIDATORS[type](payload, ctx) };
|
|
601
|
+
} catch (error) {
|
|
602
|
+
if (error instanceof PayloadFailure)
|
|
603
|
+
return { ok: false, rejection: error.rejection };
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ../signaling-protocol/dist/parse.js
|
|
609
|
+
function failure(error) {
|
|
610
|
+
return { ok: false, error };
|
|
611
|
+
}
|
|
612
|
+
function resolveDirection(options) {
|
|
613
|
+
const direction = options?.direction;
|
|
614
|
+
return direction === "client_to_server" || direction === "server_to_client" ? direction : void 0;
|
|
615
|
+
}
|
|
616
|
+
function parseValue(input, direction) {
|
|
617
|
+
if (!isPlainObject(input))
|
|
618
|
+
return failure(reject("invalid_envelope", "not_an_object"));
|
|
619
|
+
if (!isSupportedVersion(readOwn(input, "version"))) {
|
|
620
|
+
return failure(reject("unsupported_version", void 0, "version"));
|
|
621
|
+
}
|
|
622
|
+
const type = readOwn(input, "type");
|
|
623
|
+
if (!isMessageType(type))
|
|
624
|
+
return failure(reject("unknown_type", void 0, "type"));
|
|
625
|
+
if (direction !== void 0 && !isDirectionAllowed(type, direction)) {
|
|
626
|
+
return failure(reject("direction_not_allowed", void 0, "type"));
|
|
627
|
+
}
|
|
628
|
+
const meetingId = readOwn(input, "meetingId");
|
|
629
|
+
if (!isCanonicalUlid(meetingId)) {
|
|
630
|
+
return failure(reject("invalid_envelope", "invalid_ulid", "meetingId"));
|
|
631
|
+
}
|
|
632
|
+
const participantId = readOwn(input, "participantId");
|
|
633
|
+
if (!isCanonicalUlid(participantId)) {
|
|
634
|
+
return failure(reject("invalid_envelope", "invalid_ulid", "participantId"));
|
|
635
|
+
}
|
|
636
|
+
const payload = readOwn(input, "payload");
|
|
637
|
+
if (!isPlainObject(payload)) {
|
|
638
|
+
return failure(reject("invalid_envelope", "payload_not_object", "payload"));
|
|
639
|
+
}
|
|
640
|
+
const result = validatePayload(type, payload, { participantId });
|
|
641
|
+
if (!result.ok)
|
|
642
|
+
return failure(result.rejection);
|
|
643
|
+
const message = {
|
|
644
|
+
version: PROTOCOL_VERSION,
|
|
645
|
+
type,
|
|
646
|
+
meetingId,
|
|
647
|
+
participantId,
|
|
648
|
+
payload: result.value
|
|
649
|
+
};
|
|
650
|
+
return { ok: true, message };
|
|
651
|
+
}
|
|
652
|
+
function parseSignalingMessage(input, options) {
|
|
653
|
+
try {
|
|
654
|
+
return parseValue(input, resolveDirection(options));
|
|
655
|
+
} catch {
|
|
656
|
+
return failure(reject("invalid_envelope"));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function exceedsMaxMessageBytes(frame) {
|
|
660
|
+
if (frame.length > MAX_MESSAGE_BYTES)
|
|
661
|
+
return true;
|
|
662
|
+
if (frame.length * 3 <= MAX_MESSAGE_BYTES)
|
|
663
|
+
return false;
|
|
664
|
+
return utf8ByteLength(frame) > MAX_MESSAGE_BYTES;
|
|
665
|
+
}
|
|
666
|
+
function decodeSignalingFrame(frame, options) {
|
|
667
|
+
try {
|
|
668
|
+
if (typeof frame !== "string")
|
|
669
|
+
return failure(reject("invalid_envelope", "not_text"));
|
|
670
|
+
if (exceedsMaxMessageBytes(frame)) {
|
|
671
|
+
return failure(reject("invalid_envelope", "message_too_large"));
|
|
672
|
+
}
|
|
673
|
+
let value;
|
|
674
|
+
try {
|
|
675
|
+
value = JSON.parse(frame);
|
|
676
|
+
} catch {
|
|
677
|
+
return failure(reject("invalid_envelope", "invalid_json"));
|
|
678
|
+
}
|
|
679
|
+
return parseValue(value, resolveDirection(options));
|
|
680
|
+
} catch {
|
|
681
|
+
return failure(reject("invalid_envelope"));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ../signaling-protocol/dist/build.js
|
|
686
|
+
function createSignalingMessage(type, fields) {
|
|
687
|
+
const result = parseSignalingMessage({
|
|
688
|
+
version: PROTOCOL_VERSION,
|
|
689
|
+
type,
|
|
690
|
+
meetingId: fields.meetingId,
|
|
691
|
+
participantId: fields.participantId,
|
|
692
|
+
payload: fields.payload
|
|
693
|
+
});
|
|
694
|
+
if (!result.ok)
|
|
695
|
+
throw new SignalingProtocolError(result.error);
|
|
696
|
+
return result.message;
|
|
697
|
+
}
|
|
698
|
+
function encodeSignalingMessage(message) {
|
|
699
|
+
const result = parseSignalingMessage(message);
|
|
700
|
+
if (!result.ok)
|
|
701
|
+
throw new SignalingProtocolError(result.error);
|
|
702
|
+
const frame = JSON.stringify(result.message);
|
|
703
|
+
if (utf8ByteLength(frame) > MAX_MESSAGE_BYTES) {
|
|
704
|
+
throw new SignalingProtocolError(reject("invalid_envelope", "message_too_large"));
|
|
705
|
+
}
|
|
706
|
+
return frame;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// src/public/errors.ts
|
|
710
|
+
var ERROR_INFO = Object.freeze({
|
|
711
|
+
join_token_invalid: { message: "This meeting link is not valid. Ask for a new one.", retryable: false },
|
|
712
|
+
join_token_expired: { message: "This meeting link has expired. Ask for a new one.", retryable: true },
|
|
713
|
+
origin_not_allowed: { message: "This site is not allowed to join this meeting.", retryable: false },
|
|
714
|
+
connection_failed: {
|
|
715
|
+
message: "Could not connect to the meeting. Check your connection and try again.",
|
|
716
|
+
retryable: true
|
|
717
|
+
},
|
|
718
|
+
join_timeout: { message: "Joining the meeting took too long. Please try again.", retryable: true },
|
|
719
|
+
join_cancelled: { message: "Joining was cancelled.", retryable: false },
|
|
720
|
+
connection_lost: { message: "The connection to the meeting was lost.", retryable: true },
|
|
721
|
+
session_replaced: { message: "You joined this meeting from another tab or device.", retryable: false },
|
|
722
|
+
service_unavailable: {
|
|
723
|
+
message: "The meeting service is temporarily unavailable. Please try again shortly.",
|
|
724
|
+
retryable: true
|
|
725
|
+
},
|
|
726
|
+
rate_limited: { message: "Too many attempts. Please wait a moment and try again.", retryable: true },
|
|
727
|
+
meeting_ended: { message: "This meeting has ended.", retryable: false },
|
|
728
|
+
protocol_error: { message: "Something went wrong in the meeting connection.", retryable: false },
|
|
729
|
+
already_joined: { message: "This meeting is already joined or joining.", retryable: false },
|
|
730
|
+
engine_closed: {
|
|
731
|
+
message: "This meeting session has ended. Create a new one to join again.",
|
|
732
|
+
retryable: false
|
|
733
|
+
},
|
|
734
|
+
not_joined: { message: "Join the meeting first.", retryable: false },
|
|
735
|
+
not_permitted: { message: "You don't have permission to do that in this meeting.", retryable: false },
|
|
736
|
+
permission_denied: {
|
|
737
|
+
message: "Camera or microphone access was blocked. Allow access in your browser settings.",
|
|
738
|
+
retryable: false
|
|
739
|
+
},
|
|
740
|
+
device_not_found: { message: "No camera or microphone was found.", retryable: false },
|
|
741
|
+
device_unavailable: {
|
|
742
|
+
message: "Your camera or microphone is being used by another application.",
|
|
743
|
+
retryable: true
|
|
744
|
+
},
|
|
745
|
+
screen_share_cancelled: { message: "Screen sharing was cancelled.", retryable: false },
|
|
746
|
+
unsupported_environment: {
|
|
747
|
+
message: "This browser does not support meetings. Try a current version of Chrome, Edge, Firefox, or Safari.",
|
|
748
|
+
retryable: false
|
|
749
|
+
},
|
|
750
|
+
media_connection_failed: {
|
|
751
|
+
message: "Audio and video couldn't connect to the other participant. Check your network and try rejoining.",
|
|
752
|
+
retryable: true
|
|
753
|
+
},
|
|
754
|
+
media_participant_limit: {
|
|
755
|
+
message: "Audio and video are available with one other participant in this meeting.",
|
|
756
|
+
retryable: false
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
var MeetingError = class extends Error {
|
|
760
|
+
name = "MeetingError";
|
|
761
|
+
/** Stable machine-readable code. */
|
|
762
|
+
code;
|
|
763
|
+
/** True when retrying the same action later (with a fresh token for join errors) may succeed. */
|
|
764
|
+
retryable;
|
|
765
|
+
constructor(code) {
|
|
766
|
+
const info = ERROR_INFO[code];
|
|
767
|
+
super(info.message);
|
|
768
|
+
this.code = code;
|
|
769
|
+
this.retryable = info.retryable;
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// src/internal/emitter.ts
|
|
774
|
+
var Emitter = class {
|
|
775
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
776
|
+
#log;
|
|
777
|
+
constructor(log) {
|
|
778
|
+
this.#log = log;
|
|
779
|
+
}
|
|
780
|
+
on(type, listener) {
|
|
781
|
+
let set = this.#listeners.get(type);
|
|
782
|
+
if (!set) {
|
|
783
|
+
set = /* @__PURE__ */ new Set();
|
|
784
|
+
this.#listeners.set(type, set);
|
|
785
|
+
}
|
|
786
|
+
const entry = listener;
|
|
787
|
+
set.add(entry);
|
|
788
|
+
return () => {
|
|
789
|
+
this.#listeners.get(type)?.delete(entry);
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
emit(type, event) {
|
|
793
|
+
const set = this.#listeners.get(type);
|
|
794
|
+
if (!set || set.size === 0) return;
|
|
795
|
+
for (const listener of [...set]) {
|
|
796
|
+
try {
|
|
797
|
+
listener(event);
|
|
798
|
+
} catch {
|
|
799
|
+
this.#log.warn("event listener threw", { messageType: String(type) });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
clear() {
|
|
804
|
+
this.#listeners.clear();
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
// src/internal/errorMapping.ts
|
|
809
|
+
var BEFORE_JOINED = {
|
|
810
|
+
1001: "service_unavailable",
|
|
811
|
+
4001: "join_token_invalid",
|
|
812
|
+
4002: "join_token_expired",
|
|
813
|
+
4003: "origin_not_allowed",
|
|
814
|
+
4004: "meeting_ended",
|
|
815
|
+
4008: "join_timeout",
|
|
816
|
+
4009: "session_replaced",
|
|
817
|
+
4011: "protocol_error",
|
|
818
|
+
4029: "rate_limited",
|
|
819
|
+
4503: "service_unavailable"
|
|
820
|
+
};
|
|
821
|
+
var AFTER_JOINED = {
|
|
822
|
+
1e3: ["left", void 0],
|
|
823
|
+
1001: ["error", "service_unavailable"],
|
|
824
|
+
4001: ["error", "join_token_invalid"],
|
|
825
|
+
4002: ["error", "join_token_expired"],
|
|
826
|
+
4003: ["error", "origin_not_allowed"],
|
|
827
|
+
4004: ["meeting_ended", void 0],
|
|
828
|
+
4009: ["session_replaced", "session_replaced"],
|
|
829
|
+
4011: ["error", "protocol_error"],
|
|
830
|
+
4029: ["error", "rate_limited"],
|
|
831
|
+
4503: ["error", "service_unavailable"]
|
|
832
|
+
};
|
|
833
|
+
function mapClose(code, phase) {
|
|
834
|
+
if (phase === "before_joined") {
|
|
835
|
+
return { reason: "join_failed", error: new MeetingError(BEFORE_JOINED[code] ?? "connection_failed") };
|
|
836
|
+
}
|
|
837
|
+
const [reason, errorCode] = AFTER_JOINED[code] ?? ["connection_lost", "connection_lost"];
|
|
838
|
+
return errorCode ? { reason, error: new MeetingError(errorCode) } : { reason };
|
|
839
|
+
}
|
|
840
|
+
var REJECTIONS = {
|
|
841
|
+
rate_limited: "rate_limited",
|
|
842
|
+
service_unavailable: "service_unavailable",
|
|
843
|
+
role_not_allowed: "not_permitted"
|
|
844
|
+
};
|
|
845
|
+
function mapRejection(code) {
|
|
846
|
+
return new MeetingError(Object.hasOwn(REJECTIONS, code) ? REJECTIONS[code] : "protocol_error");
|
|
847
|
+
}
|
|
848
|
+
var LEFT_REASONS = /* @__PURE__ */ new Set(["left", "disconnected", "removed", "meeting_ended"]);
|
|
849
|
+
function mapLeftReason(reason) {
|
|
850
|
+
return LEFT_REASONS.has(reason) ? reason : "other";
|
|
851
|
+
}
|
|
852
|
+
var DEVICE_ERRORS = {
|
|
853
|
+
NotAllowedError: "permission_denied",
|
|
854
|
+
SecurityError: "permission_denied",
|
|
855
|
+
NotFoundError: "device_not_found",
|
|
856
|
+
OverconstrainedError: "device_not_found",
|
|
857
|
+
NotReadableError: "device_unavailable",
|
|
858
|
+
AbortError: "device_unavailable",
|
|
859
|
+
TypeError: "unsupported_environment"
|
|
860
|
+
};
|
|
861
|
+
var SCREEN_ERRORS = {
|
|
862
|
+
NotAllowedError: "screen_share_cancelled",
|
|
863
|
+
AbortError: "screen_share_cancelled",
|
|
864
|
+
SecurityError: "permission_denied",
|
|
865
|
+
TypeError: "unsupported_environment"
|
|
866
|
+
};
|
|
867
|
+
function errorName(error) {
|
|
868
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
869
|
+
const name = error.name;
|
|
870
|
+
return typeof name === "string" ? name : void 0;
|
|
871
|
+
}
|
|
872
|
+
function mapMediaError(kind, error) {
|
|
873
|
+
if (error instanceof MeetingError) return error;
|
|
874
|
+
const name = errorName(error);
|
|
875
|
+
const table = kind === "screen" ? SCREEN_ERRORS : DEVICE_ERRORS;
|
|
876
|
+
const code = name !== void 0 && Object.hasOwn(table, name) ? table[name] : void 0;
|
|
877
|
+
return new MeetingError(code ?? "device_unavailable");
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// src/internal/localMedia.ts
|
|
881
|
+
var KINDS = ["camera", "microphone", "screen"];
|
|
882
|
+
function stopTracks(stream) {
|
|
883
|
+
for (const track of stream.getTracks()) {
|
|
884
|
+
try {
|
|
885
|
+
track.stop();
|
|
886
|
+
} catch {
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function setTracksEnabled(stream, enabled) {
|
|
891
|
+
for (const track of stream.getTracks()) track.enabled = enabled;
|
|
892
|
+
}
|
|
893
|
+
function hasLiveTrack(stream) {
|
|
894
|
+
return stream.getTracks().some((track) => track.readyState === "live");
|
|
895
|
+
}
|
|
896
|
+
function deviceOf(stream, requested) {
|
|
897
|
+
const track = stream.getTracks()[0];
|
|
898
|
+
const reported = typeof track?.getSettings === "function" ? track.getSettings().deviceId : void 0;
|
|
899
|
+
return reported || requested || null;
|
|
900
|
+
}
|
|
901
|
+
var LocalMediaController = class {
|
|
902
|
+
#env;
|
|
903
|
+
#hooks;
|
|
904
|
+
#log;
|
|
905
|
+
#state = { camera: false, microphone: false, screen: false };
|
|
906
|
+
#streams = { camera: null, microphone: null, screen: null };
|
|
907
|
+
#devices = { camera: null, microphone: null, screen: null };
|
|
908
|
+
#tails = {
|
|
909
|
+
camera: Promise.resolve(),
|
|
910
|
+
microphone: Promise.resolve(),
|
|
911
|
+
screen: Promise.resolve()
|
|
912
|
+
};
|
|
913
|
+
#queued = { camera: [], microphone: [], screen: [] };
|
|
914
|
+
#released = false;
|
|
915
|
+
#generation = 0;
|
|
916
|
+
constructor(env, hooks, log) {
|
|
917
|
+
this.#env = env;
|
|
918
|
+
this.#hooks = hooks;
|
|
919
|
+
this.#log = log;
|
|
920
|
+
}
|
|
921
|
+
get state() {
|
|
922
|
+
return Object.freeze({ ...this.#state });
|
|
923
|
+
}
|
|
924
|
+
get streams() {
|
|
925
|
+
return Object.freeze({
|
|
926
|
+
camera: this.#state.camera ? this.#streams.camera : null,
|
|
927
|
+
microphone: this.#state.microphone ? this.#streams.microphone : null,
|
|
928
|
+
screen: this.#state.screen ? this.#streams.screen : null
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Turns `kind` on or off. `deviceId` applies to turning the camera or microphone on: it captures that device, or
|
|
933
|
+
* switches to it in place when `kind` is already on with another device.
|
|
934
|
+
*/
|
|
935
|
+
set(kind, enabled, deviceId) {
|
|
936
|
+
const wanted = enabled && kind !== "screen" ? deviceId : void 0;
|
|
937
|
+
const queue = this.#queued[kind];
|
|
938
|
+
const last = queue[queue.length - 1];
|
|
939
|
+
if (last) {
|
|
940
|
+
if (last.target === enabled && last.deviceId === wanted) return last.promise;
|
|
941
|
+
} else if (this.#state[kind] === enabled && !this.#released && !this.#needsSwitch(kind, wanted)) {
|
|
942
|
+
return Promise.resolve();
|
|
943
|
+
}
|
|
944
|
+
const promise = this.#tails[kind].then(() => this.#run(kind, enabled, wanted));
|
|
945
|
+
const operation = { target: enabled, deviceId: wanted, promise };
|
|
946
|
+
queue.push(operation);
|
|
947
|
+
const settle = () => {
|
|
948
|
+
const index = queue.indexOf(operation);
|
|
949
|
+
if (index >= 0) queue.splice(index, 1);
|
|
950
|
+
};
|
|
951
|
+
this.#tails[kind] = promise.then(settle, settle);
|
|
952
|
+
return promise;
|
|
953
|
+
}
|
|
954
|
+
/** Turns `kind` off locally without announcing it, because the meeting refused the change. */
|
|
955
|
+
rollback(kind) {
|
|
956
|
+
if (!this.#state[kind]) return;
|
|
957
|
+
this.#turnOff(kind, false);
|
|
958
|
+
}
|
|
959
|
+
/** Stops every capture. Pending and later changes reject with `not_joined`. Emits nothing. */
|
|
960
|
+
releaseAll() {
|
|
961
|
+
this.#released = true;
|
|
962
|
+
this.#generation += 1;
|
|
963
|
+
for (const kind of KINDS) {
|
|
964
|
+
const stream = this.#streams[kind];
|
|
965
|
+
if (stream) stopTracks(stream);
|
|
966
|
+
this.#streams[kind] = null;
|
|
967
|
+
this.#devices[kind] = null;
|
|
968
|
+
this.#state[kind] = false;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/** True when `kind` is on and a specific device other than the one in use was asked for. */
|
|
972
|
+
#needsSwitch(kind, deviceId) {
|
|
973
|
+
return this.#state[kind] && deviceId !== void 0 && deviceId !== this.#devices[kind];
|
|
974
|
+
}
|
|
975
|
+
async #run(kind, enabled, deviceId) {
|
|
976
|
+
if (this.#released) throw new MeetingError("not_joined");
|
|
977
|
+
if (enabled && this.#needsSwitch(kind, deviceId)) return this.#switchDevice(kind, deviceId);
|
|
978
|
+
if (this.#state[kind] === enabled) return;
|
|
979
|
+
if (enabled) await this.#turnOn(kind, deviceId);
|
|
980
|
+
else this.#turnOff(kind, true);
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Replaces a running capture with one from another device. The new capture is handed on before the old one stops,
|
|
984
|
+
* so the meeting keeps receiving media; nothing is announced because the on/off state does not change. When the
|
|
985
|
+
* new device cannot be captured, the old capture keeps running.
|
|
986
|
+
*/
|
|
987
|
+
async #switchDevice(kind, deviceId) {
|
|
988
|
+
const generation = this.#generation;
|
|
989
|
+
const stream = await this.#capture(kind, deviceId);
|
|
990
|
+
if (generation !== this.#generation || this.#released || !this.#state[kind]) {
|
|
991
|
+
stopTracks(stream);
|
|
992
|
+
throw new MeetingError("not_joined");
|
|
993
|
+
}
|
|
994
|
+
const previous = this.#streams[kind];
|
|
995
|
+
this.#streams[kind] = stream;
|
|
996
|
+
this.#devices[kind] = deviceOf(stream, deviceId);
|
|
997
|
+
this.#watchEnded(kind, stream);
|
|
998
|
+
this.#hooks.changed(kind, true, stream);
|
|
999
|
+
if (previous) stopTracks(previous);
|
|
1000
|
+
}
|
|
1001
|
+
async #turnOn(kind, deviceId) {
|
|
1002
|
+
if (kind === "microphone") {
|
|
1003
|
+
const kept = this.#streams.microphone;
|
|
1004
|
+
const sameDevice = deviceId === void 0 || deviceId === this.#devices.microphone;
|
|
1005
|
+
if (kept && hasLiveTrack(kept) && sameDevice) {
|
|
1006
|
+
setTracksEnabled(kept, true);
|
|
1007
|
+
if (!this.#hooks.announce(kind, true)) {
|
|
1008
|
+
setTracksEnabled(kept, false);
|
|
1009
|
+
throw new MeetingError("not_joined");
|
|
1010
|
+
}
|
|
1011
|
+
this.#state.microphone = true;
|
|
1012
|
+
this.#hooks.changed(kind, true, kept);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (kept && !hasLiveTrack(kept)) {
|
|
1016
|
+
stopTracks(kept);
|
|
1017
|
+
this.#streams.microphone = null;
|
|
1018
|
+
this.#devices.microphone = null;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
const generation = this.#generation;
|
|
1022
|
+
const stream = await this.#capture(kind, deviceId);
|
|
1023
|
+
if (generation !== this.#generation || this.#released || !this.#hooks.announce(kind, true)) {
|
|
1024
|
+
stopTracks(stream);
|
|
1025
|
+
throw new MeetingError("not_joined");
|
|
1026
|
+
}
|
|
1027
|
+
const replaced = this.#streams[kind];
|
|
1028
|
+
this.#streams[kind] = stream;
|
|
1029
|
+
this.#devices[kind] = kind === "screen" ? null : deviceOf(stream, deviceId);
|
|
1030
|
+
this.#state[kind] = true;
|
|
1031
|
+
this.#watchEnded(kind, stream);
|
|
1032
|
+
this.#hooks.changed(kind, true, stream);
|
|
1033
|
+
if (replaced) stopTracks(replaced);
|
|
1034
|
+
}
|
|
1035
|
+
#turnOff(kind, announce) {
|
|
1036
|
+
const stream = this.#streams[kind];
|
|
1037
|
+
if (kind === "microphone") {
|
|
1038
|
+
if (stream) setTracksEnabled(stream, false);
|
|
1039
|
+
} else {
|
|
1040
|
+
if (stream) stopTracks(stream);
|
|
1041
|
+
this.#streams[kind] = null;
|
|
1042
|
+
this.#devices[kind] = null;
|
|
1043
|
+
}
|
|
1044
|
+
this.#state[kind] = false;
|
|
1045
|
+
const sent = !announce || this.#hooks.announce(kind, false);
|
|
1046
|
+
this.#hooks.changed(kind, false, null);
|
|
1047
|
+
if (!sent) throw new MeetingError("not_joined");
|
|
1048
|
+
}
|
|
1049
|
+
async #capture(kind, deviceId) {
|
|
1050
|
+
const devices = this.#env.mediaDevices;
|
|
1051
|
+
try {
|
|
1052
|
+
if (kind === "screen") {
|
|
1053
|
+
if (typeof devices?.getDisplayMedia !== "function") throw new MeetingError("unsupported_environment");
|
|
1054
|
+
return await devices.getDisplayMedia({ video: true, audio: false });
|
|
1055
|
+
}
|
|
1056
|
+
if (typeof devices?.getUserMedia !== "function") throw new MeetingError("unsupported_environment");
|
|
1057
|
+
const track = deviceId === void 0 ? true : { deviceId: { exact: deviceId } };
|
|
1058
|
+
return await devices.getUserMedia(kind === "camera" ? { video: track, audio: false } : { audio: track, video: false });
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
const mapped = mapMediaError(kind, error);
|
|
1061
|
+
this.#log.warn("capture failed", { kind, code: mapped.code });
|
|
1062
|
+
throw mapped;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* A capture the browser or device ended. A live camera or unmuted microphone continues on another listed
|
|
1067
|
+
* device of that kind when one can be opened. Otherwise the kind turns off and is announced. Screen share
|
|
1068
|
+
* always turns off. A muted microphone is dropped and is not replaced.
|
|
1069
|
+
*/
|
|
1070
|
+
#watchEnded(kind, stream) {
|
|
1071
|
+
const onEnded = () => {
|
|
1072
|
+
if (this.#released || this.#streams[kind] !== stream) return;
|
|
1073
|
+
if (!this.#state[kind]) {
|
|
1074
|
+
if (kind === "microphone") {
|
|
1075
|
+
this.#streams.microphone = null;
|
|
1076
|
+
this.#devices.microphone = null;
|
|
1077
|
+
}
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (kind === "screen") {
|
|
1081
|
+
this.#log.info("screen share ended by the browser", { kind });
|
|
1082
|
+
this.set(kind, false).catch(() => void 0);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const generation = this.#generation;
|
|
1086
|
+
const scheduled = this.#tails[kind].then(() => this.#continueOnAnotherDevice(kind, stream, generation));
|
|
1087
|
+
this.#tails[kind] = scheduled.then(
|
|
1088
|
+
() => void 0,
|
|
1089
|
+
() => void 0
|
|
1090
|
+
);
|
|
1091
|
+
};
|
|
1092
|
+
for (const track of stream.getTracks()) track.addEventListener("ended", onEnded, { once: true });
|
|
1093
|
+
}
|
|
1094
|
+
/** The capture that just ended is still the one in use, and the meeting has not been released. */
|
|
1095
|
+
#stillCurrent(kind, stream, generation) {
|
|
1096
|
+
return !this.#released && this.#generation === generation && this.#state[kind] && this.#streams[kind] === stream;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Opens the first remaining device of `kind` and installs it the same way as a live switch. When none can be
|
|
1100
|
+
* opened, turns `kind` off. A release or a newer capture during the attempt stops the new stream and emits nothing.
|
|
1101
|
+
*/
|
|
1102
|
+
async #continueOnAnotherDevice(kind, endedStream, generation) {
|
|
1103
|
+
if (!this.#stillCurrent(kind, endedStream, generation)) return;
|
|
1104
|
+
const endedId = this.#devices[kind] ?? deviceOf(endedStream, void 0);
|
|
1105
|
+
const candidates = await this.#otherDevices(kind, endedId);
|
|
1106
|
+
if (!this.#stillCurrent(kind, endedStream, generation)) return;
|
|
1107
|
+
for (const deviceId of candidates) {
|
|
1108
|
+
if (!this.#stillCurrent(kind, endedStream, generation)) return;
|
|
1109
|
+
let stream;
|
|
1110
|
+
try {
|
|
1111
|
+
stream = await this.#capture(kind, deviceId);
|
|
1112
|
+
} catch {
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (!this.#stillCurrent(kind, endedStream, generation)) {
|
|
1116
|
+
stopTracks(stream);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
this.#streams[kind] = stream;
|
|
1120
|
+
this.#devices[kind] = deviceOf(stream, deviceId);
|
|
1121
|
+
this.#watchEnded(kind, stream);
|
|
1122
|
+
this.#hooks.changed(kind, true, stream);
|
|
1123
|
+
stopTracks(endedStream);
|
|
1124
|
+
this.#log.info("capture continued on another device", { kind, reason: "capture_fallback" });
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (!this.#stillCurrent(kind, endedStream, generation)) return;
|
|
1128
|
+
this.#log.info("capture ended", { kind });
|
|
1129
|
+
try {
|
|
1130
|
+
this.#turnOff(kind, true);
|
|
1131
|
+
} catch {
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/** Listed devices of `kind`, excluding the one that just ended and any empty id. */
|
|
1135
|
+
async #otherDevices(kind, endedId) {
|
|
1136
|
+
const devices = this.#env.mediaDevices;
|
|
1137
|
+
if (typeof devices?.enumerateDevices !== "function") return [];
|
|
1138
|
+
let listed;
|
|
1139
|
+
try {
|
|
1140
|
+
listed = await devices.enumerateDevices();
|
|
1141
|
+
} catch {
|
|
1142
|
+
return [];
|
|
1143
|
+
}
|
|
1144
|
+
const wanted = kind === "camera" ? "videoinput" : "audioinput";
|
|
1145
|
+
const ids = [];
|
|
1146
|
+
for (const device of listed) {
|
|
1147
|
+
if (device.kind !== wanted || !device.deviceId || device.deviceId === endedId || ids.includes(device.deviceId)) continue;
|
|
1148
|
+
ids.push(device.deviceId);
|
|
1149
|
+
}
|
|
1150
|
+
return ids;
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
// src/internal/log.ts
|
|
1155
|
+
var ALLOWED_KEYS = /* @__PURE__ */ new Set([
|
|
1156
|
+
"state",
|
|
1157
|
+
"code",
|
|
1158
|
+
"reason",
|
|
1159
|
+
"kind",
|
|
1160
|
+
"participantId",
|
|
1161
|
+
"meetingId",
|
|
1162
|
+
"closeCode",
|
|
1163
|
+
"messageType",
|
|
1164
|
+
"durationMs",
|
|
1165
|
+
"attempt",
|
|
1166
|
+
"path",
|
|
1167
|
+
"localPath",
|
|
1168
|
+
"remotePath",
|
|
1169
|
+
"trigger",
|
|
1170
|
+
"elapsedMs",
|
|
1171
|
+
"requestId",
|
|
1172
|
+
"correlationId",
|
|
1173
|
+
"rttMs",
|
|
1174
|
+
"jitterMs",
|
|
1175
|
+
"packetLossRatio",
|
|
1176
|
+
"sendBitrateBps",
|
|
1177
|
+
"receiveBitrateBps",
|
|
1178
|
+
"incomplete"
|
|
1179
|
+
]);
|
|
1180
|
+
var JWT_SHAPE = /[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/;
|
|
1181
|
+
var NEGOTIATION_CONTENT = /sdp|candidate/i;
|
|
1182
|
+
function isSafeString(value) {
|
|
1183
|
+
return !JWT_SHAPE.test(value) && !NEGOTIATION_CONTENT.test(value);
|
|
1184
|
+
}
|
|
1185
|
+
function sanitize(fields) {
|
|
1186
|
+
if (!fields) return void 0;
|
|
1187
|
+
const out = {};
|
|
1188
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
1189
|
+
if (!ALLOWED_KEYS.has(key) || value === void 0) continue;
|
|
1190
|
+
if (typeof value === "string" && !isSafeString(value)) continue;
|
|
1191
|
+
out[key] = value;
|
|
1192
|
+
}
|
|
1193
|
+
return out;
|
|
1194
|
+
}
|
|
1195
|
+
var Log = class {
|
|
1196
|
+
#logger;
|
|
1197
|
+
constructor(logger) {
|
|
1198
|
+
this.#logger = logger;
|
|
1199
|
+
}
|
|
1200
|
+
debug(message, fields) {
|
|
1201
|
+
this.#write("debug", message, fields);
|
|
1202
|
+
}
|
|
1203
|
+
info(message, fields) {
|
|
1204
|
+
this.#write("info", message, fields);
|
|
1205
|
+
}
|
|
1206
|
+
warn(message, fields) {
|
|
1207
|
+
this.#write("warn", message, fields);
|
|
1208
|
+
}
|
|
1209
|
+
error(message, fields) {
|
|
1210
|
+
this.#write("error", message, fields);
|
|
1211
|
+
}
|
|
1212
|
+
#write(level, message, fields) {
|
|
1213
|
+
const logger = this.#logger;
|
|
1214
|
+
if (!logger) return;
|
|
1215
|
+
try {
|
|
1216
|
+
const method = logger[level];
|
|
1217
|
+
if (typeof method !== "function") return;
|
|
1218
|
+
const safe = sanitize(fields);
|
|
1219
|
+
if (safe) method.call(logger, message, safe);
|
|
1220
|
+
else method.call(logger, message);
|
|
1221
|
+
} catch {
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
// src/internal/mediaDelivery.ts
|
|
1227
|
+
var NO_REMOTE_MEDIA = Object.freeze({ camera: null, microphone: null, screen: null });
|
|
1228
|
+
var SignalingOnlyMediaDelivery = class {
|
|
1229
|
+
attach() {
|
|
1230
|
+
}
|
|
1231
|
+
peersPresent() {
|
|
1232
|
+
}
|
|
1233
|
+
peerPresent() {
|
|
1234
|
+
}
|
|
1235
|
+
peerMediaChanged() {
|
|
1236
|
+
}
|
|
1237
|
+
peerLeft() {
|
|
1238
|
+
}
|
|
1239
|
+
peerReconnected() {
|
|
1240
|
+
}
|
|
1241
|
+
onPeerFrame() {
|
|
1242
|
+
}
|
|
1243
|
+
iceServersUpdated() {
|
|
1244
|
+
}
|
|
1245
|
+
localMediaChanged() {
|
|
1246
|
+
}
|
|
1247
|
+
supportsScreenShare() {
|
|
1248
|
+
return false;
|
|
1249
|
+
}
|
|
1250
|
+
remoteMedia() {
|
|
1251
|
+
return NO_REMOTE_MEDIA;
|
|
1252
|
+
}
|
|
1253
|
+
connectionStatus() {
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
close() {
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
// src/internal/reconnect/backoff.ts
|
|
1261
|
+
var RECONNECT_BACKOFF_BASE_MS = 500;
|
|
1262
|
+
var RECONNECT_BACKOFF_FACTOR = 2;
|
|
1263
|
+
var RECONNECT_BACKOFF_CAP_MS = 8e3;
|
|
1264
|
+
var RECONNECT_BACKOFF_JITTER = 0.2;
|
|
1265
|
+
function reconnectBackoffMs(attempt, clock = Math) {
|
|
1266
|
+
const n = Math.max(1, Math.floor(attempt));
|
|
1267
|
+
const raw = Math.min(RECONNECT_BACKOFF_CAP_MS, RECONNECT_BACKOFF_BASE_MS * RECONNECT_BACKOFF_FACTOR ** (n - 1));
|
|
1268
|
+
const jitter = 1 + (clock.random() * 2 - 1) * RECONNECT_BACKOFF_JITTER;
|
|
1269
|
+
return Math.max(0, Math.round(raw * jitter));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// src/internal/reconnect/recoveryWindow.ts
|
|
1273
|
+
var DEFAULT_RECONNECT_WINDOW_MS = 12e4;
|
|
1274
|
+
var MIN_RECONNECT_WINDOW_MS = 1e3;
|
|
1275
|
+
var MAX_RECONNECT_WINDOW_MS = 6e5;
|
|
1276
|
+
function clampReconnectWindowMs(value) {
|
|
1277
|
+
if (value === void 0) return DEFAULT_RECONNECT_WINDOW_MS;
|
|
1278
|
+
if (typeof value !== "number" || !Number.isInteger(value) || !Number.isFinite(value)) {
|
|
1279
|
+
return DEFAULT_RECONNECT_WINDOW_MS;
|
|
1280
|
+
}
|
|
1281
|
+
if (value < MIN_RECONNECT_WINDOW_MS) return MIN_RECONNECT_WINDOW_MS;
|
|
1282
|
+
if (value > MAX_RECONNECT_WINDOW_MS) return MAX_RECONNECT_WINDOW_MS;
|
|
1283
|
+
return value;
|
|
1284
|
+
}
|
|
1285
|
+
function markRecoveryWindowStart(state, nowMs) {
|
|
1286
|
+
if (state.windowStartedAtMs === void 0) state.windowStartedAtMs = nowMs;
|
|
1287
|
+
}
|
|
1288
|
+
function resetRecoveryWindow(state) {
|
|
1289
|
+
state.windowStartedAtMs = void 0;
|
|
1290
|
+
}
|
|
1291
|
+
function isRecoveryWindowExhausted(state, nowMs) {
|
|
1292
|
+
if (state.windowStartedAtMs === void 0) return false;
|
|
1293
|
+
return nowMs - state.windowStartedAtMs >= state.windowMs;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// src/internal/reconnect/reasons.ts
|
|
1297
|
+
var REASON_CLASSES = /* @__PURE__ */ new Set([
|
|
1298
|
+
"token_refresh_failed",
|
|
1299
|
+
"admission_refused",
|
|
1300
|
+
"recovery_window_exhausted",
|
|
1301
|
+
"meeting_ended",
|
|
1302
|
+
"session_replaced",
|
|
1303
|
+
"left",
|
|
1304
|
+
"hard_failure"
|
|
1305
|
+
]);
|
|
1306
|
+
var TRIGGERS = /* @__PURE__ */ new Set(["meeting_service", "media", "network", "visibility"]);
|
|
1307
|
+
var FORBIDDEN_FIELD_KEYS = /* @__PURE__ */ new Set([
|
|
1308
|
+
"token",
|
|
1309
|
+
"joinToken",
|
|
1310
|
+
"join_token",
|
|
1311
|
+
"authorization",
|
|
1312
|
+
"Authorization",
|
|
1313
|
+
"password",
|
|
1314
|
+
"credential",
|
|
1315
|
+
"sdp",
|
|
1316
|
+
"candidate",
|
|
1317
|
+
"ice"
|
|
1318
|
+
]);
|
|
1319
|
+
function isReconnectReasonClass(value) {
|
|
1320
|
+
return REASON_CLASSES.has(value);
|
|
1321
|
+
}
|
|
1322
|
+
function isReconnectTrigger(value) {
|
|
1323
|
+
return TRIGGERS.has(value);
|
|
1324
|
+
}
|
|
1325
|
+
function reconnectLogFields(input) {
|
|
1326
|
+
const out = {};
|
|
1327
|
+
const attempt = Math.max(1, Math.floor(input.attempt));
|
|
1328
|
+
out.attempt = attempt;
|
|
1329
|
+
if (input.trigger !== void 0 && isReconnectTrigger(input.trigger)) out.trigger = input.trigger;
|
|
1330
|
+
if (typeof input.elapsedMs === "number" && Number.isFinite(input.elapsedMs) && input.elapsedMs >= 0) {
|
|
1331
|
+
out.elapsedMs = Math.floor(input.elapsedMs);
|
|
1332
|
+
}
|
|
1333
|
+
if (input.reason !== void 0 && isReconnectReasonClass(input.reason)) out.reason = input.reason;
|
|
1334
|
+
for (const key of ["meetingId", "participantId", "requestId", "correlationId"]) {
|
|
1335
|
+
const value = input[key];
|
|
1336
|
+
if (typeof value === "string" && value.length > 0) out[key] = value;
|
|
1337
|
+
}
|
|
1338
|
+
for (const key of Object.keys(input)) {
|
|
1339
|
+
if (FORBIDDEN_FIELD_KEYS.has(key)) {
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return out;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// src/internal/reconnect/reconnectController.ts
|
|
1346
|
+
var ReconnectController = class {
|
|
1347
|
+
#env;
|
|
1348
|
+
#logger;
|
|
1349
|
+
#open;
|
|
1350
|
+
#onAbandoned;
|
|
1351
|
+
#clock;
|
|
1352
|
+
#window;
|
|
1353
|
+
#active = false;
|
|
1354
|
+
#attempt = 0;
|
|
1355
|
+
#trigger = "meeting_service";
|
|
1356
|
+
#timer;
|
|
1357
|
+
#generation = 0;
|
|
1358
|
+
#inFlight = false;
|
|
1359
|
+
constructor(deps) {
|
|
1360
|
+
this.#env = deps.env;
|
|
1361
|
+
this.#logger = deps.logger;
|
|
1362
|
+
this.#open = deps.open;
|
|
1363
|
+
this.#onAbandoned = deps.onAbandoned;
|
|
1364
|
+
this.#clock = { random: deps.random ?? (() => Math.random()) };
|
|
1365
|
+
this.#window = { windowMs: deps.windowMs, windowStartedAtMs: void 0 };
|
|
1366
|
+
}
|
|
1367
|
+
get active() {
|
|
1368
|
+
return this.#active;
|
|
1369
|
+
}
|
|
1370
|
+
get attempt() {
|
|
1371
|
+
return this.#attempt;
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Starts (or continues) a single active reconnect chain.
|
|
1375
|
+
* Overlapping starts while already active are ignored.
|
|
1376
|
+
*/
|
|
1377
|
+
start(trigger = "meeting_service") {
|
|
1378
|
+
if (this.#active) return;
|
|
1379
|
+
this.#active = true;
|
|
1380
|
+
this.#trigger = trigger;
|
|
1381
|
+
this.#attempt = 0;
|
|
1382
|
+
markRecoveryWindowStart(this.#window, this.#env.now());
|
|
1383
|
+
this.#scheduleNext(0);
|
|
1384
|
+
}
|
|
1385
|
+
/** Cancels further opens and clears timers. Safe to call when idle. */
|
|
1386
|
+
cancel(reason = "left") {
|
|
1387
|
+
if (!this.#active && this.#timer === void 0) return;
|
|
1388
|
+
const attempt = Math.max(1, this.#attempt);
|
|
1389
|
+
const started = this.#window.windowStartedAtMs;
|
|
1390
|
+
const elapsedMs = started === void 0 ? 0 : Math.max(0, this.#env.now() - started);
|
|
1391
|
+
this.#clearTimer();
|
|
1392
|
+
this.#generation += 1;
|
|
1393
|
+
this.#active = false;
|
|
1394
|
+
this.#inFlight = false;
|
|
1395
|
+
resetRecoveryWindow(this.#window);
|
|
1396
|
+
this.#log("info", "reconnect cancelled", {
|
|
1397
|
+
attempt,
|
|
1398
|
+
elapsedMs,
|
|
1399
|
+
reason,
|
|
1400
|
+
trigger: this.#trigger
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
#scheduleNext(delayMs) {
|
|
1404
|
+
this.#clearTimer();
|
|
1405
|
+
const generation = this.#generation;
|
|
1406
|
+
const run = () => {
|
|
1407
|
+
if (generation !== this.#generation) return;
|
|
1408
|
+
void this.#runAttempt();
|
|
1409
|
+
};
|
|
1410
|
+
if (delayMs <= 0) {
|
|
1411
|
+
queueMicrotask(run);
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
this.#timer = this.#env.setTimeout(run, delayMs);
|
|
1415
|
+
}
|
|
1416
|
+
async #runAttempt() {
|
|
1417
|
+
if (!this.#active || this.#inFlight) return;
|
|
1418
|
+
const now = this.#env.now();
|
|
1419
|
+
if (isRecoveryWindowExhausted(this.#window, now)) {
|
|
1420
|
+
this.#abandon("recovery_window_exhausted");
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
this.#attempt += 1;
|
|
1424
|
+
const attempt = this.#attempt;
|
|
1425
|
+
const generation = this.#generation;
|
|
1426
|
+
this.#inFlight = true;
|
|
1427
|
+
this.#log("info", "reconnect attempt started", { attempt, trigger: this.#trigger });
|
|
1428
|
+
let result = "failed";
|
|
1429
|
+
try {
|
|
1430
|
+
result = await this.#open(attempt);
|
|
1431
|
+
} catch {
|
|
1432
|
+
result = "failed";
|
|
1433
|
+
}
|
|
1434
|
+
if (generation !== this.#generation) return;
|
|
1435
|
+
this.#inFlight = false;
|
|
1436
|
+
const started = this.#window.windowStartedAtMs;
|
|
1437
|
+
const elapsedMs = started === void 0 ? 0 : Math.max(0, this.#env.now() - started);
|
|
1438
|
+
if (result === "recovered") {
|
|
1439
|
+
this.#active = false;
|
|
1440
|
+
resetRecoveryWindow(this.#window);
|
|
1441
|
+
this.#log("info", "reconnect succeeded", { attempt, elapsedMs, trigger: this.#trigger });
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (result === "cancelled") {
|
|
1445
|
+
this.cancel("left");
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
if (result === "failed") {
|
|
1449
|
+
this.#abandon("hard_failure");
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (isRecoveryWindowExhausted(this.#window, this.#env.now())) {
|
|
1453
|
+
this.#abandon("recovery_window_exhausted");
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const delay = reconnectBackoffMs(attempt + 1, this.#clock);
|
|
1457
|
+
this.#scheduleNext(delay);
|
|
1458
|
+
}
|
|
1459
|
+
#abandon(reason) {
|
|
1460
|
+
const attempt = Math.max(1, this.#attempt);
|
|
1461
|
+
const started = this.#window.windowStartedAtMs;
|
|
1462
|
+
const elapsedMs = started === void 0 ? 0 : Math.max(0, this.#env.now() - started);
|
|
1463
|
+
this.#clearTimer();
|
|
1464
|
+
this.#generation += 1;
|
|
1465
|
+
this.#active = false;
|
|
1466
|
+
this.#inFlight = false;
|
|
1467
|
+
resetRecoveryWindow(this.#window);
|
|
1468
|
+
this.#log("warn", "reconnect failed", { attempt, elapsedMs, reason, trigger: this.#trigger });
|
|
1469
|
+
this.#onAbandoned?.(reason);
|
|
1470
|
+
}
|
|
1471
|
+
#clearTimer() {
|
|
1472
|
+
if (this.#timer !== void 0) this.#env.clearTimeout(this.#timer);
|
|
1473
|
+
this.#timer = void 0;
|
|
1474
|
+
}
|
|
1475
|
+
#log(level, message, fields) {
|
|
1476
|
+
const logger = this.#logger;
|
|
1477
|
+
if (!logger) return;
|
|
1478
|
+
const method = logger[level];
|
|
1479
|
+
if (typeof method !== "function") return;
|
|
1480
|
+
try {
|
|
1481
|
+
method.call(logger, message, reconnectLogFields(fields));
|
|
1482
|
+
} catch {
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
// src/internal/roster.ts
|
|
1488
|
+
function snapshot(entry) {
|
|
1489
|
+
const media = Object.freeze({ ...entry.media });
|
|
1490
|
+
const participant = {
|
|
1491
|
+
id: entry.id,
|
|
1492
|
+
role: entry.role,
|
|
1493
|
+
media
|
|
1494
|
+
};
|
|
1495
|
+
if (entry.displayName !== void 0) participant.displayName = entry.displayName;
|
|
1496
|
+
return Object.freeze(participant);
|
|
1497
|
+
}
|
|
1498
|
+
var Roster = class {
|
|
1499
|
+
#entries = /* @__PURE__ */ new Map();
|
|
1500
|
+
#committed = false;
|
|
1501
|
+
#cache;
|
|
1502
|
+
get committed() {
|
|
1503
|
+
return this.#committed;
|
|
1504
|
+
}
|
|
1505
|
+
upsertJoined(id, role2, displayName) {
|
|
1506
|
+
const existing = this.#entries.get(id);
|
|
1507
|
+
this.#cache = void 0;
|
|
1508
|
+
if (existing) {
|
|
1509
|
+
existing.role = role2;
|
|
1510
|
+
if (displayName !== void 0) existing.displayName = displayName;
|
|
1511
|
+
else delete existing.displayName;
|
|
1512
|
+
return this.#committed ? "updated" : "buffered";
|
|
1513
|
+
}
|
|
1514
|
+
const entry = { id, role: role2, media: { camera: false, microphone: false, screen: false } };
|
|
1515
|
+
if (displayName !== void 0) entry.displayName = displayName;
|
|
1516
|
+
this.#entries.set(id, entry);
|
|
1517
|
+
return this.#committed ? "added" : "buffered";
|
|
1518
|
+
}
|
|
1519
|
+
/** Returns true when the participant's state changed. Unknown participants are ignored after commit. */
|
|
1520
|
+
setMedia(id, kind, enabled) {
|
|
1521
|
+
let entry = this.#entries.get(id);
|
|
1522
|
+
if (!entry) {
|
|
1523
|
+
if (this.#committed) return false;
|
|
1524
|
+
this.upsertJoined(id, "participant");
|
|
1525
|
+
entry = this.#entries.get(id);
|
|
1526
|
+
if (!entry) return false;
|
|
1527
|
+
}
|
|
1528
|
+
if (entry.media[kind] === enabled) return false;
|
|
1529
|
+
entry.media[kind] = enabled;
|
|
1530
|
+
this.#cache = void 0;
|
|
1531
|
+
return true;
|
|
1532
|
+
}
|
|
1533
|
+
remove(id) {
|
|
1534
|
+
const removed = this.#entries.delete(id);
|
|
1535
|
+
if (removed) this.#cache = void 0;
|
|
1536
|
+
return removed;
|
|
1537
|
+
}
|
|
1538
|
+
commit() {
|
|
1539
|
+
this.#committed = true;
|
|
1540
|
+
this.#cache = void 0;
|
|
1541
|
+
}
|
|
1542
|
+
get(id) {
|
|
1543
|
+
const entry = this.#entries.get(id);
|
|
1544
|
+
return entry ? snapshot(entry) : void 0;
|
|
1545
|
+
}
|
|
1546
|
+
/** Committed participants in arrival order; empty before commit. */
|
|
1547
|
+
list() {
|
|
1548
|
+
if (!this.#committed) return Object.freeze([]);
|
|
1549
|
+
this.#cache ??= Object.freeze([...this.#entries.values()].map(snapshot));
|
|
1550
|
+
return this.#cache;
|
|
1551
|
+
}
|
|
1552
|
+
clear() {
|
|
1553
|
+
this.#entries.clear();
|
|
1554
|
+
this.#cache = void 0;
|
|
1555
|
+
}
|
|
1556
|
+
/** Clears participants and returns to the uncommitted (joining) buffer state. */
|
|
1557
|
+
reset() {
|
|
1558
|
+
this.clear();
|
|
1559
|
+
this.#committed = false;
|
|
1560
|
+
}
|
|
1561
|
+
};
|
|
1562
|
+
|
|
1563
|
+
// src/internal/signalingSession.ts
|
|
1564
|
+
var SIGNALING_SUBPROTOCOL = "strevokit.signaling.v1";
|
|
1565
|
+
var TOKEN_SUBPROTOCOL_PREFIX = "strevokit.join-token.";
|
|
1566
|
+
var ABNORMAL_CLOSE = 1006;
|
|
1567
|
+
var OUTBOUND_TYPES = Object.freeze([
|
|
1568
|
+
"participant.join",
|
|
1569
|
+
"participant.leave",
|
|
1570
|
+
"meeting.end",
|
|
1571
|
+
"media.camera.started",
|
|
1572
|
+
"media.camera.stopped",
|
|
1573
|
+
"media.microphone.started",
|
|
1574
|
+
"media.microphone.stopped",
|
|
1575
|
+
"media.screen.started",
|
|
1576
|
+
"media.screen.stopped"
|
|
1577
|
+
]);
|
|
1578
|
+
var PEER_OUTBOUND_TYPES = Object.freeze([
|
|
1579
|
+
"webrtc.offer",
|
|
1580
|
+
"webrtc.answer",
|
|
1581
|
+
"webrtc.ice_candidate",
|
|
1582
|
+
"webrtc.connected",
|
|
1583
|
+
"webrtc.disconnected",
|
|
1584
|
+
"webrtc.failed",
|
|
1585
|
+
"webrtc.quality_sample",
|
|
1586
|
+
"webrtc.ice_restart",
|
|
1587
|
+
"webrtc.ice_restart_result"
|
|
1588
|
+
]);
|
|
1589
|
+
var SignalingSession = class {
|
|
1590
|
+
#env;
|
|
1591
|
+
#url;
|
|
1592
|
+
#addressing;
|
|
1593
|
+
#log;
|
|
1594
|
+
#token;
|
|
1595
|
+
#callbacks;
|
|
1596
|
+
#socket;
|
|
1597
|
+
#opened = false;
|
|
1598
|
+
constructor(options) {
|
|
1599
|
+
this.#env = options.env;
|
|
1600
|
+
this.#url = `${options.realtimeUrl}/v1/meetings/${options.addressing.meetingId}/signaling`;
|
|
1601
|
+
this.#addressing = options.addressing;
|
|
1602
|
+
this.#log = options.log;
|
|
1603
|
+
this.#token = options.token;
|
|
1604
|
+
this.#callbacks = options.callbacks;
|
|
1605
|
+
}
|
|
1606
|
+
open() {
|
|
1607
|
+
const token = this.#token;
|
|
1608
|
+
this.#token = void 0;
|
|
1609
|
+
const Socket = this.#env.WebSocket;
|
|
1610
|
+
if (!Socket || token === void 0) {
|
|
1611
|
+
this.#fail();
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
let socket;
|
|
1615
|
+
try {
|
|
1616
|
+
socket = new Socket(this.#url, [SIGNALING_SUBPROTOCOL, `${TOKEN_SUBPROTOCOL_PREFIX}${token}`]);
|
|
1617
|
+
} catch {
|
|
1618
|
+
this.#log.warn("connection could not be created");
|
|
1619
|
+
this.#fail();
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
this.#socket = socket;
|
|
1623
|
+
socket.onopen = () => this.#handleOpen();
|
|
1624
|
+
socket.onmessage = (event) => this.#handleFrame(event.data);
|
|
1625
|
+
socket.onclose = (event) => this.#handleClose(event.code, event.reason);
|
|
1626
|
+
socket.onerror = () => void 0;
|
|
1627
|
+
}
|
|
1628
|
+
/** Sends an empty-payload frame. Returns false when the connection is not open or the frame cannot be built. */
|
|
1629
|
+
send(type) {
|
|
1630
|
+
const socket = this.#socket;
|
|
1631
|
+
if (!socket || !this.#opened || !this.#callbacks) return false;
|
|
1632
|
+
try {
|
|
1633
|
+
const message = createSignalingMessage(type, {
|
|
1634
|
+
meetingId: this.#addressing.meetingId,
|
|
1635
|
+
participantId: this.#addressing.participantId,
|
|
1636
|
+
payload: {}
|
|
1637
|
+
});
|
|
1638
|
+
socket.send(encodeSignalingMessage(message));
|
|
1639
|
+
return true;
|
|
1640
|
+
} catch {
|
|
1641
|
+
this.#log.warn("frame could not be sent", { messageType: type });
|
|
1642
|
+
return false;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Sends a media frame. Returns false when the connection is not open or the frame fails validation. The payload
|
|
1647
|
+
* is never logged.
|
|
1648
|
+
*/
|
|
1649
|
+
sendPeer(type, payload) {
|
|
1650
|
+
const socket = this.#socket;
|
|
1651
|
+
if (!socket || !this.#opened || !this.#callbacks) return false;
|
|
1652
|
+
try {
|
|
1653
|
+
const message = createSignalingMessage(type, {
|
|
1654
|
+
meetingId: this.#addressing.meetingId,
|
|
1655
|
+
participantId: this.#addressing.participantId,
|
|
1656
|
+
payload
|
|
1657
|
+
});
|
|
1658
|
+
socket.send(encodeSignalingMessage(message));
|
|
1659
|
+
return true;
|
|
1660
|
+
} catch {
|
|
1661
|
+
this.#log.debug("frame could not be sent", { messageType: type });
|
|
1662
|
+
return false;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
/** Closes the connection. No further callbacks are made. */
|
|
1666
|
+
close(code = 1e3) {
|
|
1667
|
+
this.#callbacks = void 0;
|
|
1668
|
+
const socket = this.#socket;
|
|
1669
|
+
if (!socket) return;
|
|
1670
|
+
try {
|
|
1671
|
+
socket.close(code);
|
|
1672
|
+
} catch {
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
#handleOpen() {
|
|
1676
|
+
if (!this.#callbacks) return;
|
|
1677
|
+
if (this.#socket?.protocol !== SIGNALING_SUBPROTOCOL) {
|
|
1678
|
+
this.#log.warn("unexpected subprotocol negotiated");
|
|
1679
|
+
const socket = this.#socket;
|
|
1680
|
+
this.#fail();
|
|
1681
|
+
try {
|
|
1682
|
+
socket?.close(1e3);
|
|
1683
|
+
} catch {
|
|
1684
|
+
}
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
this.#opened = true;
|
|
1688
|
+
}
|
|
1689
|
+
#handleFrame(data) {
|
|
1690
|
+
const callbacks = this.#callbacks;
|
|
1691
|
+
if (!callbacks) return;
|
|
1692
|
+
const result = decodeSignalingFrame(data, { direction: "server_to_client" });
|
|
1693
|
+
try {
|
|
1694
|
+
if (result.ok) callbacks.onMessage(result.message);
|
|
1695
|
+
else {
|
|
1696
|
+
this.#log.debug("invalid frame received", { code: result.error.code });
|
|
1697
|
+
callbacks.onProtocolError();
|
|
1698
|
+
}
|
|
1699
|
+
} catch {
|
|
1700
|
+
this.#log.error("frame handling failed");
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
#handleClose(code, reason) {
|
|
1704
|
+
const callbacks = this.#callbacks;
|
|
1705
|
+
this.#callbacks = void 0;
|
|
1706
|
+
if (!callbacks) return;
|
|
1707
|
+
try {
|
|
1708
|
+
callbacks.onClose(typeof code === "number" ? code : ABNORMAL_CLOSE, typeof reason === "string" ? reason : "", this.#opened);
|
|
1709
|
+
} catch {
|
|
1710
|
+
this.#log.error("close handling failed");
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
#fail() {
|
|
1714
|
+
const callbacks = this.#callbacks;
|
|
1715
|
+
this.#callbacks = void 0;
|
|
1716
|
+
if (!callbacks) return;
|
|
1717
|
+
try {
|
|
1718
|
+
callbacks.onClose(ABNORMAL_CLOSE, "", false);
|
|
1719
|
+
} catch {
|
|
1720
|
+
this.#log.error("close handling failed");
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
|
|
1725
|
+
// src/internal/token.ts
|
|
1726
|
+
var TOKEN_EXPIRY_SKEW_MS = 3e4;
|
|
1727
|
+
var BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
1728
|
+
function decodeSegment(segment) {
|
|
1729
|
+
if (!BASE64URL.test(segment)) throw new MeetingError("join_token_invalid");
|
|
1730
|
+
const padded = segment.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(segment.length / 4) * 4, "=");
|
|
1731
|
+
const binary = atob(padded);
|
|
1732
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
1733
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1734
|
+
}
|
|
1735
|
+
function decodeClaims(token) {
|
|
1736
|
+
const segments = token.split(".");
|
|
1737
|
+
if (segments.length !== 3) throw new MeetingError("join_token_invalid");
|
|
1738
|
+
let claims;
|
|
1739
|
+
try {
|
|
1740
|
+
claims = decodeSegment(segments[1] ?? "");
|
|
1741
|
+
} catch {
|
|
1742
|
+
throw new MeetingError("join_token_invalid");
|
|
1743
|
+
}
|
|
1744
|
+
if (typeof claims !== "object" || claims === null || Array.isArray(claims)) {
|
|
1745
|
+
throw new MeetingError("join_token_invalid");
|
|
1746
|
+
}
|
|
1747
|
+
return claims;
|
|
1748
|
+
}
|
|
1749
|
+
function decodeAddressing(token) {
|
|
1750
|
+
const claims = decodeClaims(token);
|
|
1751
|
+
const { meeting_id: meetingId, participant_id: participantId } = claims;
|
|
1752
|
+
if (!isCanonicalUlid(meetingId) || !isCanonicalUlid(participantId)) {
|
|
1753
|
+
throw new MeetingError("join_token_invalid");
|
|
1754
|
+
}
|
|
1755
|
+
return { meetingId, participantId };
|
|
1756
|
+
}
|
|
1757
|
+
function isJoinTokenUsable(token, nowMs, skewMs = TOKEN_EXPIRY_SKEW_MS) {
|
|
1758
|
+
try {
|
|
1759
|
+
const claims = decodeClaims(token);
|
|
1760
|
+
const exp = claims.exp;
|
|
1761
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
|
|
1762
|
+
return exp * 1e3 > nowMs + skewMs;
|
|
1763
|
+
} catch {
|
|
1764
|
+
return false;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// src/internal/engine.ts
|
|
1769
|
+
var LEAVE_FALLBACK_MS = 5e3;
|
|
1770
|
+
var RESTRICTED_PENDING_MS = 2e3;
|
|
1771
|
+
var END_MEETING_TIMEOUT_MS = 1e4;
|
|
1772
|
+
var MEDIA_KINDS = ["camera", "microphone", "screen"];
|
|
1773
|
+
function deferred() {
|
|
1774
|
+
let resolve;
|
|
1775
|
+
let reject2;
|
|
1776
|
+
const promise = new Promise((res, rej) => {
|
|
1777
|
+
resolve = res;
|
|
1778
|
+
reject2 = rej;
|
|
1779
|
+
});
|
|
1780
|
+
return { promise, resolve, reject: reject2 };
|
|
1781
|
+
}
|
|
1782
|
+
function mediaFrame(type) {
|
|
1783
|
+
const match = /^media\.(camera|microphone|screen)\.(started|stopped)$/.exec(type);
|
|
1784
|
+
if (!match) return void 0;
|
|
1785
|
+
return { kind: match[1], enabled: match[2] === "started" };
|
|
1786
|
+
}
|
|
1787
|
+
var MeetingEngineImpl = class {
|
|
1788
|
+
#realtimeUrl;
|
|
1789
|
+
#joinTimeoutMs;
|
|
1790
|
+
#addressing;
|
|
1791
|
+
#env;
|
|
1792
|
+
#createDelivery;
|
|
1793
|
+
#getJoinToken;
|
|
1794
|
+
#reconnectWindowMs;
|
|
1795
|
+
#logger;
|
|
1796
|
+
#delivery = new SignalingOnlyMediaDelivery();
|
|
1797
|
+
#log;
|
|
1798
|
+
#emitter;
|
|
1799
|
+
#roster = new Roster();
|
|
1800
|
+
#media;
|
|
1801
|
+
#token;
|
|
1802
|
+
#state = "idle";
|
|
1803
|
+
#disposed = false;
|
|
1804
|
+
#session;
|
|
1805
|
+
#self = null;
|
|
1806
|
+
#role;
|
|
1807
|
+
#join;
|
|
1808
|
+
#leave;
|
|
1809
|
+
#joinTimer;
|
|
1810
|
+
#leaveTimer;
|
|
1811
|
+
#endMeeting;
|
|
1812
|
+
#endMeetingTimer;
|
|
1813
|
+
#pendingRestricted;
|
|
1814
|
+
#reconnect;
|
|
1815
|
+
#reconnectOpen;
|
|
1816
|
+
#reconnectOutcome;
|
|
1817
|
+
#preRecoveryParticipants;
|
|
1818
|
+
#pendingAbandonReason;
|
|
1819
|
+
#onVisibility;
|
|
1820
|
+
constructor(config) {
|
|
1821
|
+
this.#token = config.token;
|
|
1822
|
+
this.#realtimeUrl = config.realtimeUrl;
|
|
1823
|
+
this.#joinTimeoutMs = config.joinTimeoutMs;
|
|
1824
|
+
this.#addressing = config.addressing;
|
|
1825
|
+
this.#env = config.env;
|
|
1826
|
+
this.#createDelivery = config.createMediaDelivery;
|
|
1827
|
+
this.#getJoinToken = config.getJoinToken;
|
|
1828
|
+
this.#reconnectWindowMs = config.reconnectWindowMs ?? 12e4;
|
|
1829
|
+
this.#logger = config.logger;
|
|
1830
|
+
this.#log = new Log(config.logger);
|
|
1831
|
+
this.#emitter = new Emitter(this.#log);
|
|
1832
|
+
this.#media = new LocalMediaController(
|
|
1833
|
+
config.env,
|
|
1834
|
+
{
|
|
1835
|
+
announce: (kind, enabled) => this.#announce(kind, enabled),
|
|
1836
|
+
changed: (kind, enabled, stream) => {
|
|
1837
|
+
this.#emit("local_media_changed", { kind, enabled, stream });
|
|
1838
|
+
this.#delivery.localMediaChanged(kind, stream);
|
|
1839
|
+
}
|
|
1840
|
+
},
|
|
1841
|
+
this.#log
|
|
1842
|
+
);
|
|
1843
|
+
this.#installVisibilityListener();
|
|
1844
|
+
}
|
|
1845
|
+
get state() {
|
|
1846
|
+
return this.#state;
|
|
1847
|
+
}
|
|
1848
|
+
get self() {
|
|
1849
|
+
return this.#self;
|
|
1850
|
+
}
|
|
1851
|
+
get participants() {
|
|
1852
|
+
return this.#roster.list();
|
|
1853
|
+
}
|
|
1854
|
+
get localMediaState() {
|
|
1855
|
+
return this.#media.state;
|
|
1856
|
+
}
|
|
1857
|
+
get localMedia() {
|
|
1858
|
+
return this.#media.streams;
|
|
1859
|
+
}
|
|
1860
|
+
remoteMedia(participantId) {
|
|
1861
|
+
return this.#delivery.remoteMedia(participantId);
|
|
1862
|
+
}
|
|
1863
|
+
mediaConnectionStatus(participantId) {
|
|
1864
|
+
return this.#delivery.connectionStatus(participantId);
|
|
1865
|
+
}
|
|
1866
|
+
join() {
|
|
1867
|
+
if (this.#state === "closed") return Promise.reject(new MeetingError("engine_closed"));
|
|
1868
|
+
if (this.#state !== "idle") return Promise.reject(new MeetingError("already_joined"));
|
|
1869
|
+
const token = this.#token;
|
|
1870
|
+
if (!this.#env.WebSocket || token === void 0) return Promise.reject(new MeetingError("unsupported_environment"));
|
|
1871
|
+
const join = deferred();
|
|
1872
|
+
this.#join = join;
|
|
1873
|
+
this.#setState("connecting");
|
|
1874
|
+
if (!this.#is("connecting")) return join.promise;
|
|
1875
|
+
this.#joinTimer = this.#env.setTimeout(() => this.#failJoin(new MeetingError("join_timeout")), this.#joinTimeoutMs);
|
|
1876
|
+
this.#openSession(token);
|
|
1877
|
+
return join.promise;
|
|
1878
|
+
}
|
|
1879
|
+
leave() {
|
|
1880
|
+
switch (this.#state) {
|
|
1881
|
+
case "idle":
|
|
1882
|
+
case "closed":
|
|
1883
|
+
return Promise.resolve();
|
|
1884
|
+
case "leaving":
|
|
1885
|
+
return this.#leave?.promise ?? Promise.resolve();
|
|
1886
|
+
case "connecting":
|
|
1887
|
+
case "joining":
|
|
1888
|
+
this.#failJoin(new MeetingError("join_cancelled"));
|
|
1889
|
+
return Promise.resolve();
|
|
1890
|
+
case "reconnecting":
|
|
1891
|
+
this.#cancelReconnect("left");
|
|
1892
|
+
this.#finish("left");
|
|
1893
|
+
return Promise.resolve();
|
|
1894
|
+
case "joined":
|
|
1895
|
+
break;
|
|
1896
|
+
}
|
|
1897
|
+
const leave = deferred();
|
|
1898
|
+
this.#leave = leave;
|
|
1899
|
+
this.#media.releaseAll();
|
|
1900
|
+
this.#releaseRemoteMedia();
|
|
1901
|
+
this.#setState("leaving");
|
|
1902
|
+
if (!this.#is("leaving")) return leave.promise;
|
|
1903
|
+
if (!this.#session?.send("participant.leave")) {
|
|
1904
|
+
this.#finish("left");
|
|
1905
|
+
return leave.promise;
|
|
1906
|
+
}
|
|
1907
|
+
this.#leaveTimer = this.#env.setTimeout(() => this.#finish("left"), LEAVE_FALLBACK_MS);
|
|
1908
|
+
return leave.promise;
|
|
1909
|
+
}
|
|
1910
|
+
endMeeting() {
|
|
1911
|
+
if (this.#state !== "joined") return Promise.reject(new MeetingError("not_joined"));
|
|
1912
|
+
if (this.#role !== "host") return Promise.reject(new MeetingError("not_permitted"));
|
|
1913
|
+
if (this.#endMeeting) return this.#endMeeting.promise;
|
|
1914
|
+
const pending = deferred();
|
|
1915
|
+
this.#endMeeting = pending;
|
|
1916
|
+
if (!this.#session?.send("meeting.end")) {
|
|
1917
|
+
this.#endMeeting = void 0;
|
|
1918
|
+
return Promise.reject(new MeetingError("service_unavailable"));
|
|
1919
|
+
}
|
|
1920
|
+
this.#endMeetingTimer = this.#env.setTimeout(() => {
|
|
1921
|
+
const p = this.#endMeeting;
|
|
1922
|
+
this.#endMeeting = void 0;
|
|
1923
|
+
this.#endMeetingTimer = void 0;
|
|
1924
|
+
p?.reject(new MeetingError("service_unavailable"));
|
|
1925
|
+
}, END_MEETING_TIMEOUT_MS);
|
|
1926
|
+
return pending.promise;
|
|
1927
|
+
}
|
|
1928
|
+
enableCamera(options) {
|
|
1929
|
+
return this.#mediaCall("camera", true, options?.deviceId);
|
|
1930
|
+
}
|
|
1931
|
+
disableCamera() {
|
|
1932
|
+
return this.#mediaCall("camera", false);
|
|
1933
|
+
}
|
|
1934
|
+
mute() {
|
|
1935
|
+
return this.#mediaCall("microphone", false);
|
|
1936
|
+
}
|
|
1937
|
+
unmute(options) {
|
|
1938
|
+
return this.#mediaCall("microphone", true, options?.deviceId);
|
|
1939
|
+
}
|
|
1940
|
+
startScreenShare() {
|
|
1941
|
+
return this.#mediaCall("screen", true);
|
|
1942
|
+
}
|
|
1943
|
+
stopScreenShare() {
|
|
1944
|
+
return this.#mediaCall("screen", false);
|
|
1945
|
+
}
|
|
1946
|
+
on(type, listener) {
|
|
1947
|
+
if (this.#disposed) return () => void 0;
|
|
1948
|
+
return this.#emitter.on(type, listener);
|
|
1949
|
+
}
|
|
1950
|
+
dispose() {
|
|
1951
|
+
if (this.#disposed) return;
|
|
1952
|
+
this.#disposed = true;
|
|
1953
|
+
this.#removeVisibilityListener();
|
|
1954
|
+
this.#emitter.clear();
|
|
1955
|
+
const end = this.#endMeeting;
|
|
1956
|
+
this.#endMeeting = void 0;
|
|
1957
|
+
this.#cancelReconnect("hard_failure");
|
|
1958
|
+
this.#teardown();
|
|
1959
|
+
this.#state = "closed";
|
|
1960
|
+
const join = this.#join;
|
|
1961
|
+
this.#join = void 0;
|
|
1962
|
+
if (join) {
|
|
1963
|
+
join.promise.catch(() => void 0);
|
|
1964
|
+
join.reject(new MeetingError("join_cancelled"));
|
|
1965
|
+
}
|
|
1966
|
+
this.#leave?.resolve();
|
|
1967
|
+
end?.reject(new MeetingError("engine_closed"));
|
|
1968
|
+
}
|
|
1969
|
+
#mediaCall(kind, enabled, deviceId) {
|
|
1970
|
+
if (this.#state !== "joined") return Promise.reject(new MeetingError("not_joined"));
|
|
1971
|
+
if (kind === "screen") {
|
|
1972
|
+
const type = enabled ? "media.screen.started" : "media.screen.stopped";
|
|
1973
|
+
if (this.#role === void 0 || !isRoleAllowed(type, this.#role)) {
|
|
1974
|
+
return Promise.reject(new MeetingError("not_permitted"));
|
|
1975
|
+
}
|
|
1976
|
+
if (enabled && !this.#delivery.supportsScreenShare()) {
|
|
1977
|
+
return Promise.reject(new MeetingError("unsupported_environment"));
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
return this.#media.set(kind, enabled, typeof deviceId === "string" && deviceId !== "" ? deviceId : void 0);
|
|
1981
|
+
}
|
|
1982
|
+
#announce(kind, enabled) {
|
|
1983
|
+
if (this.#state !== "joined") return false;
|
|
1984
|
+
const type = `media.${kind}.${enabled ? "started" : "stopped"}`;
|
|
1985
|
+
if (!this.#session?.send(type)) return false;
|
|
1986
|
+
if (this.#pendingRestricted?.kind === kind) this.#clearPendingRestricted();
|
|
1987
|
+
if (ROLE_POLICY[type]) {
|
|
1988
|
+
this.#pendingRestricted = {
|
|
1989
|
+
kind,
|
|
1990
|
+
timer: this.#env.setTimeout(() => {
|
|
1991
|
+
this.#pendingRestricted = void 0;
|
|
1992
|
+
}, RESTRICTED_PENDING_MS)
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
return true;
|
|
1996
|
+
}
|
|
1997
|
+
#clearPendingRestricted() {
|
|
1998
|
+
if (this.#pendingRestricted) this.#env.clearTimeout(this.#pendingRestricted.timer);
|
|
1999
|
+
this.#pendingRestricted = void 0;
|
|
2000
|
+
}
|
|
2001
|
+
#onMessage(message) {
|
|
2002
|
+
const state = this.#state;
|
|
2003
|
+
if (state === "closed" || state === "idle") return;
|
|
2004
|
+
const from = message.participantId;
|
|
2005
|
+
const isSelf = from === this.#addressing.participantId;
|
|
2006
|
+
switch (message.type) {
|
|
2007
|
+
case "session.authenticated": {
|
|
2008
|
+
if (state === "connecting") {
|
|
2009
|
+
this.#role = message.payload.role;
|
|
2010
|
+
this.#setState("joining");
|
|
2011
|
+
if (!this.#is("joining")) return;
|
|
2012
|
+
if (!this.#session?.send("participant.join")) this.#failJoin(new MeetingError("connection_failed"));
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
if (state === "reconnecting") {
|
|
2016
|
+
this.#role = message.payload.role;
|
|
2017
|
+
if (!this.#session?.send("participant.join")) this.#failReconnectOpen(new MeetingError("connection_failed"));
|
|
2018
|
+
}
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
case "session.welcome": {
|
|
2022
|
+
if (state === "joining") {
|
|
2023
|
+
this.#clearTimer("join");
|
|
2024
|
+
this.#roster.commit();
|
|
2025
|
+
const payload = message.payload;
|
|
2026
|
+
const self = {
|
|
2027
|
+
id: this.#addressing.participantId,
|
|
2028
|
+
role: payload.role,
|
|
2029
|
+
joinedAt: payload.joinedAt
|
|
2030
|
+
};
|
|
2031
|
+
if (payload.displayName !== void 0) self.displayName = payload.displayName;
|
|
2032
|
+
this.#self = Object.freeze(self);
|
|
2033
|
+
this.#role = payload.role;
|
|
2034
|
+
const join = this.#join;
|
|
2035
|
+
this.#join = void 0;
|
|
2036
|
+
this.#setState("joined");
|
|
2037
|
+
this.#emit("joined", { self: this.#self, participants: this.#roster.list() });
|
|
2038
|
+
join?.resolve();
|
|
2039
|
+
if (this.#is("joined")) {
|
|
2040
|
+
const expiresAt = payload.iceServersExpireAt !== void 0 ? Date.parse(payload.iceServersExpireAt) : NaN;
|
|
2041
|
+
this.#startDelivery(payload.mediaMode, payload.iceServers ?? [], Number.isFinite(expiresAt) ? expiresAt : null);
|
|
2042
|
+
}
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
if (state === "reconnecting") {
|
|
2046
|
+
this.#completeReconnectWelcome(message);
|
|
2047
|
+
}
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
case "session.ice_servers": {
|
|
2051
|
+
if (!this.#is("joined")) {
|
|
2052
|
+
this.#log.debug("frame ignored", { messageType: message.type, state });
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
const expiresAt = Date.parse(message.payload.expiresAt);
|
|
2056
|
+
if (Number.isFinite(expiresAt)) this.#delivery.iceServersUpdated(message.payload.iceServers, expiresAt);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
case "participant.joined": {
|
|
2060
|
+
if (isSelf || state === "connecting") return;
|
|
2061
|
+
const result = this.#roster.upsertJoined(from, message.payload.role, message.payload.displayName);
|
|
2062
|
+
if (state === "reconnecting") return;
|
|
2063
|
+
const participant = this.#roster.get(from);
|
|
2064
|
+
if (result === "added" && participant) {
|
|
2065
|
+
this.#emit("participant_joined", { participant });
|
|
2066
|
+
const present = this.#roster.committed && this.#is("joined") ? this.#roster.get(from) : void 0;
|
|
2067
|
+
if (present) this.#delivery.peerPresent(from, present.media);
|
|
2068
|
+
}
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
case "participant.left": {
|
|
2072
|
+
if (isSelf || state === "connecting" || state === "reconnecting") return;
|
|
2073
|
+
if (this.#roster.remove(from) && this.#roster.committed) {
|
|
2074
|
+
this.#delivery.peerLeft(from);
|
|
2075
|
+
this.#emit("participant_left", { participantId: from, reason: mapLeftReason(message.payload.reason) });
|
|
2076
|
+
}
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
case "participant.reconnected": {
|
|
2080
|
+
if (isSelf || !this.#roster.committed) return;
|
|
2081
|
+
if (!this.#roster.get(from)) {
|
|
2082
|
+
this.#roster.upsertJoined(from, "participant");
|
|
2083
|
+
const restored = this.#roster.get(from);
|
|
2084
|
+
if (restored && this.#is("joined")) this.#delivery.peerPresent(from, restored.media);
|
|
2085
|
+
}
|
|
2086
|
+
this.#delivery.peerReconnected(from);
|
|
2087
|
+
this.#emit("participant_reconnected", { participantId: from });
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
case "media.camera.started":
|
|
2091
|
+
case "media.camera.stopped":
|
|
2092
|
+
case "media.microphone.started":
|
|
2093
|
+
case "media.microphone.stopped":
|
|
2094
|
+
case "media.screen.started":
|
|
2095
|
+
case "media.screen.stopped": {
|
|
2096
|
+
const change = mediaFrame(message.type);
|
|
2097
|
+
if (isSelf || state === "connecting" || !change) return;
|
|
2098
|
+
if (this.#roster.setMedia(from, change.kind, change.enabled) && this.#roster.committed) {
|
|
2099
|
+
this.#emit("participant_media_changed", { participantId: from, kind: change.kind, enabled: change.enabled });
|
|
2100
|
+
this.#delivery.peerMediaChanged(from, change.kind, change.enabled);
|
|
2101
|
+
}
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
case "meeting.ended":
|
|
2105
|
+
case "meeting.terminated": {
|
|
2106
|
+
this.#log.info("meeting ended", { messageType: message.type });
|
|
2107
|
+
if (state === "connecting" || state === "joining") this.#failJoin(new MeetingError("meeting_ended"));
|
|
2108
|
+
else {
|
|
2109
|
+
this.#cancelReconnect("meeting_ended");
|
|
2110
|
+
this.#finish("meeting_ended");
|
|
2111
|
+
}
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
case "session.rejected": {
|
|
2115
|
+
const payload = message.payload;
|
|
2116
|
+
const isEndMeetingRejection = payload.ref_type === "meeting.end" || payload.refType === "meeting.end" || this.#endMeeting !== void 0;
|
|
2117
|
+
if (this.#endMeeting && isEndMeetingRejection) {
|
|
2118
|
+
const pending = this.#endMeeting;
|
|
2119
|
+
this.#clearEndMeetingTimer();
|
|
2120
|
+
this.#endMeeting = void 0;
|
|
2121
|
+
let mappedCode = "service_unavailable";
|
|
2122
|
+
if (payload.code === "role_not_allowed") mappedCode = "not_permitted";
|
|
2123
|
+
else if (payload.code === "rate_limited") mappedCode = "rate_limited";
|
|
2124
|
+
pending.reject(new MeetingError(mappedCode));
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
this.#onRejected(payload.code);
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
case "webrtc.offer":
|
|
2131
|
+
case "webrtc.answer":
|
|
2132
|
+
case "webrtc.ice_candidate":
|
|
2133
|
+
case "webrtc.ice_restart":
|
|
2134
|
+
this.#delivery.onPeerFrame(message);
|
|
2135
|
+
return;
|
|
2136
|
+
default:
|
|
2137
|
+
this.#log.debug("frame ignored", { messageType: message.type });
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
#startDelivery(mediaMode, iceServers2, iceServersExpireAt) {
|
|
2141
|
+
const delivery = this.#createDelivery(mediaMode, { env: this.#env, log: this.#log });
|
|
2142
|
+
this.#delivery = delivery;
|
|
2143
|
+
delivery.attach({
|
|
2144
|
+
participantId: this.#addressing.participantId,
|
|
2145
|
+
iceServers: iceServers2,
|
|
2146
|
+
iceServersExpireAt,
|
|
2147
|
+
send: (type, payload) => this.#delivery === delivery && (this.#session?.sendPeer(type, payload) ?? false),
|
|
2148
|
+
emit: (type, event) => {
|
|
2149
|
+
if (this.#delivery === delivery) this.#emit(type, event);
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
const local = this.#media.streams;
|
|
2153
|
+
for (const kind of MEDIA_KINDS) {
|
|
2154
|
+
const stream = local[kind];
|
|
2155
|
+
if (stream) delivery.localMediaChanged(kind, stream);
|
|
2156
|
+
}
|
|
2157
|
+
if (this.#delivery !== delivery || !this.#is("joined")) return;
|
|
2158
|
+
delivery.peersPresent(this.#roster.list().map((participant) => ({ id: participant.id, media: participant.media })));
|
|
2159
|
+
}
|
|
2160
|
+
/** Reports every remote media and connection status as gone, then closes the delivery. */
|
|
2161
|
+
#releaseRemoteMedia() {
|
|
2162
|
+
const delivery = this.#delivery;
|
|
2163
|
+
for (const participant of this.#roster.list()) {
|
|
2164
|
+
const media = delivery.remoteMedia(participant.id);
|
|
2165
|
+
for (const kind of MEDIA_KINDS) {
|
|
2166
|
+
if (media[kind]) this.#emit("remote_media_changed", { participantId: participant.id, kind, stream: null });
|
|
2167
|
+
}
|
|
2168
|
+
if (delivery.connectionStatus(participant.id) !== null) {
|
|
2169
|
+
this.#emit("media_connection_changed", { participantId: participant.id, status: null });
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
delivery.close();
|
|
2173
|
+
}
|
|
2174
|
+
#onRejected(code) {
|
|
2175
|
+
const error = mapRejection(code);
|
|
2176
|
+
if (this.#state === "connecting" || this.#state === "joining") {
|
|
2177
|
+
this.#failJoin(error);
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
if (this.#state === "reconnecting" && this.#reconnectOpen) {
|
|
2181
|
+
const session = this.#session;
|
|
2182
|
+
this.#session = void 0;
|
|
2183
|
+
session?.close();
|
|
2184
|
+
this.#failReconnectOpen(error, error.retryable);
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
if (code === "role_not_allowed") {
|
|
2188
|
+
const pending = this.#pendingRestricted;
|
|
2189
|
+
this.#clearPendingRestricted();
|
|
2190
|
+
if (pending) this.#media.rollback(pending.kind);
|
|
2191
|
+
}
|
|
2192
|
+
this.#log.warn("request rejected", { code: error.code });
|
|
2193
|
+
this.#emit("error", { error });
|
|
2194
|
+
}
|
|
2195
|
+
#onProtocolError() {
|
|
2196
|
+
if (this.#state === "closed") return;
|
|
2197
|
+
this.#emit("error", { error: new MeetingError("protocol_error") });
|
|
2198
|
+
}
|
|
2199
|
+
#onClose(session, code) {
|
|
2200
|
+
if (this.#session !== session) return;
|
|
2201
|
+
this.#session = void 0;
|
|
2202
|
+
this.#log.info("connection closed", { closeCode: code });
|
|
2203
|
+
switch (this.#state) {
|
|
2204
|
+
case "connecting":
|
|
2205
|
+
case "joining": {
|
|
2206
|
+
const { error } = mapClose(code, "before_joined");
|
|
2207
|
+
this.#failJoin(error ?? new MeetingError("connection_failed"));
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
case "joined": {
|
|
2211
|
+
const { reason, error } = mapClose(code, "after_joined");
|
|
2212
|
+
if (reason === "left" || reason === "meeting_ended" || reason === "session_replaced") {
|
|
2213
|
+
this.#finish(reason, error);
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
if (this.#isRecoverableClose(reason, error)) {
|
|
2217
|
+
this.#beginRecovery("meeting_service");
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
this.#finish(reason, error);
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
case "reconnecting": {
|
|
2224
|
+
const { reason, error } = mapClose(code, "after_joined");
|
|
2225
|
+
if (reason === "meeting_ended" || reason === "session_replaced") {
|
|
2226
|
+
this.#cancelReconnect(reason === "meeting_ended" ? "meeting_ended" : "session_replaced");
|
|
2227
|
+
this.#finish(reason, error);
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
if (reason === "left") {
|
|
2231
|
+
this.#cancelReconnect("left");
|
|
2232
|
+
this.#finish("left");
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
const tokenFailure = error?.code === "join_token_expired" || error?.code === "join_token_invalid";
|
|
2236
|
+
if (tokenFailure) this.#token = void 0;
|
|
2237
|
+
const retry = tokenFailure || this.#isRecoverableClose(reason, error);
|
|
2238
|
+
this.#failReconnectOpen(error ?? new MeetingError("connection_lost"), retry);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
case "leaving":
|
|
2242
|
+
this.#finish("left");
|
|
2243
|
+
return;
|
|
2244
|
+
default:
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
#failJoin(error) {
|
|
2249
|
+
if (this.#state !== "connecting" && this.#state !== "joining") return;
|
|
2250
|
+
const join = this.#join;
|
|
2251
|
+
this.#join = void 0;
|
|
2252
|
+
this.#teardown();
|
|
2253
|
+
this.#log.warn("join failed", { code: error.code });
|
|
2254
|
+
this.#setState("closed");
|
|
2255
|
+
this.#emit("closed", { reason: "join_failed", error });
|
|
2256
|
+
join?.reject(error);
|
|
2257
|
+
}
|
|
2258
|
+
#finish(reason, error) {
|
|
2259
|
+
if (this.#state === "closed") return;
|
|
2260
|
+
const end = this.#endMeeting;
|
|
2261
|
+
this.#endMeeting = void 0;
|
|
2262
|
+
this.#cancelReconnect(
|
|
2263
|
+
reason === "meeting_ended" ? "meeting_ended" : reason === "session_replaced" ? "session_replaced" : reason === "left" ? "left" : "hard_failure"
|
|
2264
|
+
);
|
|
2265
|
+
this.#teardown();
|
|
2266
|
+
this.#setState("closed");
|
|
2267
|
+
this.#emit("closed", error ? { reason, error } : { reason });
|
|
2268
|
+
this.#leave?.resolve();
|
|
2269
|
+
if (end) {
|
|
2270
|
+
if (reason === "meeting_ended") end.resolve();
|
|
2271
|
+
else end.reject(error ?? new MeetingError("engine_closed"));
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
#teardown() {
|
|
2275
|
+
this.#clearTimer("join");
|
|
2276
|
+
this.#clearTimer("leave");
|
|
2277
|
+
this.#clearEndMeetingTimer();
|
|
2278
|
+
this.#clearPendingRestricted();
|
|
2279
|
+
this.#media.releaseAll();
|
|
2280
|
+
this.#delivery.close();
|
|
2281
|
+
const session = this.#session;
|
|
2282
|
+
this.#session = void 0;
|
|
2283
|
+
session?.close();
|
|
2284
|
+
this.#roster.clear();
|
|
2285
|
+
this.#reconnectOpen = void 0;
|
|
2286
|
+
this.#reconnectOutcome = void 0;
|
|
2287
|
+
this.#preRecoveryParticipants = void 0;
|
|
2288
|
+
}
|
|
2289
|
+
#isRecoverableClose(reason, error) {
|
|
2290
|
+
if (reason === "connection_lost") return true;
|
|
2291
|
+
if (reason === "error" && error?.code === "service_unavailable") return true;
|
|
2292
|
+
return false;
|
|
2293
|
+
}
|
|
2294
|
+
#beginRecovery(trigger) {
|
|
2295
|
+
if (this.#state !== "joined" && this.#state !== "reconnecting") return;
|
|
2296
|
+
if (this.#endMeeting) {
|
|
2297
|
+
const end = this.#endMeeting;
|
|
2298
|
+
this.#endMeeting = void 0;
|
|
2299
|
+
this.#clearEndMeetingTimer();
|
|
2300
|
+
end.reject(new MeetingError("connection_lost"));
|
|
2301
|
+
}
|
|
2302
|
+
if (this.#state === "joined") this.#preRecoveryParticipants = this.#roster.list();
|
|
2303
|
+
this.#pauseDeliveryForRecovery();
|
|
2304
|
+
this.#setState("reconnecting");
|
|
2305
|
+
if (!this.#is("reconnecting")) return;
|
|
2306
|
+
if (!this.#reconnect) {
|
|
2307
|
+
this.#reconnect = new ReconnectController({
|
|
2308
|
+
env: this.#env,
|
|
2309
|
+
logger: this.#logger,
|
|
2310
|
+
windowMs: this.#reconnectWindowMs,
|
|
2311
|
+
open: () => this.#reconnectOpenAttempt(),
|
|
2312
|
+
onAbandoned: (reason) => this.#onReconnectAbandoned(reason)
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
this.#reconnect.start(trigger);
|
|
2316
|
+
}
|
|
2317
|
+
#pauseDeliveryForRecovery() {
|
|
2318
|
+
const delivery = this.#delivery;
|
|
2319
|
+
for (const participant of this.#roster.list()) {
|
|
2320
|
+
const media = delivery.remoteMedia(participant.id);
|
|
2321
|
+
for (const kind of MEDIA_KINDS) {
|
|
2322
|
+
if (media[kind]) this.#emit("remote_media_changed", { participantId: participant.id, kind, stream: null });
|
|
2323
|
+
}
|
|
2324
|
+
if (delivery.connectionStatus(participant.id) !== null) {
|
|
2325
|
+
this.#emit("media_connection_changed", { participantId: participant.id, status: null });
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
delivery.close();
|
|
2329
|
+
this.#delivery = new SignalingOnlyMediaDelivery();
|
|
2330
|
+
}
|
|
2331
|
+
async #reconnectOpenAttempt() {
|
|
2332
|
+
if (this.#state !== "reconnecting" || this.#disposed) return "cancelled";
|
|
2333
|
+
let token;
|
|
2334
|
+
try {
|
|
2335
|
+
token = await this.#resolveReconnectToken();
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
const meetingError = error instanceof MeetingError ? error : new MeetingError("join_token_invalid");
|
|
2338
|
+
this.#log.warn("reconnect token refresh failed", { code: meetingError.code });
|
|
2339
|
+
this.#pendingAbandonReason = "token_refresh_failed";
|
|
2340
|
+
return "failed";
|
|
2341
|
+
}
|
|
2342
|
+
this.#roster.reset();
|
|
2343
|
+
this.#reconnectOutcome = void 0;
|
|
2344
|
+
const open = deferred();
|
|
2345
|
+
this.#reconnectOpen = open;
|
|
2346
|
+
const attemptTimer = this.#env.setTimeout(() => {
|
|
2347
|
+
if (this.#reconnectOpen !== open) return;
|
|
2348
|
+
const session = this.#session;
|
|
2349
|
+
this.#session = void 0;
|
|
2350
|
+
session?.close();
|
|
2351
|
+
this.#failReconnectOpen(new MeetingError("join_timeout"), true);
|
|
2352
|
+
}, this.#joinTimeoutMs);
|
|
2353
|
+
this.#openSession(token);
|
|
2354
|
+
try {
|
|
2355
|
+
await open.promise;
|
|
2356
|
+
} catch {
|
|
2357
|
+
} finally {
|
|
2358
|
+
this.#env.clearTimeout(attemptTimer);
|
|
2359
|
+
}
|
|
2360
|
+
if (this.#state !== "reconnecting" && this.#state !== "joined") {
|
|
2361
|
+
return this.#reconnectOutcome ?? "cancelled";
|
|
2362
|
+
}
|
|
2363
|
+
return this.#reconnectOutcome ?? "failed";
|
|
2364
|
+
}
|
|
2365
|
+
async #resolveReconnectToken() {
|
|
2366
|
+
const current = this.#token;
|
|
2367
|
+
if (current !== void 0 && isJoinTokenUsable(current, this.#env.now())) return current;
|
|
2368
|
+
const refresh = this.#getJoinToken;
|
|
2369
|
+
if (!refresh) throw new MeetingError("join_token_expired");
|
|
2370
|
+
const next = await refresh();
|
|
2371
|
+
if (typeof next !== "string" || next.length === 0) throw new MeetingError("join_token_invalid");
|
|
2372
|
+
const addressing = decodeAddressing(next);
|
|
2373
|
+
if (addressing.meetingId !== this.#addressing.meetingId || addressing.participantId !== this.#addressing.participantId) {
|
|
2374
|
+
throw new MeetingError("join_token_invalid");
|
|
2375
|
+
}
|
|
2376
|
+
this.#token = next;
|
|
2377
|
+
return next;
|
|
2378
|
+
}
|
|
2379
|
+
#openSession(token) {
|
|
2380
|
+
const session = new SignalingSession({
|
|
2381
|
+
env: this.#env,
|
|
2382
|
+
realtimeUrl: this.#realtimeUrl,
|
|
2383
|
+
token,
|
|
2384
|
+
addressing: this.#addressing,
|
|
2385
|
+
log: this.#log,
|
|
2386
|
+
callbacks: {
|
|
2387
|
+
onMessage: (message) => this.#onMessage(message),
|
|
2388
|
+
onProtocolError: () => this.#onProtocolError(),
|
|
2389
|
+
onClose: (code) => this.#onClose(session, code)
|
|
2390
|
+
}
|
|
2391
|
+
});
|
|
2392
|
+
this.#session = session;
|
|
2393
|
+
session.open();
|
|
2394
|
+
}
|
|
2395
|
+
#completeReconnectWelcome(message) {
|
|
2396
|
+
const payload = message.payload;
|
|
2397
|
+
if (message.participantId !== this.#addressing.participantId) {
|
|
2398
|
+
this.#failReconnectOpen(new MeetingError("protocol_error"));
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
this.#roster.commit();
|
|
2402
|
+
const self = {
|
|
2403
|
+
id: this.#addressing.participantId,
|
|
2404
|
+
role: payload.role,
|
|
2405
|
+
joinedAt: payload.joinedAt
|
|
2406
|
+
};
|
|
2407
|
+
if (payload.displayName !== void 0) self.displayName = payload.displayName;
|
|
2408
|
+
this.#self = Object.freeze(self);
|
|
2409
|
+
this.#role = payload.role;
|
|
2410
|
+
this.#reconnectOutcome = "recovered";
|
|
2411
|
+
const open = this.#reconnectOpen;
|
|
2412
|
+
this.#reconnectOpen = void 0;
|
|
2413
|
+
this.#setState("joined");
|
|
2414
|
+
open?.resolve();
|
|
2415
|
+
if (!this.#is("joined")) {
|
|
2416
|
+
this.#preRecoveryParticipants = void 0;
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
this.#emitRecoveryRosterDiff();
|
|
2420
|
+
if (!this.#is("joined")) return;
|
|
2421
|
+
const expiresAt = payload.iceServersExpireAt !== void 0 ? Date.parse(payload.iceServersExpireAt) : NaN;
|
|
2422
|
+
this.#startDelivery(payload.mediaMode, payload.iceServers ?? [], Number.isFinite(expiresAt) ? expiresAt : null);
|
|
2423
|
+
}
|
|
2424
|
+
/** Announce peers who joined or left during the outage; leave survivors quiet (no join/leave flash). */
|
|
2425
|
+
#emitRecoveryRosterDiff() {
|
|
2426
|
+
const previous = this.#preRecoveryParticipants ?? [];
|
|
2427
|
+
this.#preRecoveryParticipants = void 0;
|
|
2428
|
+
const previousIds = new Set(previous.map((participant) => participant.id));
|
|
2429
|
+
const next = this.#roster.list();
|
|
2430
|
+
const nextIds = new Set(next.map((participant) => participant.id));
|
|
2431
|
+
for (const id of previousIds) {
|
|
2432
|
+
if (!nextIds.has(id)) this.#emit("participant_left", { participantId: id, reason: "disconnected" });
|
|
2433
|
+
}
|
|
2434
|
+
for (const participant of next) {
|
|
2435
|
+
if (!previousIds.has(participant.id)) this.#emit("participant_joined", { participant });
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
#failReconnectOpen(error, retry = false) {
|
|
2439
|
+
const open = this.#reconnectOpen;
|
|
2440
|
+
if (!open) return;
|
|
2441
|
+
this.#reconnectOpen = void 0;
|
|
2442
|
+
this.#reconnectOutcome = retry ? "retry" : "failed";
|
|
2443
|
+
if (!retry) this.#log.warn("reconnect open failed", { code: error.code });
|
|
2444
|
+
open.resolve();
|
|
2445
|
+
}
|
|
2446
|
+
#onReconnectAbandoned(reason) {
|
|
2447
|
+
if (this.#state === "closed") return;
|
|
2448
|
+
const actual = this.#pendingAbandonReason ?? reason;
|
|
2449
|
+
this.#pendingAbandonReason = void 0;
|
|
2450
|
+
this.#preRecoveryParticipants = void 0;
|
|
2451
|
+
this.#reconnect = void 0;
|
|
2452
|
+
if (actual === "meeting_ended") {
|
|
2453
|
+
this.#finish("meeting_ended");
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
if (actual === "session_replaced") {
|
|
2457
|
+
this.#finish("session_replaced", new MeetingError("session_replaced"));
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
if (actual === "left") {
|
|
2461
|
+
this.#finish("left");
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
const error = actual === "token_refresh_failed" ? new MeetingError("join_token_expired") : new MeetingError("connection_lost");
|
|
2465
|
+
this.#finish("connection_lost", error);
|
|
2466
|
+
}
|
|
2467
|
+
#cancelReconnect(reason) {
|
|
2468
|
+
const controller = this.#reconnect;
|
|
2469
|
+
this.#reconnect = void 0;
|
|
2470
|
+
this.#preRecoveryParticipants = void 0;
|
|
2471
|
+
if (this.#reconnectOpen) {
|
|
2472
|
+
this.#reconnectOutcome = "cancelled";
|
|
2473
|
+
const open = this.#reconnectOpen;
|
|
2474
|
+
this.#reconnectOpen = void 0;
|
|
2475
|
+
open.resolve();
|
|
2476
|
+
}
|
|
2477
|
+
controller?.cancel(reason);
|
|
2478
|
+
}
|
|
2479
|
+
#installVisibilityListener() {
|
|
2480
|
+
const doc = globalThis.document;
|
|
2481
|
+
if (!doc || typeof doc.addEventListener !== "function") return;
|
|
2482
|
+
this.#onVisibility = () => {
|
|
2483
|
+
if (doc.visibilityState !== "visible" || this.#disposed) return;
|
|
2484
|
+
if (this.#state === "reconnecting") {
|
|
2485
|
+
if (this.#reconnect && !this.#reconnect.active) this.#reconnect.start("visibility");
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
if (this.#state === "joined" && this.#session === void 0) {
|
|
2489
|
+
this.#beginRecovery("visibility");
|
|
2490
|
+
}
|
|
2491
|
+
};
|
|
2492
|
+
doc.addEventListener("visibilitychange", this.#onVisibility);
|
|
2493
|
+
}
|
|
2494
|
+
#removeVisibilityListener() {
|
|
2495
|
+
const doc = globalThis.document;
|
|
2496
|
+
const handler = this.#onVisibility;
|
|
2497
|
+
this.#onVisibility = void 0;
|
|
2498
|
+
if (doc && handler && typeof doc.removeEventListener === "function") {
|
|
2499
|
+
doc.removeEventListener("visibilitychange", handler);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
#clearEndMeetingTimer() {
|
|
2503
|
+
if (this.#endMeetingTimer !== void 0) this.#env.clearTimeout(this.#endMeetingTimer);
|
|
2504
|
+
this.#endMeetingTimer = void 0;
|
|
2505
|
+
}
|
|
2506
|
+
#clearTimer(which) {
|
|
2507
|
+
const handle = which === "join" ? this.#joinTimer : this.#leaveTimer;
|
|
2508
|
+
if (handle !== void 0) this.#env.clearTimeout(handle);
|
|
2509
|
+
if (which === "join") this.#joinTimer = void 0;
|
|
2510
|
+
else this.#leaveTimer = void 0;
|
|
2511
|
+
}
|
|
2512
|
+
/** Re-reads the state after emitting, since listeners may have changed it. */
|
|
2513
|
+
#is(state) {
|
|
2514
|
+
return this.#state === state;
|
|
2515
|
+
}
|
|
2516
|
+
#setState(next) {
|
|
2517
|
+
const previous = this.#state;
|
|
2518
|
+
if (previous === next) return;
|
|
2519
|
+
this.#state = next;
|
|
2520
|
+
this.#log.info("state changed", { state: next });
|
|
2521
|
+
this.#emit("state_changed", { state: next, previous });
|
|
2522
|
+
}
|
|
2523
|
+
#emit(type, event) {
|
|
2524
|
+
if (!this.#disposed) this.#emitter.emit(type, event);
|
|
2525
|
+
}
|
|
2526
|
+
};
|
|
2527
|
+
|
|
2528
|
+
// src/internal/selectedPair.ts
|
|
2529
|
+
function selectedCandidatePairReport(reports) {
|
|
2530
|
+
const byId = new Map(reports.map((report) => [report.id, report]));
|
|
2531
|
+
const selectedId = reports.find((r) => r.type === "transport" && r.selectedCandidatePairId)?.selectedCandidatePairId;
|
|
2532
|
+
return (selectedId ? byId.get(selectedId) : void 0) ?? reports.find((r) => r.type === "candidate-pair" && r.state === "succeeded" && r.nominated === true);
|
|
2533
|
+
}
|
|
2534
|
+
async function selectedPair(pc) {
|
|
2535
|
+
const reports = await statsOf(pc);
|
|
2536
|
+
if (!reports) return null;
|
|
2537
|
+
const pair = selectedCandidatePairReport(reports);
|
|
2538
|
+
if (!pair?.localCandidateId || !pair.remoteCandidateId) return null;
|
|
2539
|
+
const byId = new Map(reports.map((report) => [report.id, report]));
|
|
2540
|
+
const localType = byId.get(pair.localCandidateId)?.candidateType;
|
|
2541
|
+
const remoteType = byId.get(pair.remoteCandidateId)?.candidateType;
|
|
2542
|
+
if (!localType || !remoteType) return null;
|
|
2543
|
+
return { localId: pair.localCandidateId, localType, remoteType };
|
|
2544
|
+
}
|
|
2545
|
+
async function localCandidateIds(pc) {
|
|
2546
|
+
const reports = await statsOf(pc);
|
|
2547
|
+
if (!reports) return null;
|
|
2548
|
+
return new Set(reports.filter((r) => r.type === "local-candidate").map((r) => r.id));
|
|
2549
|
+
}
|
|
2550
|
+
async function statsOf(pc) {
|
|
2551
|
+
try {
|
|
2552
|
+
const collection = await pc.getStats();
|
|
2553
|
+
const reports = [];
|
|
2554
|
+
collection.forEach((report) => reports.push(report));
|
|
2555
|
+
return reports;
|
|
2556
|
+
} catch {
|
|
2557
|
+
return null;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
function pathReport(pair) {
|
|
2561
|
+
const localPath = kindOf(pair.localType);
|
|
2562
|
+
const remotePath = kindOf(pair.remoteType);
|
|
2563
|
+
return { path: localPath === "relayed" || remotePath === "relayed" ? "relayed" : "direct", localPath, remotePath };
|
|
2564
|
+
}
|
|
2565
|
+
function samePath(a, b) {
|
|
2566
|
+
return a.path === b.path && a.localPath === b.localPath && a.remotePath === b.remotePath;
|
|
2567
|
+
}
|
|
2568
|
+
function kindOf(candidateType) {
|
|
2569
|
+
return candidateType === "relay" ? "relayed" : "direct";
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// src/internal/qualitySample.ts
|
|
2573
|
+
var QUALITY_SAMPLE_INTERVAL_MS = 5e3;
|
|
2574
|
+
function finiteNumber(value) {
|
|
2575
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
2576
|
+
}
|
|
2577
|
+
function nonNegative(value) {
|
|
2578
|
+
return value !== void 0 && value >= 0 ? value : void 0;
|
|
2579
|
+
}
|
|
2580
|
+
async function collectReports(pc) {
|
|
2581
|
+
try {
|
|
2582
|
+
const collection = await pc.getStats();
|
|
2583
|
+
const reports = [];
|
|
2584
|
+
collection.forEach((report) => reports.push(report));
|
|
2585
|
+
return reports;
|
|
2586
|
+
} catch {
|
|
2587
|
+
return null;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
function pickRttMs(reports) {
|
|
2591
|
+
const pair = selectedCandidatePairReport(reports);
|
|
2592
|
+
if (pair) {
|
|
2593
|
+
const seconds = finiteNumber(pair.currentRoundTripTime);
|
|
2594
|
+
if (seconds !== void 0) return nonNegative(seconds * 1e3);
|
|
2595
|
+
}
|
|
2596
|
+
for (const report of reports) {
|
|
2597
|
+
if (report.type === "remote-inbound-rtp") {
|
|
2598
|
+
const seconds = finiteNumber(report.roundTripTime);
|
|
2599
|
+
if (seconds !== void 0) return nonNegative(seconds * 1e3);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return void 0;
|
|
2603
|
+
}
|
|
2604
|
+
function pickJitterMs(reports) {
|
|
2605
|
+
for (const report of reports) {
|
|
2606
|
+
if (report.type === "inbound-rtp") {
|
|
2607
|
+
const seconds = finiteNumber(report.jitter);
|
|
2608
|
+
if (seconds !== void 0) return nonNegative(seconds * 1e3);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
return void 0;
|
|
2612
|
+
}
|
|
2613
|
+
function pickPacketLossRatio(reports) {
|
|
2614
|
+
let lost = 0;
|
|
2615
|
+
let received = 0;
|
|
2616
|
+
let saw = false;
|
|
2617
|
+
for (const report of reports) {
|
|
2618
|
+
if (report.type !== "inbound-rtp") continue;
|
|
2619
|
+
const packetsLost = finiteNumber(report.packetsLost);
|
|
2620
|
+
const packetsReceived = finiteNumber(report.packetsReceived);
|
|
2621
|
+
if (packetsLost === void 0 && packetsReceived === void 0) continue;
|
|
2622
|
+
saw = true;
|
|
2623
|
+
lost += Math.max(0, packetsLost ?? 0);
|
|
2624
|
+
received += Math.max(0, packetsReceived ?? 0);
|
|
2625
|
+
}
|
|
2626
|
+
if (!saw) return void 0;
|
|
2627
|
+
const total = lost + received;
|
|
2628
|
+
if (total <= 0) return 0;
|
|
2629
|
+
return Math.min(1, lost / total);
|
|
2630
|
+
}
|
|
2631
|
+
function sumBytes(reports, type, key) {
|
|
2632
|
+
let total = 0;
|
|
2633
|
+
let saw = false;
|
|
2634
|
+
for (const report of reports) {
|
|
2635
|
+
if (report.type !== type) continue;
|
|
2636
|
+
const value = finiteNumber(report[key]);
|
|
2637
|
+
if (value === void 0) continue;
|
|
2638
|
+
saw = true;
|
|
2639
|
+
total += Math.max(0, value);
|
|
2640
|
+
}
|
|
2641
|
+
return saw ? total : void 0;
|
|
2642
|
+
}
|
|
2643
|
+
function bitrateBps(current, previous, elapsedMs) {
|
|
2644
|
+
if (current === void 0 || previous === void 0 || elapsedMs <= 0) return void 0;
|
|
2645
|
+
const delta = current - previous;
|
|
2646
|
+
if (delta < 0) return void 0;
|
|
2647
|
+
return delta * 8 * 1e3 / elapsedMs;
|
|
2648
|
+
}
|
|
2649
|
+
function pickFrames(reports) {
|
|
2650
|
+
let framesDropped;
|
|
2651
|
+
let frameWidth;
|
|
2652
|
+
let frameHeight;
|
|
2653
|
+
for (const report of reports) {
|
|
2654
|
+
if (report.type !== "inbound-rtp") continue;
|
|
2655
|
+
const dropped = finiteNumber(report.framesDropped);
|
|
2656
|
+
if (dropped !== void 0 && dropped >= 0) framesDropped = (framesDropped ?? 0) + dropped;
|
|
2657
|
+
const width = finiteNumber(report.frameWidth);
|
|
2658
|
+
const height = finiteNumber(report.frameHeight);
|
|
2659
|
+
if (width !== void 0 && width > 0) frameWidth = width;
|
|
2660
|
+
if (height !== void 0 && height > 0) frameHeight = height;
|
|
2661
|
+
}
|
|
2662
|
+
return {
|
|
2663
|
+
...framesDropped !== void 0 ? { framesDropped } : {},
|
|
2664
|
+
...frameWidth !== void 0 ? { frameWidth } : {},
|
|
2665
|
+
...frameHeight !== void 0 ? { frameHeight } : {}
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
function isQualityCodecToken(value) {
|
|
2669
|
+
if (value.length === 0 || value.length > MAX_QUALITY_CODEC_LENGTH) return false;
|
|
2670
|
+
if (value.includes("\r\n") || value.includes("\n")) return false;
|
|
2671
|
+
if (/^v=0/i.test(value) || /^candidate:/i.test(value) || /\bIN IP[46]\b/i.test(value)) return false;
|
|
2672
|
+
return /^[a-zA-Z0-9._+/-]+$/.test(value);
|
|
2673
|
+
}
|
|
2674
|
+
function pickCodec(reports) {
|
|
2675
|
+
const byId = new Map(reports.map((report) => [report.id, report]));
|
|
2676
|
+
for (const report of reports) {
|
|
2677
|
+
if (report.type !== "inbound-rtp" && report.type !== "outbound-rtp") continue;
|
|
2678
|
+
const codecId = report.codecId;
|
|
2679
|
+
if (!codecId) continue;
|
|
2680
|
+
const mime = byId.get(codecId)?.mimeType;
|
|
2681
|
+
if (typeof mime === "string" && isQualityCodecToken(mime)) return mime;
|
|
2682
|
+
}
|
|
2683
|
+
for (const report of reports) {
|
|
2684
|
+
if (report.type === "codec" && typeof report.mimeType === "string" && isQualityCodecToken(report.mimeType)) {
|
|
2685
|
+
return report.mimeType;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
return void 0;
|
|
2689
|
+
}
|
|
2690
|
+
async function buildQualitySample(pc, options) {
|
|
2691
|
+
const sampledAt = new Date(options.nowMs).toISOString();
|
|
2692
|
+
const base = {
|
|
2693
|
+
connectionState: options.connectionState,
|
|
2694
|
+
sampledAt
|
|
2695
|
+
};
|
|
2696
|
+
const reports = await collectReports(pc);
|
|
2697
|
+
if (!reports) {
|
|
2698
|
+
return { sample: { ...base, incomplete: true }, bytes: options.previous ?? null };
|
|
2699
|
+
}
|
|
2700
|
+
const rttMs = pickRttMs(reports);
|
|
2701
|
+
const jitterMs = pickJitterMs(reports);
|
|
2702
|
+
const packetLossRatio = pickPacketLossRatio(reports);
|
|
2703
|
+
const bytesSent = sumBytes(reports, "outbound-rtp", "bytesSent");
|
|
2704
|
+
const bytesReceived = sumBytes(reports, "inbound-rtp", "bytesReceived");
|
|
2705
|
+
const elapsedMs = options.previous ? Math.max(0, options.nowMs - options.previous.atMs) : 0;
|
|
2706
|
+
const sendBitrateBps = bitrateBps(bytesSent, options.previous?.sent, elapsedMs);
|
|
2707
|
+
const receiveBitrateBps = bitrateBps(bytesReceived, options.previous?.received, elapsedMs);
|
|
2708
|
+
const frames = pickFrames(reports);
|
|
2709
|
+
const codec = pickCodec(reports);
|
|
2710
|
+
let iceLocalCandidateType;
|
|
2711
|
+
let iceRemoteCandidateType;
|
|
2712
|
+
let pathFields = {};
|
|
2713
|
+
try {
|
|
2714
|
+
const pair = await selectedPair(pc);
|
|
2715
|
+
if (pair) {
|
|
2716
|
+
iceLocalCandidateType = pair.localType;
|
|
2717
|
+
iceRemoteCandidateType = pair.remoteType;
|
|
2718
|
+
pathFields = pathReport(pair);
|
|
2719
|
+
}
|
|
2720
|
+
} catch {
|
|
2721
|
+
}
|
|
2722
|
+
const hasCore = rttMs !== void 0 && jitterMs !== void 0 && packetLossRatio !== void 0 && sendBitrateBps !== void 0 && receiveBitrateBps !== void 0 && iceLocalCandidateType !== void 0;
|
|
2723
|
+
const sample = {
|
|
2724
|
+
...base,
|
|
2725
|
+
...rttMs !== void 0 ? { rttMs } : {},
|
|
2726
|
+
...jitterMs !== void 0 ? { jitterMs } : {},
|
|
2727
|
+
...packetLossRatio !== void 0 ? { packetLossRatio } : {},
|
|
2728
|
+
...sendBitrateBps !== void 0 ? { sendBitrateBps } : {},
|
|
2729
|
+
...receiveBitrateBps !== void 0 ? { receiveBitrateBps } : {},
|
|
2730
|
+
...iceLocalCandidateType ? { iceLocalCandidateType } : {},
|
|
2731
|
+
...iceRemoteCandidateType ? { iceRemoteCandidateType } : {},
|
|
2732
|
+
...pathFields,
|
|
2733
|
+
...frames,
|
|
2734
|
+
...codec ? { codec } : {},
|
|
2735
|
+
...!hasCore ? { incomplete: true } : {}
|
|
2736
|
+
};
|
|
2737
|
+
const bytes = bytesSent !== void 0 || bytesReceived !== void 0 ? {
|
|
2738
|
+
sent: bytesSent ?? options.previous?.sent ?? 0,
|
|
2739
|
+
received: bytesReceived ?? options.previous?.received ?? 0,
|
|
2740
|
+
atMs: options.nowMs
|
|
2741
|
+
} : options.previous ?? null;
|
|
2742
|
+
return { sample, bytes };
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// src/internal/peerLink.ts
|
|
2746
|
+
var MAX_QUEUED_CANDIDATES = 64;
|
|
2747
|
+
var CONNECTION_TIMEOUT_MS = 3e4;
|
|
2748
|
+
var SWITCH_ATTEMPT_TIMEOUT_MS = 15e3;
|
|
2749
|
+
var SWITCH_RETRY_DELAYS_MS = [5e3, 1e4, 2e4];
|
|
2750
|
+
var SWITCH_DEADLINE_MARGIN_MS = 5e3;
|
|
2751
|
+
var SWITCH_POLL_MS = 1e3;
|
|
2752
|
+
var SWITCH_INTERRUPTION_HOLD_MS = 5e3;
|
|
2753
|
+
var INTERRUPTION_RESTART_HOLD_MS = 3e3;
|
|
2754
|
+
var PATH_RECHECK_DELAYS_MS = [2e3, 1e4];
|
|
2755
|
+
var PATH_RECHECK_INTERVAL_MS = 3e4;
|
|
2756
|
+
function copyServers(iceServers2) {
|
|
2757
|
+
return iceServers2.map((server) => {
|
|
2758
|
+
const copy = { urls: [...server.urls] };
|
|
2759
|
+
if (server.username !== void 0) copy.username = server.username;
|
|
2760
|
+
if (server.credential !== void 0) copy.credential = server.credential;
|
|
2761
|
+
return copy;
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
function roleFor(selfId, peerId) {
|
|
2765
|
+
return selfId < peerId ? "offerer" : "answerer";
|
|
2766
|
+
}
|
|
2767
|
+
function coarseState(pc) {
|
|
2768
|
+
const state = typeof pc.connectionState === "string" ? pc.connectionState : pc.iceConnectionState;
|
|
2769
|
+
switch (state) {
|
|
2770
|
+
case "connected":
|
|
2771
|
+
case "completed":
|
|
2772
|
+
return "connected";
|
|
2773
|
+
case "disconnected":
|
|
2774
|
+
return "disconnected";
|
|
2775
|
+
case "failed":
|
|
2776
|
+
return "failed";
|
|
2777
|
+
case "closed":
|
|
2778
|
+
return "closed";
|
|
2779
|
+
default:
|
|
2780
|
+
return "connecting";
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
var PeerLink = class {
|
|
2784
|
+
peerId;
|
|
2785
|
+
role;
|
|
2786
|
+
#status = "connecting";
|
|
2787
|
+
#everConnected = false;
|
|
2788
|
+
#closed = false;
|
|
2789
|
+
#pc;
|
|
2790
|
+
#env;
|
|
2791
|
+
#log;
|
|
2792
|
+
#callbacks;
|
|
2793
|
+
#local;
|
|
2794
|
+
#queued = [];
|
|
2795
|
+
#remoteDescriptionSet = false;
|
|
2796
|
+
#offerHandled = false;
|
|
2797
|
+
#audio;
|
|
2798
|
+
#video;
|
|
2799
|
+
#screen;
|
|
2800
|
+
#streams = { camera: null, microphone: null, screen: null };
|
|
2801
|
+
#timer;
|
|
2802
|
+
#chain = Promise.resolve();
|
|
2803
|
+
#switch = null;
|
|
2804
|
+
#restarting = false;
|
|
2805
|
+
#interruptionHold;
|
|
2806
|
+
#restartHold;
|
|
2807
|
+
/** Local candidates gathered while an offer is being created; sent after it so the other side sees it first. */
|
|
2808
|
+
#heldLocal = null;
|
|
2809
|
+
#remoteOrigin;
|
|
2810
|
+
#lastReport = null;
|
|
2811
|
+
#recheckTimer;
|
|
2812
|
+
#qualityTimer;
|
|
2813
|
+
#qualityBytes = null;
|
|
2814
|
+
#qualityRunning = false;
|
|
2815
|
+
/** Throws when the connection cannot be created; the owner treats that as a negotiation failure. */
|
|
2816
|
+
constructor(options) {
|
|
2817
|
+
this.peerId = options.peerId;
|
|
2818
|
+
this.role = roleFor(options.selfId, options.peerId);
|
|
2819
|
+
this.#env = options.env;
|
|
2820
|
+
this.#log = options.log;
|
|
2821
|
+
this.#callbacks = options.callbacks;
|
|
2822
|
+
const { local } = options;
|
|
2823
|
+
this.#local = { ...local };
|
|
2824
|
+
const PeerConnection = options.env.RTCPeerConnection;
|
|
2825
|
+
if (!PeerConnection) throw new Error("peer connections unavailable");
|
|
2826
|
+
this.#pc = new PeerConnection({ iceServers: copyServers(options.iceServers) });
|
|
2827
|
+
this.#pc.onicecandidate = (event) => this.#onLocalCandidate(event.candidate);
|
|
2828
|
+
this.#pc.onconnectionstatechange = () => this.#onStateChange();
|
|
2829
|
+
this.#pc.oniceconnectionstatechange = () => {
|
|
2830
|
+
if (typeof this.#pc.connectionState !== "string") this.#onStateChange();
|
|
2831
|
+
};
|
|
2832
|
+
this.#timer = this.#env.setTimeout(() => {
|
|
2833
|
+
this.#timer = void 0;
|
|
2834
|
+
if (!this.#everConnected) this.#fail("connection_timeout");
|
|
2835
|
+
}, options.timeoutMs);
|
|
2836
|
+
this.#log.debug("media connection created", { participantId: this.peerId });
|
|
2837
|
+
}
|
|
2838
|
+
get status() {
|
|
2839
|
+
return this.#status;
|
|
2840
|
+
}
|
|
2841
|
+
get everConnected() {
|
|
2842
|
+
return this.#everConnected;
|
|
2843
|
+
}
|
|
2844
|
+
/** Usable for rendering: connected at least once, and neither failed nor closed. */
|
|
2845
|
+
get usable() {
|
|
2846
|
+
return this.#everConnected && !this.#closed && this.#status !== "failed";
|
|
2847
|
+
}
|
|
2848
|
+
/** The stable stream holding the received track of `kind`, once the transceivers exist. */
|
|
2849
|
+
remoteStream(kind) {
|
|
2850
|
+
return this.#streams[kind];
|
|
2851
|
+
}
|
|
2852
|
+
/** Offerer: adds the transceivers (audio → camera → screen) and sends the offer. */
|
|
2853
|
+
start() {
|
|
2854
|
+
if (this.role !== "offerer" || this.#audio) return;
|
|
2855
|
+
this.#run(async () => {
|
|
2856
|
+
this.#audio = this.#pc.addTransceiver("audio", { direction: "sendrecv" });
|
|
2857
|
+
this.#video = this.#pc.addTransceiver("video", { direction: "sendrecv" });
|
|
2858
|
+
this.#screen = this.#pc.addTransceiver("video", { direction: "sendrecv" });
|
|
2859
|
+
this.#bindStreams();
|
|
2860
|
+
await this.#attachAll();
|
|
2861
|
+
const offer = await this.#pc.createOffer();
|
|
2862
|
+
this.#heldLocal = [];
|
|
2863
|
+
await this.#pc.setLocalDescription(offer);
|
|
2864
|
+
if (this.#closed) return;
|
|
2865
|
+
this.#sendOffer(this.#pc.localDescription?.sdp ?? offer.sdp ?? "");
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Answerer: applies the offer and sends the answer. A later offer (a restart) is answered when no other
|
|
2870
|
+
* negotiation is in progress, keeping the transceivers and received streams; otherwise it is ignored.
|
|
2871
|
+
*/
|
|
2872
|
+
acceptOffer(sdp2) {
|
|
2873
|
+
if (this.role !== "answerer") {
|
|
2874
|
+
this.#log.debug("frame ignored", { messageType: "webrtc.offer", reason: "offerer" });
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
if (this.#offerHandled) {
|
|
2878
|
+
this.#acceptReoffer(sdp2);
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
this.#offerHandled = true;
|
|
2882
|
+
this.#remoteOrigin = sessionOrigin(sdp2);
|
|
2883
|
+
this.#run(async () => {
|
|
2884
|
+
await this.#pc.setRemoteDescription({ type: "offer", sdp: sdp2 });
|
|
2885
|
+
this.#remoteDescriptionSet = true;
|
|
2886
|
+
const transceivers = this.#pc.getTransceivers();
|
|
2887
|
+
this.#audio = transceivers[0];
|
|
2888
|
+
this.#video = transceivers[1];
|
|
2889
|
+
this.#screen = transceivers[2];
|
|
2890
|
+
for (const transceiver of [this.#audio, this.#video, this.#screen]) {
|
|
2891
|
+
if (transceiver) transceiver.direction = "sendrecv";
|
|
2892
|
+
}
|
|
2893
|
+
this.#bindStreams();
|
|
2894
|
+
await this.#attachAll();
|
|
2895
|
+
const answer = await this.#pc.createAnswer();
|
|
2896
|
+
await this.#pc.setLocalDescription(answer);
|
|
2897
|
+
if (this.#closed) return;
|
|
2898
|
+
const answerSdp = this.#pc.localDescription?.sdp ?? answer.sdp ?? "";
|
|
2899
|
+
this.#callbacks.sendAnswer({ targetParticipantId: this.peerId, sdp: answerSdp });
|
|
2900
|
+
await this.#flushCandidates();
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
/**
|
|
2904
|
+
* Answerer: whether `sdp` comes from a different connection than the one this link answered first, as when the
|
|
2905
|
+
* other side replaced its connection. Such an offer needs a fresh link; a restart keeps the same origin.
|
|
2906
|
+
*/
|
|
2907
|
+
startsNewSession(sdp2) {
|
|
2908
|
+
const origin = sessionOrigin(sdp2);
|
|
2909
|
+
return this.#offerHandled && this.#remoteOrigin !== void 0 && origin !== void 0 && origin !== this.#remoteOrigin;
|
|
2910
|
+
}
|
|
2911
|
+
/** Offerer: applies the answer while an offer is outstanding; otherwise ignored. */
|
|
2912
|
+
acceptAnswer(sdp2) {
|
|
2913
|
+
if (this.role !== "offerer") {
|
|
2914
|
+
this.#log.debug("frame ignored", { messageType: "webrtc.answer", reason: "answerer" });
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2917
|
+
this.#run(async () => {
|
|
2918
|
+
if (this.#pc.signalingState !== "have-local-offer") {
|
|
2919
|
+
this.#log.debug("frame ignored", { messageType: "webrtc.answer", state: this.#pc.signalingState });
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
if (!this.#restarting) {
|
|
2923
|
+
await this.#pc.setRemoteDescription({ type: "answer", sdp: sdp2 });
|
|
2924
|
+
this.#remoteDescriptionSet = true;
|
|
2925
|
+
await this.#flushCandidates();
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
this.#restarting = false;
|
|
2929
|
+
try {
|
|
2930
|
+
await this.#pc.setRemoteDescription({ type: "answer", sdp: sdp2 });
|
|
2931
|
+
} catch {
|
|
2932
|
+
this.#restartFailed();
|
|
2933
|
+
this.#rescheduleInterruptionRestartIfNeeded();
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
await this.#flushCandidates();
|
|
2937
|
+
this.#rescheduleInterruptionRestartIfNeeded();
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
/** Replaces the server list for future candidate gathering. The current connection is untouched. */
|
|
2941
|
+
updateIceServers(iceServers2) {
|
|
2942
|
+
if (this.#closed) return;
|
|
2943
|
+
try {
|
|
2944
|
+
this.#pc.setConfiguration({ ...this.#pc.getConfiguration(), iceServers: copyServers(iceServers2) });
|
|
2945
|
+
} catch {
|
|
2946
|
+
this.#log.debug("negotiation step failed", { participantId: this.peerId, reason: "configuration" });
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
/**
|
|
2950
|
+
* Moves to fresh candidates before `deadlineMs` when this side sends through a relay, whose allocation is tied to
|
|
2951
|
+
* the credentials about to expire. Direct paths and links that are not connected are left alone.
|
|
2952
|
+
*/
|
|
2953
|
+
planSwitch(deadlineMs) {
|
|
2954
|
+
if (this.#closed || this.#status !== "connected") return;
|
|
2955
|
+
const active = this.#switch;
|
|
2956
|
+
if (active) {
|
|
2957
|
+
active.deadline = deadlineMs;
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
void selectedPair(this.#pc).then((pair) => {
|
|
2961
|
+
if (this.#closed || this.#switch || this.#status !== "connected" || pair?.localType !== "relay") return;
|
|
2962
|
+
const started = {
|
|
2963
|
+
deadline: deadlineMs,
|
|
2964
|
+
attempt: 0,
|
|
2965
|
+
known: /* @__PURE__ */ new Set(),
|
|
2966
|
+
attemptTimer: void 0,
|
|
2967
|
+
pollTimer: void 0,
|
|
2968
|
+
retryTimer: void 0
|
|
2969
|
+
};
|
|
2970
|
+
this.#switch = started;
|
|
2971
|
+
this.#log.debug("media path switch started", { participantId: this.peerId });
|
|
2972
|
+
void this.#beginAttempt(started);
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
/**
|
|
2976
|
+
* Offerer: renegotiates with fresh candidates. Joins a restart whose answer is still outstanding instead of starting
|
|
2977
|
+
* another; a restart that already finished may predate the other side's fresh servers, so it does not count.
|
|
2978
|
+
*/
|
|
2979
|
+
restart(reason) {
|
|
2980
|
+
if (this.role !== "offerer" || this.#closed || this.#restarting) return false;
|
|
2981
|
+
this.#restarting = true;
|
|
2982
|
+
this.#log.debug("media connection restart", { participantId: this.peerId, reason });
|
|
2983
|
+
this.#chain = this.#chain.then(async () => {
|
|
2984
|
+
if (this.#closed) return;
|
|
2985
|
+
try {
|
|
2986
|
+
const offer = await this.#pc.createOffer({ iceRestart: true });
|
|
2987
|
+
this.#heldLocal = [];
|
|
2988
|
+
await this.#pc.setLocalDescription(offer);
|
|
2989
|
+
if (this.#closed) return;
|
|
2990
|
+
this.#sendOffer(this.#pc.localDescription?.sdp ?? offer.sdp ?? "");
|
|
2991
|
+
} catch {
|
|
2992
|
+
this.#heldLocal = null;
|
|
2993
|
+
this.#restarting = false;
|
|
2994
|
+
this.#restartFailed();
|
|
2995
|
+
this.#rescheduleInterruptionRestartIfNeeded();
|
|
2996
|
+
}
|
|
2997
|
+
});
|
|
2998
|
+
return true;
|
|
2999
|
+
}
|
|
3000
|
+
/** The browser regained connectivity or changed networks: an offerer restarts an interrupted connection now. */
|
|
3001
|
+
networkChanged() {
|
|
3002
|
+
if (this.#closed || this.#status !== "interrupted" || this.role !== "offerer") return;
|
|
3003
|
+
this.#clearRestartHold();
|
|
3004
|
+
if (this.restart("network")) this.#callbacks.recoveryStarted("network");
|
|
3005
|
+
else this.#scheduleInterruptionRestart();
|
|
3006
|
+
}
|
|
3007
|
+
addRemoteCandidate(candidate) {
|
|
3008
|
+
if (this.#closed) return;
|
|
3009
|
+
const copy = {
|
|
3010
|
+
candidate: candidate.candidate,
|
|
3011
|
+
sdpMid: candidate.sdpMid,
|
|
3012
|
+
sdpMLineIndex: candidate.sdpMLineIndex,
|
|
3013
|
+
...candidate.usernameFragment !== void 0 ? { usernameFragment: candidate.usernameFragment } : {}
|
|
3014
|
+
};
|
|
3015
|
+
if (!this.#remoteDescriptionSet) {
|
|
3016
|
+
if (this.#queued.length >= MAX_QUEUED_CANDIDATES) {
|
|
3017
|
+
this.#log.debug("late candidate dropped", { participantId: this.peerId, reason: "queue_full" });
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
this.#queued.push(copy);
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
void this.#applyCandidate(copy);
|
|
3024
|
+
}
|
|
3025
|
+
/** Sends `track` for `kind` (or nothing for `null`). Skipped when that track is already being sent. */
|
|
3026
|
+
attachLocal(kind, track) {
|
|
3027
|
+
this.#local[kind] = track;
|
|
3028
|
+
if (this.#closed) return;
|
|
3029
|
+
const sender = (kind === "camera" ? this.#video : kind === "screen" ? this.#screen : this.#audio)?.sender;
|
|
3030
|
+
if (!sender || sender.track === track) return;
|
|
3031
|
+
sender.replaceTrack(track).catch(() => {
|
|
3032
|
+
this.#log.debug("negotiation step failed", { participantId: this.peerId, kind, reason: "replace_track" });
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
/** Closes the connection and stops received tracks. Reports nothing. Idempotent. */
|
|
3036
|
+
close() {
|
|
3037
|
+
if (this.#closed) return;
|
|
3038
|
+
this.#closed = true;
|
|
3039
|
+
this.#clearTimer();
|
|
3040
|
+
this.#endSwitch();
|
|
3041
|
+
this.#clearInterruptionHold();
|
|
3042
|
+
this.#clearRestartHold();
|
|
3043
|
+
this.#clearRecheck();
|
|
3044
|
+
this.#stopQualitySampler();
|
|
3045
|
+
this.#pc.onicecandidate = null;
|
|
3046
|
+
this.#pc.onconnectionstatechange = null;
|
|
3047
|
+
this.#pc.oniceconnectionstatechange = null;
|
|
3048
|
+
for (const stream of Object.values(this.#streams)) {
|
|
3049
|
+
for (const track of stream?.getTracks() ?? []) {
|
|
3050
|
+
try {
|
|
3051
|
+
track.stop();
|
|
3052
|
+
} catch {
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
try {
|
|
3057
|
+
this.#pc.close();
|
|
3058
|
+
} catch {
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
#run(step) {
|
|
3062
|
+
this.#chain = this.#chain.then(async () => {
|
|
3063
|
+
if (this.#closed) return;
|
|
3064
|
+
try {
|
|
3065
|
+
await step();
|
|
3066
|
+
} catch {
|
|
3067
|
+
if (this.#closed) return;
|
|
3068
|
+
this.#log.debug("negotiation step failed", { participantId: this.peerId });
|
|
3069
|
+
this.#fail("negotiation_failed");
|
|
3070
|
+
}
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
#acceptReoffer(sdp2) {
|
|
3074
|
+
this.#run(async () => {
|
|
3075
|
+
if (this.#pc.signalingState !== "stable") {
|
|
3076
|
+
this.#log.debug("frame ignored", { messageType: "webrtc.offer", reason: "busy" });
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
try {
|
|
3080
|
+
await this.#pc.setRemoteDescription({ type: "offer", sdp: sdp2 });
|
|
3081
|
+
const answer = await this.#pc.createAnswer();
|
|
3082
|
+
await this.#pc.setLocalDescription(answer);
|
|
3083
|
+
if (this.#closed) return;
|
|
3084
|
+
const answerSdp = this.#pc.localDescription?.sdp ?? answer.sdp ?? "";
|
|
3085
|
+
this.#callbacks.sendAnswer({ targetParticipantId: this.peerId, sdp: answerSdp });
|
|
3086
|
+
} catch {
|
|
3087
|
+
this.#restartFailed();
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
await this.#flushCandidates();
|
|
3091
|
+
});
|
|
3092
|
+
}
|
|
3093
|
+
/** A restart negotiation step failed: the connection stays as it is and the current attempt, if any, fails. */
|
|
3094
|
+
#restartFailed() {
|
|
3095
|
+
if (this.#closed) return;
|
|
3096
|
+
this.#log.debug("negotiation step failed", { participantId: this.peerId, reason: "restart" });
|
|
3097
|
+
const current = this.#switch;
|
|
3098
|
+
if (current?.attemptTimer !== void 0) this.#attemptFailed(current);
|
|
3099
|
+
}
|
|
3100
|
+
async #beginAttempt(current) {
|
|
3101
|
+
current.attempt += 1;
|
|
3102
|
+
const known = await localCandidateIds(this.#pc);
|
|
3103
|
+
if (this.#switch !== current || this.#closed) return;
|
|
3104
|
+
current.known = known ?? /* @__PURE__ */ new Set();
|
|
3105
|
+
current.attemptTimer = this.#env.setTimeout(() => this.#attemptFailed(current), SWITCH_ATTEMPT_TIMEOUT_MS);
|
|
3106
|
+
if (this.role === "offerer") {
|
|
3107
|
+
this.restart("refresh");
|
|
3108
|
+
} else if (!this.#callbacks.requestRestart()) {
|
|
3109
|
+
this.#endSwitch();
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
this.#schedulePoll(current);
|
|
3113
|
+
}
|
|
3114
|
+
#schedulePoll(current) {
|
|
3115
|
+
current.pollTimer = this.#env.setTimeout(() => {
|
|
3116
|
+
current.pollTimer = void 0;
|
|
3117
|
+
void this.#checkSwitch(current);
|
|
3118
|
+
}, SWITCH_POLL_MS);
|
|
3119
|
+
}
|
|
3120
|
+
async #checkSwitch(current) {
|
|
3121
|
+
if (this.#switch !== current || current.attemptTimer === void 0) return;
|
|
3122
|
+
const pair = coarseState(this.#pc) === "connected" ? await selectedPair(this.#pc) : null;
|
|
3123
|
+
if (this.#switch !== current || current.attemptTimer === void 0) return;
|
|
3124
|
+
if (!pair || current.known.has(pair.localId)) {
|
|
3125
|
+
this.#schedulePoll(current);
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
this.#endSwitch();
|
|
3129
|
+
const report = pathReport(pair);
|
|
3130
|
+
this.#log.debug("media path switch completed", { participantId: this.peerId, attempt: current.attempt, ...report });
|
|
3131
|
+
this.#sendPath(report);
|
|
3132
|
+
this.#callbacks.switchResult("completed");
|
|
3133
|
+
}
|
|
3134
|
+
#attemptFailed(current) {
|
|
3135
|
+
if (this.#switch !== current || this.#closed) return;
|
|
3136
|
+
this.#clearSwitchTimers(current);
|
|
3137
|
+
this.#restarting = false;
|
|
3138
|
+
this.#rescheduleInterruptionRestartIfNeeded();
|
|
3139
|
+
this.#log.debug("media path switch attempt failed", { participantId: this.peerId, attempt: current.attempt });
|
|
3140
|
+
this.#callbacks.switchResult("failed");
|
|
3141
|
+
if (this.#switch !== current || this.#closed) return;
|
|
3142
|
+
const delay = SWITCH_RETRY_DELAYS_MS[Math.min(current.attempt, SWITCH_RETRY_DELAYS_MS.length) - 1] ?? 0;
|
|
3143
|
+
if (this.#env.now() + delay >= current.deadline - SWITCH_DEADLINE_MARGIN_MS) {
|
|
3144
|
+
this.#endSwitch();
|
|
3145
|
+
this.#log.debug("media path switch stopped", { participantId: this.peerId, attempt: current.attempt });
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
current.retryTimer = this.#env.setTimeout(() => {
|
|
3149
|
+
current.retryTimer = void 0;
|
|
3150
|
+
if (this.#switch === current && !this.#closed) void this.#beginAttempt(current);
|
|
3151
|
+
}, delay);
|
|
3152
|
+
}
|
|
3153
|
+
#endSwitch() {
|
|
3154
|
+
const current = this.#switch;
|
|
3155
|
+
if (!current) return;
|
|
3156
|
+
this.#switch = null;
|
|
3157
|
+
this.#clearSwitchTimers(current);
|
|
3158
|
+
}
|
|
3159
|
+
#clearSwitchTimers(current) {
|
|
3160
|
+
for (const timer of [current.attemptTimer, current.pollTimer, current.retryTimer]) {
|
|
3161
|
+
if (timer !== void 0) this.#env.clearTimeout(timer);
|
|
3162
|
+
}
|
|
3163
|
+
current.attemptTimer = void 0;
|
|
3164
|
+
current.pollTimer = void 0;
|
|
3165
|
+
current.retryTimer = void 0;
|
|
3166
|
+
}
|
|
3167
|
+
#clearInterruptionHold() {
|
|
3168
|
+
if (this.#interruptionHold !== void 0) this.#env.clearTimeout(this.#interruptionHold);
|
|
3169
|
+
this.#interruptionHold = void 0;
|
|
3170
|
+
}
|
|
3171
|
+
#clearRestartHold() {
|
|
3172
|
+
if (this.#restartHold !== void 0) this.#env.clearTimeout(this.#restartHold);
|
|
3173
|
+
this.#restartHold = void 0;
|
|
3174
|
+
}
|
|
3175
|
+
/** When a concurrent restart ends and the link is still interrupted, arm another recovery. */
|
|
3176
|
+
#rescheduleInterruptionRestartIfNeeded() {
|
|
3177
|
+
if (this.#closed || this.#status !== "interrupted") return;
|
|
3178
|
+
this.#scheduleInterruptionRestart();
|
|
3179
|
+
}
|
|
3180
|
+
/** Offerer: restarts the connection when an interruption outlasts the hold, without waiting for it to fail. */
|
|
3181
|
+
#scheduleInterruptionRestart() {
|
|
3182
|
+
if (this.role !== "offerer" || this.#restartHold !== void 0) return;
|
|
3183
|
+
this.#restartHold = this.#env.setTimeout(() => {
|
|
3184
|
+
this.#restartHold = void 0;
|
|
3185
|
+
if (this.#closed || this.#status !== "interrupted" || coarseState(this.#pc) === "connected") return;
|
|
3186
|
+
if (this.restart("interruption")) this.#callbacks.recoveryStarted("media");
|
|
3187
|
+
else this.#scheduleInterruptionRestart();
|
|
3188
|
+
}, INTERRUPTION_RESTART_HOLD_MS);
|
|
3189
|
+
}
|
|
3190
|
+
#bindStreams() {
|
|
3191
|
+
const Stream = this.#env.MediaStream;
|
|
3192
|
+
if (!Stream) return;
|
|
3193
|
+
if (this.#audio && !this.#streams.microphone) this.#streams.microphone = new Stream([this.#audio.receiver.track]);
|
|
3194
|
+
if (this.#video && !this.#streams.camera) this.#streams.camera = new Stream([this.#video.receiver.track]);
|
|
3195
|
+
if (this.#screen && !this.#streams.screen) this.#streams.screen = new Stream([this.#screen.receiver.track]);
|
|
3196
|
+
}
|
|
3197
|
+
async #attachAll() {
|
|
3198
|
+
const pairs = [
|
|
3199
|
+
[this.#audio, this.#local.microphone],
|
|
3200
|
+
[this.#video, this.#local.camera],
|
|
3201
|
+
[this.#screen, this.#local.screen]
|
|
3202
|
+
];
|
|
3203
|
+
for (const [transceiver, track] of pairs) {
|
|
3204
|
+
if (transceiver && track && transceiver.sender.track !== track) await transceiver.sender.replaceTrack(track);
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
async #flushCandidates() {
|
|
3208
|
+
for (const candidate of this.#queued.splice(0)) await this.#applyCandidate(candidate);
|
|
3209
|
+
}
|
|
3210
|
+
async #applyCandidate(candidate) {
|
|
3211
|
+
if (this.#closed) return;
|
|
3212
|
+
try {
|
|
3213
|
+
await this.#pc.addIceCandidate(candidate);
|
|
3214
|
+
} catch {
|
|
3215
|
+
this.#log.debug("late candidate dropped", { participantId: this.peerId, reason: "rejected" });
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
/** Sends the offer, then the candidates gathered while it was being created. */
|
|
3219
|
+
#sendOffer(sdp2) {
|
|
3220
|
+
this.#callbacks.sendOffer({ targetParticipantId: this.peerId, sdp: sdp2 });
|
|
3221
|
+
const held = this.#heldLocal;
|
|
3222
|
+
this.#heldLocal = null;
|
|
3223
|
+
for (const candidate of held ?? []) this.#callbacks.sendCandidate({ targetParticipantId: this.peerId, candidate });
|
|
3224
|
+
}
|
|
3225
|
+
#onLocalCandidate(candidate) {
|
|
3226
|
+
if (this.#closed || !candidate || candidate.candidate === "") return;
|
|
3227
|
+
const payload = {
|
|
3228
|
+
candidate: candidate.candidate,
|
|
3229
|
+
sdpMid: candidate.sdpMid ?? null,
|
|
3230
|
+
sdpMLineIndex: candidate.sdpMLineIndex ?? null
|
|
3231
|
+
};
|
|
3232
|
+
if (typeof candidate.usernameFragment === "string") payload.usernameFragment = candidate.usernameFragment;
|
|
3233
|
+
if (this.#heldLocal) this.#heldLocal.push(payload);
|
|
3234
|
+
else this.#callbacks.sendCandidate({ targetParticipantId: this.peerId, candidate: payload });
|
|
3235
|
+
}
|
|
3236
|
+
#onStateChange() {
|
|
3237
|
+
if (this.#closed || this.#status === "failed") return;
|
|
3238
|
+
const state = coarseState(this.#pc);
|
|
3239
|
+
this.#log.debug("media connection state", { participantId: this.peerId, state });
|
|
3240
|
+
switch (state) {
|
|
3241
|
+
case "connected":
|
|
3242
|
+
this.#everConnected = true;
|
|
3243
|
+
this.#clearTimer();
|
|
3244
|
+
this.#clearInterruptionHold();
|
|
3245
|
+
if (this.#setStatus("connected")) void this.#reportPath();
|
|
3246
|
+
this.#startQualitySampler();
|
|
3247
|
+
return;
|
|
3248
|
+
case "disconnected":
|
|
3249
|
+
if (!this.#everConnected) return;
|
|
3250
|
+
if (this.#switch && this.#status === "connected") {
|
|
3251
|
+
this.#interruptionHold ??= this.#env.setTimeout(() => {
|
|
3252
|
+
this.#interruptionHold = void 0;
|
|
3253
|
+
if (!this.#closed && coarseState(this.#pc) !== "connected") this.#setStatus("interrupted");
|
|
3254
|
+
}, SWITCH_INTERRUPTION_HOLD_MS);
|
|
3255
|
+
return;
|
|
3256
|
+
}
|
|
3257
|
+
this.#setStatus("interrupted");
|
|
3258
|
+
this.#startQualitySampler();
|
|
3259
|
+
return;
|
|
3260
|
+
case "failed":
|
|
3261
|
+
this.#fail("connection_failed");
|
|
3262
|
+
return;
|
|
3263
|
+
default:
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
#qualityConnectionState() {
|
|
3268
|
+
if (this.#status === "interrupted") return "interrupted";
|
|
3269
|
+
if (this.#status === "failed") return "failed";
|
|
3270
|
+
if (this.#status === "connected") return "connected";
|
|
3271
|
+
return "connecting";
|
|
3272
|
+
}
|
|
3273
|
+
#startQualitySampler() {
|
|
3274
|
+
if (this.#closed || this.#qualityRunning) return;
|
|
3275
|
+
this.#qualityRunning = true;
|
|
3276
|
+
this.#scheduleQualitySample();
|
|
3277
|
+
}
|
|
3278
|
+
#stopQualitySampler() {
|
|
3279
|
+
this.#qualityRunning = false;
|
|
3280
|
+
if (this.#qualityTimer !== void 0) this.#env.clearTimeout(this.#qualityTimer);
|
|
3281
|
+
this.#qualityTimer = void 0;
|
|
3282
|
+
this.#qualityBytes = null;
|
|
3283
|
+
}
|
|
3284
|
+
#scheduleQualitySample() {
|
|
3285
|
+
if (!this.#qualityRunning || this.#closed) return;
|
|
3286
|
+
this.#qualityTimer = this.#env.setTimeout(() => {
|
|
3287
|
+
this.#qualityTimer = void 0;
|
|
3288
|
+
void this.#emitQualitySample();
|
|
3289
|
+
}, QUALITY_SAMPLE_INTERVAL_MS);
|
|
3290
|
+
}
|
|
3291
|
+
async #emitQualitySample() {
|
|
3292
|
+
if (!this.#qualityRunning || this.#closed) return;
|
|
3293
|
+
try {
|
|
3294
|
+
const result = await buildQualitySample(this.#pc, {
|
|
3295
|
+
connectionState: this.#qualityConnectionState(),
|
|
3296
|
+
nowMs: this.#env.now(),
|
|
3297
|
+
previous: this.#qualityBytes
|
|
3298
|
+
});
|
|
3299
|
+
if (this.#closed || !this.#qualityRunning) return;
|
|
3300
|
+
this.#qualityBytes = result.bytes;
|
|
3301
|
+
this.#log.debug("webrtc quality sample", {
|
|
3302
|
+
participantId: this.peerId,
|
|
3303
|
+
state: result.sample.connectionState,
|
|
3304
|
+
...result.sample.rttMs !== void 0 ? { rttMs: result.sample.rttMs } : {},
|
|
3305
|
+
...result.sample.jitterMs !== void 0 ? { jitterMs: result.sample.jitterMs } : {},
|
|
3306
|
+
...result.sample.packetLossRatio !== void 0 ? { packetLossRatio: result.sample.packetLossRatio } : {},
|
|
3307
|
+
...result.sample.sendBitrateBps !== void 0 ? { sendBitrateBps: result.sample.sendBitrateBps } : {},
|
|
3308
|
+
...result.sample.receiveBitrateBps !== void 0 ? { receiveBitrateBps: result.sample.receiveBitrateBps } : {},
|
|
3309
|
+
...result.sample.path ? { path: result.sample.path } : {},
|
|
3310
|
+
...result.sample.incomplete ? { incomplete: true } : {}
|
|
3311
|
+
});
|
|
3312
|
+
this.#callbacks.qualitySample(result.sample);
|
|
3313
|
+
} catch {
|
|
3314
|
+
}
|
|
3315
|
+
if (!this.#closed && this.#qualityRunning) this.#scheduleQualitySample();
|
|
3316
|
+
}
|
|
3317
|
+
/** True when the status changed. */
|
|
3318
|
+
#setStatus(status, reason) {
|
|
3319
|
+
if (this.#status === status) return false;
|
|
3320
|
+
this.#status = status;
|
|
3321
|
+
if (status === "interrupted") this.#scheduleInterruptionRestart();
|
|
3322
|
+
else this.#clearRestartHold();
|
|
3323
|
+
this.#callbacks.statusChanged(status, reason);
|
|
3324
|
+
return true;
|
|
3325
|
+
}
|
|
3326
|
+
async #reportPath() {
|
|
3327
|
+
const pair = await selectedPair(this.#pc);
|
|
3328
|
+
if (this.#closed || this.#status !== "connected") return;
|
|
3329
|
+
this.#sendPath(pair ? pathReport(pair) : null);
|
|
3330
|
+
}
|
|
3331
|
+
/** Reports the path and reads it again on the recheck schedule, starting over from the first delay. */
|
|
3332
|
+
#sendPath(report) {
|
|
3333
|
+
this.#lastReport = report;
|
|
3334
|
+
this.#callbacks.connected(report);
|
|
3335
|
+
this.#scheduleRecheck(0);
|
|
3336
|
+
}
|
|
3337
|
+
#scheduleRecheck(step) {
|
|
3338
|
+
this.#clearRecheck();
|
|
3339
|
+
const at = PATH_RECHECK_DELAYS_MS[step];
|
|
3340
|
+
const delay = at === void 0 ? PATH_RECHECK_INTERVAL_MS : at - (PATH_RECHECK_DELAYS_MS[step - 1] ?? 0);
|
|
3341
|
+
this.#recheckTimer = this.#env.setTimeout(() => {
|
|
3342
|
+
this.#recheckTimer = void 0;
|
|
3343
|
+
void this.#recheckPath(step);
|
|
3344
|
+
}, delay);
|
|
3345
|
+
}
|
|
3346
|
+
/**
|
|
3347
|
+
* The browser can settle on the path after the first report, for example when the other side's relay address was
|
|
3348
|
+
* first seen as peer-reflexive. A changed path is reported again; an unchanged one sends nothing.
|
|
3349
|
+
*/
|
|
3350
|
+
async #recheckPath(step) {
|
|
3351
|
+
if (this.#closed) return;
|
|
3352
|
+
const idle = () => this.#status !== "connected" || this.#switch !== null;
|
|
3353
|
+
const pair = idle() ? null : await selectedPair(this.#pc);
|
|
3354
|
+
if (this.#closed || this.#recheckTimer !== void 0) return;
|
|
3355
|
+
const report = pair && !idle() ? pathReport(pair) : null;
|
|
3356
|
+
if (report && !(this.#lastReport && samePath(report, this.#lastReport))) {
|
|
3357
|
+
this.#log.debug("media path updated", { participantId: this.peerId, ...report });
|
|
3358
|
+
this.#sendPath(report);
|
|
3359
|
+
return;
|
|
3360
|
+
}
|
|
3361
|
+
this.#scheduleRecheck(step + 1);
|
|
3362
|
+
}
|
|
3363
|
+
#clearRecheck() {
|
|
3364
|
+
if (this.#recheckTimer !== void 0) this.#env.clearTimeout(this.#recheckTimer);
|
|
3365
|
+
this.#recheckTimer = void 0;
|
|
3366
|
+
}
|
|
3367
|
+
#fail(reason) {
|
|
3368
|
+
if (this.#closed || this.#status === "failed") return;
|
|
3369
|
+
this.#clearTimer();
|
|
3370
|
+
this.#setStatus("failed", reason);
|
|
3371
|
+
this.close();
|
|
3372
|
+
}
|
|
3373
|
+
#clearTimer() {
|
|
3374
|
+
if (this.#timer !== void 0) this.#env.clearTimeout(this.#timer);
|
|
3375
|
+
this.#timer = void 0;
|
|
3376
|
+
}
|
|
3377
|
+
};
|
|
3378
|
+
function sessionOrigin(sdp2) {
|
|
3379
|
+
for (const line of sdp2.split(/\r?\n/)) {
|
|
3380
|
+
if (line.startsWith("o=")) return line.split(" ")[1];
|
|
3381
|
+
}
|
|
3382
|
+
return void 0;
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
// src/internal/remoteMedia.ts
|
|
3386
|
+
var KINDS2 = ["camera", "microphone", "screen"];
|
|
3387
|
+
function computeRemoteMedia(inputs) {
|
|
3388
|
+
if (!inputs.active || !inputs.usable) return NO_REMOTE_MEDIA;
|
|
3389
|
+
const camera = inputs.announced.camera ? inputs.streams.camera : null;
|
|
3390
|
+
const microphone = inputs.announced.microphone ? inputs.streams.microphone : null;
|
|
3391
|
+
const screen = inputs.announced.screen ? inputs.streams.screen : null;
|
|
3392
|
+
if (camera === null && microphone === null && screen === null) return NO_REMOTE_MEDIA;
|
|
3393
|
+
return Object.freeze({ camera, microphone, screen });
|
|
3394
|
+
}
|
|
3395
|
+
var RemoteMediaTracker = class {
|
|
3396
|
+
#current = /* @__PURE__ */ new Map();
|
|
3397
|
+
get(participantId) {
|
|
3398
|
+
return this.#current.get(participantId) ?? NO_REMOTE_MEDIA;
|
|
3399
|
+
}
|
|
3400
|
+
update(participantId, next, emit) {
|
|
3401
|
+
const previous = this.get(participantId);
|
|
3402
|
+
if (next === NO_REMOTE_MEDIA) this.#current.delete(participantId);
|
|
3403
|
+
else this.#current.set(participantId, next);
|
|
3404
|
+
for (const kind of KINDS2) {
|
|
3405
|
+
if (previous[kind] !== next[kind]) emit({ participantId, kind, stream: next[kind] });
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
/** Forgets everything without reporting. */
|
|
3409
|
+
clear() {
|
|
3410
|
+
this.#current.clear();
|
|
3411
|
+
}
|
|
3412
|
+
};
|
|
3413
|
+
|
|
3414
|
+
// src/internal/peerMediaDelivery.ts
|
|
3415
|
+
var EARLY_FRAME_HOLD_MS = 5e3;
|
|
3416
|
+
var MAX_EARLY_CANDIDATES = 64;
|
|
3417
|
+
var NOTHING_ANNOUNCED = Object.freeze({ camera: false, microphone: false, screen: false });
|
|
3418
|
+
var PeerMediaDelivery = class {
|
|
3419
|
+
#env;
|
|
3420
|
+
#log;
|
|
3421
|
+
#timeoutMs;
|
|
3422
|
+
#recoveryWindowMs;
|
|
3423
|
+
#episode = null;
|
|
3424
|
+
#unsubscribeNetwork;
|
|
3425
|
+
#context;
|
|
3426
|
+
/** The latest server list; links created later use it. */
|
|
3427
|
+
#iceServers = [];
|
|
3428
|
+
/** Whether the platform relays restart requests and results (it announces credential expiry). */
|
|
3429
|
+
#serverSupportsRestart = false;
|
|
3430
|
+
#inert = false;
|
|
3431
|
+
#closed = false;
|
|
3432
|
+
#present = /* @__PURE__ */ new Map();
|
|
3433
|
+
#activeId = null;
|
|
3434
|
+
#link = null;
|
|
3435
|
+
#status = /* @__PURE__ */ new Map();
|
|
3436
|
+
#remote = new RemoteMediaTracker();
|
|
3437
|
+
#limitReported = /* @__PURE__ */ new Set();
|
|
3438
|
+
#early = /* @__PURE__ */ new Map();
|
|
3439
|
+
#heldOffer;
|
|
3440
|
+
#local = { camera: null, microphone: null, screen: null };
|
|
3441
|
+
constructor(options) {
|
|
3442
|
+
this.#env = options.env;
|
|
3443
|
+
this.#log = options.log;
|
|
3444
|
+
this.#timeoutMs = options.connectionTimeoutMs ?? CONNECTION_TIMEOUT_MS;
|
|
3445
|
+
this.#recoveryWindowMs = options.recoveryWindowMs ?? DEFAULT_RECONNECT_WINDOW_MS;
|
|
3446
|
+
}
|
|
3447
|
+
attach(context) {
|
|
3448
|
+
if (this.#closed || this.#context) return;
|
|
3449
|
+
this.#context = context;
|
|
3450
|
+
this.#iceServers = context.iceServers;
|
|
3451
|
+
this.#serverSupportsRestart = context.iceServersExpireAt !== null;
|
|
3452
|
+
if (!this.#env.RTCPeerConnection || !this.#env.MediaStream) {
|
|
3453
|
+
this.#inert = true;
|
|
3454
|
+
this.#log.warn("media connections unsupported");
|
|
3455
|
+
this.#emit("error", { error: new MeetingError("unsupported_environment") });
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
this.#unsubscribeNetwork = this.#env.onNetworkChange?.(() => this.#link?.networkChanged());
|
|
3459
|
+
}
|
|
3460
|
+
peersPresent(entries) {
|
|
3461
|
+
if (!this.#active()) return;
|
|
3462
|
+
for (const { id, media } of entries) {
|
|
3463
|
+
this.#present.set(id, { ...media });
|
|
3464
|
+
this.#limitReported.delete(id);
|
|
3465
|
+
}
|
|
3466
|
+
this.#evaluate();
|
|
3467
|
+
const held = this.#heldOffer;
|
|
3468
|
+
if (held && entries.some((entry) => entry.id === held.from)) {
|
|
3469
|
+
this.#dropHeldOffer();
|
|
3470
|
+
if (held.from === this.#activeId) this.#handleOffer(held.from, held.sdp);
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
peerPresent(participantId, media) {
|
|
3474
|
+
this.peersPresent([{ id: participantId, media }]);
|
|
3475
|
+
}
|
|
3476
|
+
peerMediaChanged(participantId, kind, enabled) {
|
|
3477
|
+
const announced = this.#present.get(participantId);
|
|
3478
|
+
if (!this.#active() || !announced) return;
|
|
3479
|
+
this.#present.set(participantId, { ...announced, [kind]: enabled });
|
|
3480
|
+
this.#refreshRemote(participantId);
|
|
3481
|
+
}
|
|
3482
|
+
peerLeft(participantId) {
|
|
3483
|
+
if (!this.#active()) return;
|
|
3484
|
+
this.#present.delete(participantId);
|
|
3485
|
+
this.#limitReported.delete(participantId);
|
|
3486
|
+
this.#dropEarly(participantId);
|
|
3487
|
+
if (this.#heldOffer?.from === participantId) this.#dropHeldOffer();
|
|
3488
|
+
if (participantId === this.#activeId) {
|
|
3489
|
+
this.#closeLink();
|
|
3490
|
+
this.#activeId = null;
|
|
3491
|
+
}
|
|
3492
|
+
this.#setStatus(participantId, null);
|
|
3493
|
+
this.#evaluate();
|
|
3494
|
+
}
|
|
3495
|
+
peerReconnected(participantId) {
|
|
3496
|
+
if (!this.#active() || participantId !== this.#activeId) return;
|
|
3497
|
+
this.#dropEarly(participantId);
|
|
3498
|
+
this.#closeLink();
|
|
3499
|
+
this.#setStatus(participantId, null);
|
|
3500
|
+
this.#startPairing();
|
|
3501
|
+
}
|
|
3502
|
+
onPeerFrame(message) {
|
|
3503
|
+
if (!this.#active()) return;
|
|
3504
|
+
const from = message.participantId;
|
|
3505
|
+
if (!this.#present.has(from)) {
|
|
3506
|
+
this.#holdEarly(message);
|
|
3507
|
+
return;
|
|
3508
|
+
}
|
|
3509
|
+
if (from !== this.#activeId) {
|
|
3510
|
+
this.#log.debug("frame ignored", { messageType: message.type, reason: "not_paired" });
|
|
3511
|
+
return;
|
|
3512
|
+
}
|
|
3513
|
+
switch (message.type) {
|
|
3514
|
+
case "webrtc.offer":
|
|
3515
|
+
this.#handleOffer(from, message.payload.sdp);
|
|
3516
|
+
return;
|
|
3517
|
+
case "webrtc.answer":
|
|
3518
|
+
if (this.#link) this.#link.acceptAnswer(message.payload.sdp);
|
|
3519
|
+
else this.#log.debug("frame ignored", { messageType: message.type, reason: "no_connection" });
|
|
3520
|
+
return;
|
|
3521
|
+
case "webrtc.ice_candidate":
|
|
3522
|
+
if (this.#link) this.#link.addRemoteCandidate(message.payload.candidate);
|
|
3523
|
+
else this.#addEarlyCandidate(from, message.payload.candidate);
|
|
3524
|
+
return;
|
|
3525
|
+
case "webrtc.ice_restart":
|
|
3526
|
+
if (this.#link?.role === "offerer") this.#link.restart("peer_request");
|
|
3527
|
+
else this.#log.debug("frame ignored", { messageType: message.type, reason: this.#link ? "answerer" : "no_connection" });
|
|
3528
|
+
return;
|
|
3529
|
+
default:
|
|
3530
|
+
return;
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
iceServersUpdated(iceServers2, expiresAt) {
|
|
3534
|
+
if (!this.#active()) return;
|
|
3535
|
+
this.#iceServers = iceServers2;
|
|
3536
|
+
const link = this.#link;
|
|
3537
|
+
if (!link) return;
|
|
3538
|
+
link.updateIceServers(iceServers2);
|
|
3539
|
+
link.planSwitch(expiresAt);
|
|
3540
|
+
}
|
|
3541
|
+
localMediaChanged(kind, stream) {
|
|
3542
|
+
if (this.#closed) return;
|
|
3543
|
+
if (kind === "screen") {
|
|
3544
|
+
const track2 = stream?.getVideoTracks()[0] ?? null;
|
|
3545
|
+
this.#local.screen = track2;
|
|
3546
|
+
this.#link?.attachLocal("screen", track2);
|
|
3547
|
+
return;
|
|
3548
|
+
}
|
|
3549
|
+
const track = (kind === "camera" ? stream?.getVideoTracks()[0] : stream?.getAudioTracks()[0]) ?? null;
|
|
3550
|
+
if (kind === "microphone" && track === null) return;
|
|
3551
|
+
this.#local[kind] = track;
|
|
3552
|
+
this.#link?.attachLocal(kind, track);
|
|
3553
|
+
}
|
|
3554
|
+
supportsScreenShare() {
|
|
3555
|
+
return this.#active();
|
|
3556
|
+
}
|
|
3557
|
+
remoteMedia(participantId) {
|
|
3558
|
+
return this.#remote.get(participantId);
|
|
3559
|
+
}
|
|
3560
|
+
connectionStatus(participantId) {
|
|
3561
|
+
return this.#status.get(participantId) ?? null;
|
|
3562
|
+
}
|
|
3563
|
+
close() {
|
|
3564
|
+
if (this.#closed) return;
|
|
3565
|
+
this.#closed = true;
|
|
3566
|
+
this.#unsubscribeNetwork?.();
|
|
3567
|
+
this.#unsubscribeNetwork = void 0;
|
|
3568
|
+
this.#episode = null;
|
|
3569
|
+
const link = this.#link;
|
|
3570
|
+
this.#link = null;
|
|
3571
|
+
link?.close();
|
|
3572
|
+
for (const participantId of [...this.#early.keys()]) this.#dropEarly(participantId);
|
|
3573
|
+
this.#dropHeldOffer();
|
|
3574
|
+
this.#present.clear();
|
|
3575
|
+
this.#status.clear();
|
|
3576
|
+
this.#remote.clear();
|
|
3577
|
+
this.#activeId = null;
|
|
3578
|
+
}
|
|
3579
|
+
#active() {
|
|
3580
|
+
return this.#context !== void 0 && !this.#inert && !this.#closed;
|
|
3581
|
+
}
|
|
3582
|
+
/** The single-peer rule, applied after every presence change. */
|
|
3583
|
+
#evaluate() {
|
|
3584
|
+
const others = this.#present.size;
|
|
3585
|
+
if (this.#activeId !== null && others >= 2 && !this.#keepsPair()) {
|
|
3586
|
+
const abandoned = this.#activeId;
|
|
3587
|
+
this.#closeLink();
|
|
3588
|
+
this.#activeId = null;
|
|
3589
|
+
this.#setStatus(abandoned, null);
|
|
3590
|
+
this.#log.debug("media connection abandoned", { participantId: abandoned });
|
|
3591
|
+
}
|
|
3592
|
+
if (this.#activeId === null && others === 1) {
|
|
3593
|
+
const [only] = this.#present.keys();
|
|
3594
|
+
if (only !== void 0) {
|
|
3595
|
+
this.#activeId = only;
|
|
3596
|
+
this.#startPairing();
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
if (others >= 2) {
|
|
3600
|
+
for (const participantId of this.#present.keys()) {
|
|
3601
|
+
if (participantId === this.#activeId && this.#keepsPair()) continue;
|
|
3602
|
+
if (this.#limitReported.has(participantId)) continue;
|
|
3603
|
+
this.#limitReported.add(participantId);
|
|
3604
|
+
this.#log.info("media not carried", { participantId, code: "media_participant_limit" });
|
|
3605
|
+
this.#emit("error", { error: new MeetingError("media_participant_limit") });
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
/** A pair is kept once its link has connected, until the peer leaves, reconnects, or the link fails. */
|
|
3610
|
+
#keepsPair() {
|
|
3611
|
+
return this.#link !== null && this.#link.everConnected && this.#link.status !== "failed";
|
|
3612
|
+
}
|
|
3613
|
+
#startPairing() {
|
|
3614
|
+
const peerId = this.#activeId;
|
|
3615
|
+
const self = this.#context?.participantId;
|
|
3616
|
+
if (peerId === null || self === void 0) return;
|
|
3617
|
+
if (roleFor(self, peerId) !== "offerer") return;
|
|
3618
|
+
this.#createLink(peerId)?.start();
|
|
3619
|
+
}
|
|
3620
|
+
#handleOffer(from, sdp2) {
|
|
3621
|
+
const self = this.#context?.participantId;
|
|
3622
|
+
if (self === void 0) return;
|
|
3623
|
+
if (roleFor(self, from) === "offerer") {
|
|
3624
|
+
this.#log.debug("frame ignored", { messageType: "webrtc.offer", reason: "offerer" });
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3627
|
+
let link = this.#link;
|
|
3628
|
+
if (link?.startsNewSession(sdp2)) {
|
|
3629
|
+
const interrupted = link.everConnected;
|
|
3630
|
+
this.#link = null;
|
|
3631
|
+
link.close();
|
|
3632
|
+
if (interrupted) this.#openEpisode("media").replaced = true;
|
|
3633
|
+
link = this.#createLink(from, interrupted ? "interrupted" : "connecting");
|
|
3634
|
+
this.#refreshRemote(from);
|
|
3635
|
+
}
|
|
3636
|
+
link ??= this.#createLink(from);
|
|
3637
|
+
link?.acceptOffer(sdp2);
|
|
3638
|
+
}
|
|
3639
|
+
#createLink(peerId, initialStatus = "connecting") {
|
|
3640
|
+
const context = this.#context;
|
|
3641
|
+
if (!context) return null;
|
|
3642
|
+
let link;
|
|
3643
|
+
try {
|
|
3644
|
+
link = new PeerLink({
|
|
3645
|
+
env: this.#env,
|
|
3646
|
+
log: this.#log,
|
|
3647
|
+
selfId: context.participantId,
|
|
3648
|
+
peerId,
|
|
3649
|
+
iceServers: this.#iceServers,
|
|
3650
|
+
timeoutMs: this.#timeoutMs,
|
|
3651
|
+
local: this.#local,
|
|
3652
|
+
callbacks: {
|
|
3653
|
+
sendOffer: (payload) => {
|
|
3654
|
+
if (this.#link === link) context.send("webrtc.offer", payload);
|
|
3655
|
+
},
|
|
3656
|
+
sendAnswer: (payload) => {
|
|
3657
|
+
if (this.#link === link) context.send("webrtc.answer", payload);
|
|
3658
|
+
},
|
|
3659
|
+
sendCandidate: (payload) => {
|
|
3660
|
+
if (this.#link === link) context.send("webrtc.ice_candidate", payload);
|
|
3661
|
+
},
|
|
3662
|
+
statusChanged: (status, reason) => {
|
|
3663
|
+
if (this.#link === link) this.#onLinkStatus(link, status, reason);
|
|
3664
|
+
},
|
|
3665
|
+
connected: (report) => {
|
|
3666
|
+
if (this.#link === link) context.send("webrtc.connected", { peerParticipantId: peerId, ...report });
|
|
3667
|
+
},
|
|
3668
|
+
requestRestart: () => this.#link === link && this.#serverSupportsRestart && context.send("webrtc.ice_restart", { targetParticipantId: peerId }),
|
|
3669
|
+
recoveryStarted: (trigger) => {
|
|
3670
|
+
if (this.#link === link) this.#recoveryStarted(trigger);
|
|
3671
|
+
},
|
|
3672
|
+
switchResult: (outcome) => {
|
|
3673
|
+
if (this.#link === link && this.#serverSupportsRestart) {
|
|
3674
|
+
context.send("webrtc.ice_restart_result", { peerParticipantId: peerId, outcome });
|
|
3675
|
+
}
|
|
3676
|
+
},
|
|
3677
|
+
qualitySample: (sample) => {
|
|
3678
|
+
if (this.#link !== link) return;
|
|
3679
|
+
try {
|
|
3680
|
+
context.send("webrtc.quality_sample", { peerParticipantId: peerId, ...sample });
|
|
3681
|
+
} catch {
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
});
|
|
3686
|
+
} catch {
|
|
3687
|
+
this.#log.debug("negotiation step failed", { participantId: peerId, reason: "create" });
|
|
3688
|
+
this.#reportFailure(peerId, "negotiation_failed");
|
|
3689
|
+
return null;
|
|
3690
|
+
}
|
|
3691
|
+
this.#link = link;
|
|
3692
|
+
this.#setStatus(peerId, initialStatus);
|
|
3693
|
+
const early = this.#early.get(peerId);
|
|
3694
|
+
if (early) {
|
|
3695
|
+
this.#dropEarly(peerId);
|
|
3696
|
+
for (const candidate of early.candidates) link.addRemoteCandidate(candidate);
|
|
3697
|
+
}
|
|
3698
|
+
return link;
|
|
3699
|
+
}
|
|
3700
|
+
#onLinkStatus(link, status, reason) {
|
|
3701
|
+
const peerParticipantId = link.peerId;
|
|
3702
|
+
switch (status) {
|
|
3703
|
+
case "connected":
|
|
3704
|
+
this.#recovered(peerParticipantId);
|
|
3705
|
+
this.#setStatus(peerParticipantId, "connected");
|
|
3706
|
+
this.#refreshRemote(peerParticipantId);
|
|
3707
|
+
return;
|
|
3708
|
+
case "interrupted":
|
|
3709
|
+
this.#openEpisode("media");
|
|
3710
|
+
this.#context?.send("webrtc.disconnected", { peerParticipantId });
|
|
3711
|
+
this.#setStatus(peerParticipantId, "interrupted");
|
|
3712
|
+
return;
|
|
3713
|
+
case "failed":
|
|
3714
|
+
this.#link = null;
|
|
3715
|
+
link.close();
|
|
3716
|
+
if (this.#replaceFailedLink(link)) return;
|
|
3717
|
+
this.#giveUp(peerParticipantId);
|
|
3718
|
+
this.#reportFailure(peerParticipantId, reason ?? "connection_failed");
|
|
3719
|
+
return;
|
|
3720
|
+
default:
|
|
3721
|
+
return;
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
#openEpisode(trigger) {
|
|
3725
|
+
this.#episode ??= { startedAt: this.#env.now(), attempts: 0, trigger, replaced: false };
|
|
3726
|
+
return this.#episode;
|
|
3727
|
+
}
|
|
3728
|
+
#recoveryStarted(trigger) {
|
|
3729
|
+
const episode = this.#openEpisode(trigger);
|
|
3730
|
+
episode.attempts += 1;
|
|
3731
|
+
episode.trigger = trigger;
|
|
3732
|
+
this.#log.info("reconnect attempt started", reconnectLogFields({ attempt: episode.attempts, trigger }));
|
|
3733
|
+
}
|
|
3734
|
+
/** The connection is carrying media again after a drop. */
|
|
3735
|
+
#recovered(peerParticipantId) {
|
|
3736
|
+
const episode = this.#episode;
|
|
3737
|
+
if (!episode) return;
|
|
3738
|
+
this.#episode = null;
|
|
3739
|
+
this.#log.info(
|
|
3740
|
+
"reconnect succeeded",
|
|
3741
|
+
reconnectLogFields({
|
|
3742
|
+
attempt: Math.max(1, episode.attempts),
|
|
3743
|
+
trigger: episode.trigger,
|
|
3744
|
+
elapsedMs: Math.max(0, this.#env.now() - episode.startedAt),
|
|
3745
|
+
participantId: peerParticipantId
|
|
3746
|
+
})
|
|
3747
|
+
);
|
|
3748
|
+
}
|
|
3749
|
+
/** Recovery is over without media coming back. */
|
|
3750
|
+
#giveUp(peerParticipantId) {
|
|
3751
|
+
const episode = this.#episode;
|
|
3752
|
+
if (!episode) return;
|
|
3753
|
+
this.#episode = null;
|
|
3754
|
+
const elapsedMs = Math.max(0, this.#env.now() - episode.startedAt);
|
|
3755
|
+
this.#log.warn(
|
|
3756
|
+
"reconnect failed",
|
|
3757
|
+
reconnectLogFields({
|
|
3758
|
+
attempt: Math.max(1, episode.attempts),
|
|
3759
|
+
elapsedMs,
|
|
3760
|
+
reason: elapsedMs >= this.#recoveryWindowMs ? "recovery_window_exhausted" : "hard_failure",
|
|
3761
|
+
participantId: peerParticipantId
|
|
3762
|
+
})
|
|
3763
|
+
);
|
|
3764
|
+
}
|
|
3765
|
+
/**
|
|
3766
|
+
* A connection that carried media and then failed is replaced once, within the recovery window, before media is
|
|
3767
|
+
* given up. The meeting connection is untouched. False when no replacement is allowed or it could not be made.
|
|
3768
|
+
*/
|
|
3769
|
+
#replaceFailedLink(failed) {
|
|
3770
|
+
const peerId = failed.peerId;
|
|
3771
|
+
if (!failed.everConnected || peerId !== this.#activeId || !this.#present.has(peerId)) return false;
|
|
3772
|
+
const episode = this.#openEpisode("media");
|
|
3773
|
+
if (episode.replaced || this.#env.now() - episode.startedAt >= this.#recoveryWindowMs) return false;
|
|
3774
|
+
episode.replaced = true;
|
|
3775
|
+
episode.attempts += 1;
|
|
3776
|
+
this.#log.info("reconnect attempt started", reconnectLogFields({ attempt: episode.attempts, trigger: episode.trigger }));
|
|
3777
|
+
const next = this.#createLink(peerId, "interrupted");
|
|
3778
|
+
if (!next) {
|
|
3779
|
+
this.#episode = null;
|
|
3780
|
+
return true;
|
|
3781
|
+
}
|
|
3782
|
+
this.#refreshRemote(peerId);
|
|
3783
|
+
next.start();
|
|
3784
|
+
return true;
|
|
3785
|
+
}
|
|
3786
|
+
/** Reports a failed link. The pair stays as it is; nothing more is tried until the peer reconnects or rejoins. */
|
|
3787
|
+
#reportFailure(peerParticipantId, reason) {
|
|
3788
|
+
this.#log.warn("media connection failed", { participantId: peerParticipantId, reason });
|
|
3789
|
+
this.#context?.send("webrtc.failed", { peerParticipantId, reason });
|
|
3790
|
+
this.#setStatus(peerParticipantId, "failed");
|
|
3791
|
+
this.#refreshRemote(peerParticipantId);
|
|
3792
|
+
this.#emit("error", { error: new MeetingError("media_connection_failed") });
|
|
3793
|
+
}
|
|
3794
|
+
/** Closes the current link without reporting a failure, and reports its remote media as gone. */
|
|
3795
|
+
#closeLink() {
|
|
3796
|
+
const link = this.#link;
|
|
3797
|
+
if (!link) return;
|
|
3798
|
+
this.#link = null;
|
|
3799
|
+
this.#episode = null;
|
|
3800
|
+
link.close();
|
|
3801
|
+
this.#refreshRemote(link.peerId);
|
|
3802
|
+
}
|
|
3803
|
+
#refreshRemote(participantId) {
|
|
3804
|
+
const link = this.#link;
|
|
3805
|
+
const active = participantId === this.#activeId && link !== null && link.peerId === participantId;
|
|
3806
|
+
const next = computeRemoteMedia({
|
|
3807
|
+
active,
|
|
3808
|
+
usable: active && link.usable,
|
|
3809
|
+
announced: this.#present.get(participantId) ?? NOTHING_ANNOUNCED,
|
|
3810
|
+
streams: {
|
|
3811
|
+
camera: link?.remoteStream("camera") ?? null,
|
|
3812
|
+
microphone: link?.remoteStream("microphone") ?? null,
|
|
3813
|
+
screen: link?.remoteStream("screen") ?? null
|
|
3814
|
+
}
|
|
3815
|
+
});
|
|
3816
|
+
this.#remote.update(participantId, next, (event) => this.#emit("remote_media_changed", event));
|
|
3817
|
+
}
|
|
3818
|
+
#setStatus(participantId, status) {
|
|
3819
|
+
const previous = this.#status.get(participantId) ?? null;
|
|
3820
|
+
if (previous === status) return;
|
|
3821
|
+
if (status === null) this.#status.delete(participantId);
|
|
3822
|
+
else this.#status.set(participantId, status);
|
|
3823
|
+
this.#emit("media_connection_changed", { participantId, status });
|
|
3824
|
+
}
|
|
3825
|
+
#holdEarly(message) {
|
|
3826
|
+
switch (message.type) {
|
|
3827
|
+
case "webrtc.offer": {
|
|
3828
|
+
this.#dropHeldOffer();
|
|
3829
|
+
const from = message.participantId;
|
|
3830
|
+
this.#heldOffer = {
|
|
3831
|
+
from,
|
|
3832
|
+
sdp: message.payload.sdp,
|
|
3833
|
+
timer: this.#env.setTimeout(() => {
|
|
3834
|
+
if (this.#heldOffer?.from === from) this.#heldOffer = void 0;
|
|
3835
|
+
}, EARLY_FRAME_HOLD_MS)
|
|
3836
|
+
};
|
|
3837
|
+
return;
|
|
3838
|
+
}
|
|
3839
|
+
case "webrtc.ice_candidate":
|
|
3840
|
+
this.#addEarlyCandidate(message.participantId, message.payload.candidate);
|
|
3841
|
+
return;
|
|
3842
|
+
default:
|
|
3843
|
+
this.#log.debug("frame ignored", { messageType: message.type, reason: "unknown_participant" });
|
|
3844
|
+
}
|
|
3845
|
+
}
|
|
3846
|
+
#addEarlyCandidate(participantId, candidate) {
|
|
3847
|
+
let early = this.#early.get(participantId);
|
|
3848
|
+
if (!early) {
|
|
3849
|
+
early = {
|
|
3850
|
+
candidates: [],
|
|
3851
|
+
timer: this.#env.setTimeout(() => this.#early.delete(participantId), EARLY_FRAME_HOLD_MS)
|
|
3852
|
+
};
|
|
3853
|
+
this.#early.set(participantId, early);
|
|
3854
|
+
}
|
|
3855
|
+
if (early.candidates.length >= MAX_EARLY_CANDIDATES) {
|
|
3856
|
+
this.#log.debug("late candidate dropped", { participantId, reason: "queue_full" });
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
early.candidates.push(candidate);
|
|
3860
|
+
}
|
|
3861
|
+
#dropEarly(participantId) {
|
|
3862
|
+
const early = this.#early.get(participantId);
|
|
3863
|
+
if (!early) return;
|
|
3864
|
+
this.#env.clearTimeout(early.timer);
|
|
3865
|
+
this.#early.delete(participantId);
|
|
3866
|
+
}
|
|
3867
|
+
#dropHeldOffer() {
|
|
3868
|
+
if (this.#heldOffer) this.#env.clearTimeout(this.#heldOffer.timer);
|
|
3869
|
+
this.#heldOffer = void 0;
|
|
3870
|
+
}
|
|
3871
|
+
#emit(type, event) {
|
|
3872
|
+
if (!this.#closed) this.#context?.emit(type, event);
|
|
3873
|
+
}
|
|
3874
|
+
};
|
|
3875
|
+
|
|
3876
|
+
// src/createMeetingEngine.ts
|
|
3877
|
+
var DEFAULT_JOIN_TIMEOUT_MS = 15e3;
|
|
3878
|
+
var MIN_JOIN_TIMEOUT_MS = 1e3;
|
|
3879
|
+
var MAX_JOIN_TIMEOUT_MS = 6e4;
|
|
3880
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1"]);
|
|
3881
|
+
function normalizeRealtimeUrl(value) {
|
|
3882
|
+
const invalid = new TypeError(
|
|
3883
|
+
"realtimeUrl must be wss://host[:port] (ws:// only for localhost) with no path, query, or fragment."
|
|
3884
|
+
);
|
|
3885
|
+
if (typeof value !== "string" || value.includes("?") || value.includes("#")) throw invalid;
|
|
3886
|
+
let url;
|
|
3887
|
+
try {
|
|
3888
|
+
url = new URL(value);
|
|
3889
|
+
} catch {
|
|
3890
|
+
throw invalid;
|
|
3891
|
+
}
|
|
3892
|
+
const secure = url.protocol === "wss:";
|
|
3893
|
+
const loopback = url.protocol === "ws:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
3894
|
+
if (!secure && !loopback) throw invalid;
|
|
3895
|
+
if (url.username !== "" || url.password !== "" || url.pathname !== "" && url.pathname !== "/") throw invalid;
|
|
3896
|
+
return `${url.protocol}//${url.host}`;
|
|
3897
|
+
}
|
|
3898
|
+
function normalizeJoinTimeout(value) {
|
|
3899
|
+
if (value === void 0) return DEFAULT_JOIN_TIMEOUT_MS;
|
|
3900
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < MIN_JOIN_TIMEOUT_MS || value > MAX_JOIN_TIMEOUT_MS) {
|
|
3901
|
+
throw new TypeError(`joinTimeoutMs must be an integer from ${MIN_JOIN_TIMEOUT_MS} to ${MAX_JOIN_TIMEOUT_MS}.`);
|
|
3902
|
+
}
|
|
3903
|
+
return value;
|
|
3904
|
+
}
|
|
3905
|
+
function createMeetingEngine(options) {
|
|
3906
|
+
if (typeof options !== "object" || options === null) throw new TypeError("createMeetingEngine requires options.");
|
|
3907
|
+
const realtimeUrl = normalizeRealtimeUrl(options.realtimeUrl);
|
|
3908
|
+
const joinTimeoutMs = normalizeJoinTimeout(options.joinTimeoutMs);
|
|
3909
|
+
const token = options.token;
|
|
3910
|
+
if (typeof token !== "string" || token.length === 0) throw new MeetingError("join_token_invalid");
|
|
3911
|
+
const env = currentEnvironment();
|
|
3912
|
+
if (!env.WebSocket) throw new MeetingError("unsupported_environment");
|
|
3913
|
+
const addressing = decodeAddressing(token);
|
|
3914
|
+
const getJoinToken = options.getJoinToken;
|
|
3915
|
+
if (getJoinToken !== void 0 && typeof getJoinToken !== "function") {
|
|
3916
|
+
throw new TypeError("getJoinToken must be a function when provided.");
|
|
3917
|
+
}
|
|
3918
|
+
const reconnectWindowMs = clampReconnectWindowMs(options.reconnectWindowMs);
|
|
3919
|
+
return new MeetingEngineImpl({
|
|
3920
|
+
token,
|
|
3921
|
+
realtimeUrl,
|
|
3922
|
+
joinTimeoutMs,
|
|
3923
|
+
logger: options.logger,
|
|
3924
|
+
addressing,
|
|
3925
|
+
env,
|
|
3926
|
+
getJoinToken: typeof getJoinToken === "function" ? getJoinToken : void 0,
|
|
3927
|
+
reconnectWindowMs,
|
|
3928
|
+
createMediaDelivery: (mediaMode, deps) => mediaMode === "p2p" ? new PeerMediaDelivery({ ...deps, recoveryWindowMs: reconnectWindowMs }) : new SignalingOnlyMediaDelivery()
|
|
3929
|
+
});
|
|
3930
|
+
}
|
|
3931
|
+
export {
|
|
3932
|
+
MeetingError,
|
|
3933
|
+
createMeetingEngine
|
|
3934
|
+
};
|
|
3935
|
+
//# sourceMappingURL=index.js.map
|