@strevokit/meeting-sdk 0.0.0-stage → 0.6.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/dist/index.js ADDED
@@ -0,0 +1,2817 @@
1
+ // src/internal/env.ts
2
+ var override;
3
+ function defaultEnvironment() {
4
+ const scope = globalThis;
5
+ return {
6
+ WebSocket: typeof scope.WebSocket === "function" ? scope.WebSocket : void 0,
7
+ mediaDevices: scope.navigator?.mediaDevices,
8
+ RTCPeerConnection: typeof scope.RTCPeerConnection === "function" ? scope.RTCPeerConnection : void 0,
9
+ MediaStream: typeof scope.MediaStream === "function" ? scope.MediaStream : void 0,
10
+ setTimeout: (callback, ms) => globalThis.setTimeout(callback, ms),
11
+ clearTimeout: (handle) => globalThis.clearTimeout(handle),
12
+ now: () => Date.now()
13
+ };
14
+ }
15
+ function currentEnvironment() {
16
+ return override ?? defaultEnvironment();
17
+ }
18
+
19
+ // ../signaling-protocol/dist/version.js
20
+ var PROTOCOL_VERSION = 1;
21
+ var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze([1]);
22
+ function isSupportedVersion(value) {
23
+ return typeof value === "number" && Number.isInteger(value) && SUPPORTED_PROTOCOL_VERSIONS.includes(value);
24
+ }
25
+
26
+ // ../signaling-protocol/dist/limits.js
27
+ var MAX_MESSAGE_BYTES = 65536;
28
+ var MAX_SDP_BYTES = 49152;
29
+ var MAX_ICE_CANDIDATE_LENGTH = 1024;
30
+ var MAX_DISPLAY_NAME_CODE_POINTS = 255;
31
+ var MAX_REASON_LENGTH = 64;
32
+ var MAX_SDP_MID_LENGTH = 64;
33
+ var MAX_USERNAME_FRAGMENT_LENGTH = 256;
34
+ var MAX_SDP_MLINE_INDEX = 1023;
35
+ var MAX_ICE_SERVERS = 4;
36
+ var MAX_ICE_SERVER_URLS = 4;
37
+ var MAX_ICE_SERVER_URL_LENGTH = 256;
38
+ var MAX_ICE_SERVER_USERNAME_LENGTH = 256;
39
+ var MAX_ICE_SERVER_CREDENTIAL_LENGTH = 256;
40
+
41
+ // ../signaling-protocol/dist/rejection.js
42
+ var REJECTION_CODES = Object.freeze([
43
+ "invalid_envelope",
44
+ "unsupported_version",
45
+ "unknown_type",
46
+ "direction_not_allowed",
47
+ "invalid_payload"
48
+ ]);
49
+ var REJECTION_DETAILS = Object.freeze([
50
+ "message_too_large",
51
+ "not_text",
52
+ "invalid_json",
53
+ "not_an_object",
54
+ "invalid_ulid",
55
+ "payload_not_object",
56
+ "missing_field",
57
+ "wrong_type",
58
+ "too_long",
59
+ "out_of_range",
60
+ "invalid_format"
61
+ ]);
62
+ var CODE_MESSAGES = {
63
+ invalid_envelope: "Message envelope is invalid.",
64
+ unsupported_version: "Protocol version is missing or not supported.",
65
+ unknown_type: "Message type is not part of the protocol.",
66
+ direction_not_allowed: "Message type is not allowed in this direction.",
67
+ invalid_payload: "Message payload is invalid."
68
+ };
69
+ var DETAIL_MESSAGES = {
70
+ message_too_large: "Message exceeds the maximum size.",
71
+ not_text: "Message frame must be UTF-8 text.",
72
+ invalid_json: "Message frame is not valid JSON.",
73
+ not_an_object: "Message must be a JSON object.",
74
+ invalid_ulid: "Identifier must be a canonical ULID.",
75
+ payload_not_object: "Payload must be a JSON object.",
76
+ missing_field: "A required field is missing.",
77
+ wrong_type: "A field has the wrong type.",
78
+ too_long: "A field is too long.",
79
+ out_of_range: "A field is out of range.",
80
+ invalid_format: "A field has an invalid format."
81
+ };
82
+ function reject(code, detail, field) {
83
+ const rejection = {
84
+ code,
85
+ message: detail ? DETAIL_MESSAGES[detail] : CODE_MESSAGES[code]
86
+ };
87
+ if (field !== void 0)
88
+ rejection.field = field;
89
+ if (detail !== void 0)
90
+ rejection.detail = detail;
91
+ return rejection;
92
+ }
93
+ var SignalingProtocolError = class extends Error {
94
+ rejection;
95
+ constructor(rejection) {
96
+ super(`${rejection.code}: ${rejection.message}`);
97
+ this.name = "SignalingProtocolError";
98
+ this.rejection = rejection;
99
+ }
100
+ };
101
+
102
+ // ../signaling-protocol/dist/primitives.js
103
+ var ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/;
104
+ var REASON_CODE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
105
+ var TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?Z$/;
106
+ function isCanonicalUlid(value) {
107
+ return typeof value === "string" && ULID_PATTERN.test(value);
108
+ }
109
+ function utf8ByteLength(value) {
110
+ let bytes = 0;
111
+ for (let i = 0; i < value.length; i++) {
112
+ const unit = value.charCodeAt(i);
113
+ if (unit < 128) {
114
+ bytes += 1;
115
+ } else if (unit < 2048) {
116
+ bytes += 2;
117
+ } else if (unit >= 55296 && unit <= 56319 && i + 1 < value.length) {
118
+ const next = value.charCodeAt(i + 1);
119
+ if (next >= 56320 && next <= 57343) {
120
+ bytes += 4;
121
+ i++;
122
+ } else {
123
+ bytes += 3;
124
+ }
125
+ } else {
126
+ bytes += 3;
127
+ }
128
+ }
129
+ return bytes;
130
+ }
131
+ function codePointLength(value) {
132
+ let count = 0;
133
+ for (let i = 0; i < value.length; i++) {
134
+ const unit = value.charCodeAt(i);
135
+ if (unit >= 55296 && unit <= 56319 && i + 1 < value.length) {
136
+ const next = value.charCodeAt(i + 1);
137
+ if (next >= 56320 && next <= 57343)
138
+ i++;
139
+ }
140
+ count++;
141
+ }
142
+ return count;
143
+ }
144
+ function isReasonCode(value) {
145
+ return typeof value === "string" && REASON_CODE_PATTERN.test(value);
146
+ }
147
+ function isRfc3339UtcTimestamp(value) {
148
+ if (typeof value !== "string")
149
+ return false;
150
+ const match = TIMESTAMP_PATTERN.exec(value);
151
+ if (!match)
152
+ return false;
153
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map((part) => Number(part));
154
+ if (month < 1 || month > 12 || day < 1)
155
+ return false;
156
+ if (hour > 23 || minute > 59 || second > 59)
157
+ return false;
158
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
159
+ return day <= daysInMonth;
160
+ }
161
+ function isPlainObject(value) {
162
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
163
+ return false;
164
+ }
165
+ const proto = Object.getPrototypeOf(value);
166
+ return proto === Object.prototype || proto === null;
167
+ }
168
+ function readOwn(obj, key) {
169
+ return Object.hasOwn(obj, key) ? obj[key] : void 0;
170
+ }
171
+ function hasOwn(obj, key) {
172
+ return Object.hasOwn(obj, key);
173
+ }
174
+
175
+ // ../signaling-protocol/dist/catalog.js
176
+ var CATALOG = {
177
+ "participant.join": { family: "participant", direction: "client_to_server" },
178
+ "participant.joined": { family: "participant", direction: "server_to_client" },
179
+ "participant.leave": { family: "participant", direction: "client_to_server" },
180
+ "participant.left": { family: "participant", direction: "server_to_client" },
181
+ "participant.reconnected": { family: "participant", direction: "server_to_client" },
182
+ "webrtc.offer": { family: "webrtc", direction: "both" },
183
+ "webrtc.answer": { family: "webrtc", direction: "both" },
184
+ "webrtc.ice_candidate": { family: "webrtc", direction: "both" },
185
+ "webrtc.connected": { family: "webrtc", direction: "client_to_server" },
186
+ "webrtc.disconnected": { family: "webrtc", direction: "client_to_server" },
187
+ "webrtc.failed": { family: "webrtc", direction: "client_to_server" },
188
+ "webrtc.ice_restart": { family: "webrtc", direction: "both" },
189
+ "webrtc.ice_restart_result": { family: "webrtc", direction: "client_to_server" },
190
+ "media.camera.started": { family: "media", direction: "both" },
191
+ "media.camera.stopped": { family: "media", direction: "both" },
192
+ "media.microphone.started": { family: "media", direction: "both" },
193
+ "media.microphone.stopped": { family: "media", direction: "both" },
194
+ "media.screen.started": { family: "media", direction: "both" },
195
+ "media.screen.stopped": { family: "media", direction: "both" },
196
+ "meeting.started": { family: "meeting", direction: "server_to_client" },
197
+ "meeting.ended": { family: "meeting", direction: "server_to_client" },
198
+ "meeting.terminated": { family: "meeting", direction: "server_to_client" },
199
+ "meeting.end": { family: "meeting", direction: "client_to_server" },
200
+ "session.authenticated": { family: "session", direction: "server_to_client" },
201
+ "session.welcome": { family: "session", direction: "server_to_client" },
202
+ "session.rejected": { family: "session", direction: "server_to_client" },
203
+ "session.ice_servers": { family: "session", direction: "server_to_client" }
204
+ };
205
+ for (const entry of Object.values(CATALOG))
206
+ Object.freeze(entry);
207
+ var MESSAGE_CATALOG = Object.freeze(CATALOG);
208
+ var MESSAGE_TYPES = Object.freeze(Object.keys(CATALOG));
209
+ function isMessageType(value) {
210
+ return typeof value === "string" && Object.hasOwn(CATALOG, value);
211
+ }
212
+ function isDirectionAllowed(type, direction) {
213
+ const allowed = MESSAGE_CATALOG[type].direction;
214
+ return allowed === "both" || allowed === direction;
215
+ }
216
+
217
+ // ../signaling-protocol/dist/rolePolicy.js
218
+ var ALL_ROLES = Object.freeze(["host", "participant"]);
219
+ var ROLE_POLICY = Object.freeze({
220
+ "media.screen.started": Object.freeze(["host"]),
221
+ "media.screen.stopped": Object.freeze(["host"]),
222
+ "meeting.end": Object.freeze(["host"])
223
+ });
224
+ function allowedRoles(type) {
225
+ return ROLE_POLICY[type] ?? ALL_ROLES;
226
+ }
227
+ function isRoleAllowed(type, role2) {
228
+ return allowedRoles(type).includes(role2);
229
+ }
230
+
231
+ // ../signaling-protocol/dist/payloads.js
232
+ var PayloadFailure = class {
233
+ rejection;
234
+ constructor(rejection) {
235
+ this.rejection = rejection;
236
+ }
237
+ };
238
+ function fail(detail, path) {
239
+ throw new PayloadFailure(reject("invalid_payload", detail, path));
240
+ }
241
+ function required(obj, key, path) {
242
+ if (!hasOwn(obj, key))
243
+ fail("missing_field", path);
244
+ return obj[key];
245
+ }
246
+ function requiredString(obj, key, path) {
247
+ const value = required(obj, key, path);
248
+ if (typeof value !== "string")
249
+ fail("wrong_type", path);
250
+ return value;
251
+ }
252
+ function participantRef(obj, key, path, ctx, allowSelf) {
253
+ const value = requiredString(obj, key, path);
254
+ if (!isCanonicalUlid(value))
255
+ fail("invalid_ulid", path);
256
+ if (!allowSelf && value === ctx.participantId)
257
+ fail("invalid_format", path);
258
+ return value;
259
+ }
260
+ function reasonCode(obj, key, path) {
261
+ const value = requiredString(obj, key, path);
262
+ if (value.length > MAX_REASON_LENGTH)
263
+ fail("too_long", path);
264
+ if (!isReasonCode(value))
265
+ fail("invalid_format", path);
266
+ return value;
267
+ }
268
+ function timestamp(obj, key, path) {
269
+ const value = requiredString(obj, key, path);
270
+ if (!isRfc3339UtcTimestamp(value))
271
+ fail("invalid_format", path);
272
+ return value;
273
+ }
274
+ function sdp(obj) {
275
+ const path = "payload.sdp";
276
+ const value = requiredString(obj, "sdp", path);
277
+ if (value.length === 0)
278
+ fail("invalid_format", path);
279
+ if (value.length > MAX_SDP_BYTES)
280
+ fail("too_long", path);
281
+ if (value.length * 3 > MAX_SDP_BYTES && utf8ByteLength(value) > MAX_SDP_BYTES) {
282
+ fail("too_long", path);
283
+ }
284
+ return value;
285
+ }
286
+ function nullableString(obj, key, path, maxLength) {
287
+ const value = obj[key];
288
+ if (value === null)
289
+ return null;
290
+ if (typeof value !== "string")
291
+ fail("wrong_type", path);
292
+ if (value.length > maxLength)
293
+ fail("too_long", path);
294
+ return value;
295
+ }
296
+ function iceCandidate(obj) {
297
+ const base = "payload.candidate";
298
+ const raw = required(obj, "candidate", base);
299
+ if (!isPlainObject(raw))
300
+ fail("wrong_type", base);
301
+ const candidate = requiredString(raw, "candidate", `${base}.candidate`);
302
+ if (candidate.length > MAX_ICE_CANDIDATE_LENGTH)
303
+ fail("too_long", `${base}.candidate`);
304
+ required(raw, "sdpMid", `${base}.sdpMid`);
305
+ const sdpMid = nullableString(raw, "sdpMid", `${base}.sdpMid`, MAX_SDP_MID_LENGTH);
306
+ const indexPath = `${base}.sdpMLineIndex`;
307
+ const index = required(raw, "sdpMLineIndex", indexPath);
308
+ let sdpMLineIndex = null;
309
+ if (index !== null) {
310
+ if (typeof index !== "number")
311
+ fail("wrong_type", indexPath);
312
+ if (!Number.isInteger(index) || index < 0 || index > MAX_SDP_MLINE_INDEX) {
313
+ fail("out_of_range", indexPath);
314
+ }
315
+ sdpMLineIndex = index;
316
+ }
317
+ if (candidate.length > 0 && sdpMid === null && sdpMLineIndex === null) {
318
+ fail("invalid_format", base);
319
+ }
320
+ const value = { candidate, sdpMid, sdpMLineIndex };
321
+ if (hasOwn(raw, "usernameFragment")) {
322
+ value.usernameFragment = nullableString(raw, "usernameFragment", `${base}.usernameFragment`, MAX_USERNAME_FRAGMENT_LENGTH);
323
+ }
324
+ return value;
325
+ }
326
+ var empty = () => ({});
327
+ var sessionDescription = (p, ctx) => ({
328
+ targetParticipantId: participantRef(p, "targetParticipantId", "payload.targetParticipantId", ctx, false),
329
+ sdp: sdp(p)
330
+ });
331
+ var peer = (p, ctx) => ({
332
+ peerParticipantId: participantRef(p, "peerParticipantId", "payload.peerParticipantId", ctx, false)
333
+ });
334
+ function role(obj) {
335
+ const value = requiredString(obj, "role", "payload.role");
336
+ if (value !== "host" && value !== "participant")
337
+ fail("invalid_format", "payload.role");
338
+ return value;
339
+ }
340
+ function optionalDisplayName(p, value) {
341
+ if (hasOwn(p, "displayName")) {
342
+ const path = "payload.displayName";
343
+ const displayName = p.displayName;
344
+ if (typeof displayName !== "string")
345
+ fail("wrong_type", path);
346
+ if (displayName.length === 0)
347
+ fail("invalid_format", path);
348
+ if (codePointLength(displayName) > MAX_DISPLAY_NAME_CODE_POINTS)
349
+ fail("too_long", path);
350
+ value.displayName = displayName;
351
+ }
352
+ return value;
353
+ }
354
+ var ICE_SERVER_URL_SCHEMES = ["stun:", "stuns:", "turn:", "turns:"];
355
+ function optionalBoundedString(obj, key, path, maxLength, value) {
356
+ if (!hasOwn(obj, key))
357
+ return;
358
+ const raw = obj[key];
359
+ if (typeof raw !== "string")
360
+ fail("wrong_type", path);
361
+ if (raw.length > maxLength)
362
+ fail("too_long", path);
363
+ value[key] = raw;
364
+ }
365
+ function iceServer(raw, path) {
366
+ if (!isPlainObject(raw))
367
+ fail("wrong_type", path);
368
+ const urlsPath = `${path}.urls`;
369
+ const urls = required(raw, "urls", urlsPath);
370
+ if (!Array.isArray(urls))
371
+ fail("wrong_type", urlsPath);
372
+ if (urls.length === 0 || urls.length > MAX_ICE_SERVER_URLS)
373
+ fail("out_of_range", urlsPath);
374
+ const checked = urls.map((url, index) => {
375
+ const urlPath = `${urlsPath}.${index}`;
376
+ if (typeof url !== "string")
377
+ fail("wrong_type", urlPath);
378
+ if (url.length > MAX_ICE_SERVER_URL_LENGTH)
379
+ fail("too_long", urlPath);
380
+ if (!ICE_SERVER_URL_SCHEMES.some((scheme) => url.startsWith(scheme) && url.length > scheme.length)) {
381
+ fail("invalid_format", urlPath);
382
+ }
383
+ return url;
384
+ });
385
+ const value = { urls: checked };
386
+ optionalBoundedString(raw, "username", `${path}.username`, MAX_ICE_SERVER_USERNAME_LENGTH, value);
387
+ optionalBoundedString(raw, "credential", `${path}.credential`, MAX_ICE_SERVER_CREDENTIAL_LENGTH, value);
388
+ return value;
389
+ }
390
+ function iceServers(obj) {
391
+ const path = "payload.iceServers";
392
+ const servers = required(obj, "iceServers", path);
393
+ if (!Array.isArray(servers))
394
+ fail("wrong_type", path);
395
+ if (servers.length > MAX_ICE_SERVERS)
396
+ fail("too_long", path);
397
+ return servers.map((server, index) => iceServer(server, `${path}.${index}`));
398
+ }
399
+ function sessionWelcome(p) {
400
+ const value = optionalDisplayName(p, {
401
+ role: role(p),
402
+ joinedAt: timestamp(p, "joinedAt", "payload.joinedAt")
403
+ });
404
+ if (hasOwn(p, "mediaMode")) {
405
+ const path = "payload.mediaMode";
406
+ const mediaMode = p.mediaMode;
407
+ if (typeof mediaMode !== "string")
408
+ fail("wrong_type", path);
409
+ if (mediaMode !== "p2p" && mediaMode !== "sfu")
410
+ fail("invalid_format", path);
411
+ value.mediaMode = mediaMode;
412
+ }
413
+ if (hasOwn(p, "iceServers"))
414
+ value.iceServers = iceServers(p);
415
+ if (hasOwn(p, "iceServersExpireAt")) {
416
+ value.iceServersExpireAt = timestamp(p, "iceServersExpireAt", "payload.iceServersExpireAt");
417
+ }
418
+ return value;
419
+ }
420
+ var VALIDATORS = {
421
+ "participant.join": empty,
422
+ "participant.joined": (p) => optionalDisplayName(p, { role: role(p) }),
423
+ "participant.leave": empty,
424
+ "participant.left": (p) => ({ reason: reasonCode(p, "reason", "payload.reason") }),
425
+ "participant.reconnected": empty,
426
+ "webrtc.offer": sessionDescription,
427
+ "webrtc.answer": sessionDescription,
428
+ "webrtc.ice_candidate": (p, ctx) => ({
429
+ targetParticipantId: participantRef(p, "targetParticipantId", "payload.targetParticipantId", ctx, false),
430
+ candidate: iceCandidate(p)
431
+ }),
432
+ "webrtc.connected": (p, ctx) => {
433
+ const value = peer(p, ctx);
434
+ for (const key of ["path", "localPath", "remotePath"]) {
435
+ if (hasOwn(p, key))
436
+ value[key] = reasonCode(p, key, `payload.${key}`);
437
+ }
438
+ return value;
439
+ },
440
+ "webrtc.disconnected": peer,
441
+ "webrtc.failed": (p, ctx) => ({
442
+ ...peer(p, ctx),
443
+ reason: reasonCode(p, "reason", "payload.reason")
444
+ }),
445
+ "webrtc.ice_restart": (p, ctx) => ({
446
+ targetParticipantId: participantRef(p, "targetParticipantId", "payload.targetParticipantId", ctx, false)
447
+ }),
448
+ "webrtc.ice_restart_result": (p, ctx) => ({
449
+ ...peer(p, ctx),
450
+ outcome: reasonCode(p, "outcome", "payload.outcome")
451
+ }),
452
+ "media.camera.started": empty,
453
+ "media.camera.stopped": empty,
454
+ "media.microphone.started": empty,
455
+ "media.microphone.stopped": empty,
456
+ "media.screen.started": empty,
457
+ "media.screen.stopped": empty,
458
+ "meeting.started": (p) => ({ startedAt: timestamp(p, "startedAt", "payload.startedAt") }),
459
+ "meeting.ended": (p, ctx) => {
460
+ const value = { endedAt: timestamp(p, "endedAt", "payload.endedAt") };
461
+ if (hasOwn(p, "endedByParticipantId")) {
462
+ value.endedByParticipantId = participantRef(p, "endedByParticipantId", "payload.endedByParticipantId", ctx, true);
463
+ }
464
+ return value;
465
+ },
466
+ "meeting.terminated": (p) => ({
467
+ terminatedAt: timestamp(p, "terminatedAt", "payload.terminatedAt"),
468
+ reason: reasonCode(p, "reason", "payload.reason")
469
+ }),
470
+ "meeting.end": empty,
471
+ "session.authenticated": (p) => ({ role: role(p) }),
472
+ "session.welcome": sessionWelcome,
473
+ "session.rejected": (p) => {
474
+ const value = { code: reasonCode(p, "code", "payload.code") };
475
+ if (hasOwn(p, "detail"))
476
+ value.detail = reasonCode(p, "detail", "payload.detail");
477
+ return value;
478
+ },
479
+ "session.ice_servers": (p) => ({
480
+ iceServers: iceServers(p),
481
+ expiresAt: timestamp(p, "expiresAt", "payload.expiresAt")
482
+ })
483
+ };
484
+ function validatePayload(type, payload, ctx) {
485
+ try {
486
+ return { ok: true, value: VALIDATORS[type](payload, ctx) };
487
+ } catch (error) {
488
+ if (error instanceof PayloadFailure)
489
+ return { ok: false, rejection: error.rejection };
490
+ throw error;
491
+ }
492
+ }
493
+
494
+ // ../signaling-protocol/dist/parse.js
495
+ function failure(error) {
496
+ return { ok: false, error };
497
+ }
498
+ function resolveDirection(options) {
499
+ const direction = options?.direction;
500
+ return direction === "client_to_server" || direction === "server_to_client" ? direction : void 0;
501
+ }
502
+ function parseValue(input, direction) {
503
+ if (!isPlainObject(input))
504
+ return failure(reject("invalid_envelope", "not_an_object"));
505
+ if (!isSupportedVersion(readOwn(input, "version"))) {
506
+ return failure(reject("unsupported_version", void 0, "version"));
507
+ }
508
+ const type = readOwn(input, "type");
509
+ if (!isMessageType(type))
510
+ return failure(reject("unknown_type", void 0, "type"));
511
+ if (direction !== void 0 && !isDirectionAllowed(type, direction)) {
512
+ return failure(reject("direction_not_allowed", void 0, "type"));
513
+ }
514
+ const meetingId = readOwn(input, "meetingId");
515
+ if (!isCanonicalUlid(meetingId)) {
516
+ return failure(reject("invalid_envelope", "invalid_ulid", "meetingId"));
517
+ }
518
+ const participantId = readOwn(input, "participantId");
519
+ if (!isCanonicalUlid(participantId)) {
520
+ return failure(reject("invalid_envelope", "invalid_ulid", "participantId"));
521
+ }
522
+ const payload = readOwn(input, "payload");
523
+ if (!isPlainObject(payload)) {
524
+ return failure(reject("invalid_envelope", "payload_not_object", "payload"));
525
+ }
526
+ const result = validatePayload(type, payload, { participantId });
527
+ if (!result.ok)
528
+ return failure(result.rejection);
529
+ const message = {
530
+ version: PROTOCOL_VERSION,
531
+ type,
532
+ meetingId,
533
+ participantId,
534
+ payload: result.value
535
+ };
536
+ return { ok: true, message };
537
+ }
538
+ function parseSignalingMessage(input, options) {
539
+ try {
540
+ return parseValue(input, resolveDirection(options));
541
+ } catch {
542
+ return failure(reject("invalid_envelope"));
543
+ }
544
+ }
545
+ function exceedsMaxMessageBytes(frame) {
546
+ if (frame.length > MAX_MESSAGE_BYTES)
547
+ return true;
548
+ if (frame.length * 3 <= MAX_MESSAGE_BYTES)
549
+ return false;
550
+ return utf8ByteLength(frame) > MAX_MESSAGE_BYTES;
551
+ }
552
+ function decodeSignalingFrame(frame, options) {
553
+ try {
554
+ if (typeof frame !== "string")
555
+ return failure(reject("invalid_envelope", "not_text"));
556
+ if (exceedsMaxMessageBytes(frame)) {
557
+ return failure(reject("invalid_envelope", "message_too_large"));
558
+ }
559
+ let value;
560
+ try {
561
+ value = JSON.parse(frame);
562
+ } catch {
563
+ return failure(reject("invalid_envelope", "invalid_json"));
564
+ }
565
+ return parseValue(value, resolveDirection(options));
566
+ } catch {
567
+ return failure(reject("invalid_envelope"));
568
+ }
569
+ }
570
+
571
+ // ../signaling-protocol/dist/build.js
572
+ function createSignalingMessage(type, fields) {
573
+ const result = parseSignalingMessage({
574
+ version: PROTOCOL_VERSION,
575
+ type,
576
+ meetingId: fields.meetingId,
577
+ participantId: fields.participantId,
578
+ payload: fields.payload
579
+ });
580
+ if (!result.ok)
581
+ throw new SignalingProtocolError(result.error);
582
+ return result.message;
583
+ }
584
+ function encodeSignalingMessage(message) {
585
+ const result = parseSignalingMessage(message);
586
+ if (!result.ok)
587
+ throw new SignalingProtocolError(result.error);
588
+ const frame = JSON.stringify(result.message);
589
+ if (utf8ByteLength(frame) > MAX_MESSAGE_BYTES) {
590
+ throw new SignalingProtocolError(reject("invalid_envelope", "message_too_large"));
591
+ }
592
+ return frame;
593
+ }
594
+
595
+ // src/public/errors.ts
596
+ var ERROR_INFO = Object.freeze({
597
+ join_token_invalid: { message: "This meeting link is not valid. Ask for a new one.", retryable: false },
598
+ join_token_expired: { message: "This meeting link has expired. Ask for a new one.", retryable: true },
599
+ origin_not_allowed: { message: "This site is not allowed to join this meeting.", retryable: false },
600
+ connection_failed: {
601
+ message: "Could not connect to the meeting. Check your connection and try again.",
602
+ retryable: true
603
+ },
604
+ join_timeout: { message: "Joining the meeting took too long. Please try again.", retryable: true },
605
+ join_cancelled: { message: "Joining was cancelled.", retryable: false },
606
+ connection_lost: { message: "The connection to the meeting was lost.", retryable: true },
607
+ session_replaced: { message: "You joined this meeting from another tab or device.", retryable: false },
608
+ service_unavailable: {
609
+ message: "The meeting service is temporarily unavailable. Please try again shortly.",
610
+ retryable: true
611
+ },
612
+ rate_limited: { message: "Too many attempts. Please wait a moment and try again.", retryable: true },
613
+ meeting_ended: { message: "This meeting has ended.", retryable: false },
614
+ protocol_error: { message: "Something went wrong in the meeting connection.", retryable: false },
615
+ already_joined: { message: "This meeting is already joined or joining.", retryable: false },
616
+ engine_closed: {
617
+ message: "This meeting session has ended. Create a new one to join again.",
618
+ retryable: false
619
+ },
620
+ not_joined: { message: "Join the meeting first.", retryable: false },
621
+ not_permitted: { message: "You don't have permission to do that in this meeting.", retryable: false },
622
+ permission_denied: {
623
+ message: "Camera or microphone access was blocked. Allow access in your browser settings.",
624
+ retryable: false
625
+ },
626
+ device_not_found: { message: "No camera or microphone was found.", retryable: false },
627
+ device_unavailable: {
628
+ message: "Your camera or microphone is being used by another application.",
629
+ retryable: true
630
+ },
631
+ screen_share_cancelled: { message: "Screen sharing was cancelled.", retryable: false },
632
+ unsupported_environment: {
633
+ message: "This browser does not support meetings. Try a current version of Chrome, Edge, Firefox, or Safari.",
634
+ retryable: false
635
+ },
636
+ media_connection_failed: {
637
+ message: "Audio and video couldn't connect to the other participant. Check your network and try rejoining.",
638
+ retryable: true
639
+ },
640
+ media_participant_limit: {
641
+ message: "Audio and video are available with one other participant in this meeting.",
642
+ retryable: false
643
+ }
644
+ });
645
+ var MeetingError = class extends Error {
646
+ name = "MeetingError";
647
+ /** Stable machine-readable code. */
648
+ code;
649
+ /** True when retrying the same action later (with a fresh token for join errors) may succeed. */
650
+ retryable;
651
+ constructor(code) {
652
+ const info = ERROR_INFO[code];
653
+ super(info.message);
654
+ this.code = code;
655
+ this.retryable = info.retryable;
656
+ }
657
+ };
658
+
659
+ // src/internal/emitter.ts
660
+ var Emitter = class {
661
+ #listeners = /* @__PURE__ */ new Map();
662
+ #log;
663
+ constructor(log) {
664
+ this.#log = log;
665
+ }
666
+ on(type, listener) {
667
+ let set = this.#listeners.get(type);
668
+ if (!set) {
669
+ set = /* @__PURE__ */ new Set();
670
+ this.#listeners.set(type, set);
671
+ }
672
+ const entry = listener;
673
+ set.add(entry);
674
+ return () => {
675
+ this.#listeners.get(type)?.delete(entry);
676
+ };
677
+ }
678
+ emit(type, event) {
679
+ const set = this.#listeners.get(type);
680
+ if (!set || set.size === 0) return;
681
+ for (const listener of [...set]) {
682
+ try {
683
+ listener(event);
684
+ } catch {
685
+ this.#log.warn("event listener threw", { messageType: String(type) });
686
+ }
687
+ }
688
+ }
689
+ clear() {
690
+ this.#listeners.clear();
691
+ }
692
+ };
693
+
694
+ // src/internal/errorMapping.ts
695
+ var BEFORE_JOINED = {
696
+ 1001: "service_unavailable",
697
+ 4001: "join_token_invalid",
698
+ 4002: "join_token_expired",
699
+ 4003: "origin_not_allowed",
700
+ 4004: "meeting_ended",
701
+ 4008: "join_timeout",
702
+ 4009: "session_replaced",
703
+ 4011: "protocol_error",
704
+ 4029: "rate_limited",
705
+ 4503: "service_unavailable"
706
+ };
707
+ var AFTER_JOINED = {
708
+ 1e3: ["left", void 0],
709
+ 1001: ["error", "service_unavailable"],
710
+ 4001: ["error", "join_token_invalid"],
711
+ 4002: ["error", "join_token_expired"],
712
+ 4003: ["error", "origin_not_allowed"],
713
+ 4004: ["meeting_ended", void 0],
714
+ 4009: ["session_replaced", "session_replaced"],
715
+ 4011: ["error", "protocol_error"],
716
+ 4029: ["error", "rate_limited"],
717
+ 4503: ["error", "service_unavailable"]
718
+ };
719
+ function mapClose(code, phase) {
720
+ if (phase === "before_joined") {
721
+ return { reason: "join_failed", error: new MeetingError(BEFORE_JOINED[code] ?? "connection_failed") };
722
+ }
723
+ const [reason, errorCode] = AFTER_JOINED[code] ?? ["connection_lost", "connection_lost"];
724
+ return errorCode ? { reason, error: new MeetingError(errorCode) } : { reason };
725
+ }
726
+ var REJECTIONS = {
727
+ rate_limited: "rate_limited",
728
+ service_unavailable: "service_unavailable",
729
+ role_not_allowed: "not_permitted"
730
+ };
731
+ function mapRejection(code) {
732
+ return new MeetingError(Object.hasOwn(REJECTIONS, code) ? REJECTIONS[code] : "protocol_error");
733
+ }
734
+ var LEFT_REASONS = /* @__PURE__ */ new Set(["left", "disconnected", "removed", "meeting_ended"]);
735
+ function mapLeftReason(reason) {
736
+ return LEFT_REASONS.has(reason) ? reason : "other";
737
+ }
738
+ var DEVICE_ERRORS = {
739
+ NotAllowedError: "permission_denied",
740
+ SecurityError: "permission_denied",
741
+ NotFoundError: "device_not_found",
742
+ OverconstrainedError: "device_not_found",
743
+ NotReadableError: "device_unavailable",
744
+ AbortError: "device_unavailable",
745
+ TypeError: "unsupported_environment"
746
+ };
747
+ var SCREEN_ERRORS = {
748
+ NotAllowedError: "screen_share_cancelled",
749
+ AbortError: "screen_share_cancelled",
750
+ SecurityError: "permission_denied",
751
+ TypeError: "unsupported_environment"
752
+ };
753
+ function errorName(error) {
754
+ if (typeof error !== "object" || error === null) return void 0;
755
+ const name = error.name;
756
+ return typeof name === "string" ? name : void 0;
757
+ }
758
+ function mapMediaError(kind, error) {
759
+ if (error instanceof MeetingError) return error;
760
+ const name = errorName(error);
761
+ const table = kind === "screen" ? SCREEN_ERRORS : DEVICE_ERRORS;
762
+ const code = name !== void 0 && Object.hasOwn(table, name) ? table[name] : void 0;
763
+ return new MeetingError(code ?? "device_unavailable");
764
+ }
765
+
766
+ // src/internal/localMedia.ts
767
+ var KINDS = ["camera", "microphone", "screen"];
768
+ function stopTracks(stream) {
769
+ for (const track of stream.getTracks()) {
770
+ try {
771
+ track.stop();
772
+ } catch {
773
+ }
774
+ }
775
+ }
776
+ function setTracksEnabled(stream, enabled) {
777
+ for (const track of stream.getTracks()) track.enabled = enabled;
778
+ }
779
+ function hasLiveTrack(stream) {
780
+ return stream.getTracks().some((track) => track.readyState === "live");
781
+ }
782
+ function deviceOf(stream, requested) {
783
+ const track = stream.getTracks()[0];
784
+ const reported = typeof track?.getSettings === "function" ? track.getSettings().deviceId : void 0;
785
+ return reported || requested || null;
786
+ }
787
+ var LocalMediaController = class {
788
+ #env;
789
+ #hooks;
790
+ #log;
791
+ #state = { camera: false, microphone: false, screen: false };
792
+ #streams = { camera: null, microphone: null, screen: null };
793
+ #devices = { camera: null, microphone: null, screen: null };
794
+ #tails = {
795
+ camera: Promise.resolve(),
796
+ microphone: Promise.resolve(),
797
+ screen: Promise.resolve()
798
+ };
799
+ #queued = { camera: [], microphone: [], screen: [] };
800
+ #released = false;
801
+ #generation = 0;
802
+ constructor(env, hooks, log) {
803
+ this.#env = env;
804
+ this.#hooks = hooks;
805
+ this.#log = log;
806
+ }
807
+ get state() {
808
+ return Object.freeze({ ...this.#state });
809
+ }
810
+ get streams() {
811
+ return Object.freeze({
812
+ camera: this.#state.camera ? this.#streams.camera : null,
813
+ microphone: this.#state.microphone ? this.#streams.microphone : null,
814
+ screen: this.#state.screen ? this.#streams.screen : null
815
+ });
816
+ }
817
+ /**
818
+ * Turns `kind` on or off. `deviceId` applies to turning the camera or microphone on: it captures that device, or
819
+ * switches to it in place when `kind` is already on with another device.
820
+ */
821
+ set(kind, enabled, deviceId) {
822
+ const wanted = enabled && kind !== "screen" ? deviceId : void 0;
823
+ const queue = this.#queued[kind];
824
+ const last = queue[queue.length - 1];
825
+ if (last) {
826
+ if (last.target === enabled && last.deviceId === wanted) return last.promise;
827
+ } else if (this.#state[kind] === enabled && !this.#released && !this.#needsSwitch(kind, wanted)) {
828
+ return Promise.resolve();
829
+ }
830
+ const promise = this.#tails[kind].then(() => this.#run(kind, enabled, wanted));
831
+ const operation = { target: enabled, deviceId: wanted, promise };
832
+ queue.push(operation);
833
+ const settle = () => {
834
+ const index = queue.indexOf(operation);
835
+ if (index >= 0) queue.splice(index, 1);
836
+ };
837
+ this.#tails[kind] = promise.then(settle, settle);
838
+ return promise;
839
+ }
840
+ /** Turns `kind` off locally without announcing it, because the meeting refused the change. */
841
+ rollback(kind) {
842
+ if (!this.#state[kind]) return;
843
+ this.#turnOff(kind, false);
844
+ }
845
+ /** Stops every capture. Pending and later changes reject with `not_joined`. Emits nothing. */
846
+ releaseAll() {
847
+ this.#released = true;
848
+ this.#generation += 1;
849
+ for (const kind of KINDS) {
850
+ const stream = this.#streams[kind];
851
+ if (stream) stopTracks(stream);
852
+ this.#streams[kind] = null;
853
+ this.#devices[kind] = null;
854
+ this.#state[kind] = false;
855
+ }
856
+ }
857
+ /** True when `kind` is on and a specific device other than the one in use was asked for. */
858
+ #needsSwitch(kind, deviceId) {
859
+ return this.#state[kind] && deviceId !== void 0 && deviceId !== this.#devices[kind];
860
+ }
861
+ async #run(kind, enabled, deviceId) {
862
+ if (this.#released) throw new MeetingError("not_joined");
863
+ if (enabled && this.#needsSwitch(kind, deviceId)) return this.#switchDevice(kind, deviceId);
864
+ if (this.#state[kind] === enabled) return;
865
+ if (enabled) await this.#turnOn(kind, deviceId);
866
+ else this.#turnOff(kind, true);
867
+ }
868
+ /**
869
+ * Replaces a running capture with one from another device. The new capture is handed on before the old one stops,
870
+ * so the meeting keeps receiving media; nothing is announced because the on/off state does not change. When the
871
+ * new device cannot be captured, the old capture keeps running.
872
+ */
873
+ async #switchDevice(kind, deviceId) {
874
+ const generation = this.#generation;
875
+ const stream = await this.#capture(kind, deviceId);
876
+ if (generation !== this.#generation || this.#released || !this.#state[kind]) {
877
+ stopTracks(stream);
878
+ throw new MeetingError("not_joined");
879
+ }
880
+ const previous = this.#streams[kind];
881
+ this.#streams[kind] = stream;
882
+ this.#devices[kind] = deviceOf(stream, deviceId);
883
+ this.#watchEnded(kind, stream);
884
+ this.#hooks.changed(kind, true, stream);
885
+ if (previous) stopTracks(previous);
886
+ }
887
+ async #turnOn(kind, deviceId) {
888
+ if (kind === "microphone") {
889
+ const kept = this.#streams.microphone;
890
+ const sameDevice = deviceId === void 0 || deviceId === this.#devices.microphone;
891
+ if (kept && hasLiveTrack(kept) && sameDevice) {
892
+ setTracksEnabled(kept, true);
893
+ if (!this.#hooks.announce(kind, true)) {
894
+ setTracksEnabled(kept, false);
895
+ throw new MeetingError("not_joined");
896
+ }
897
+ this.#state.microphone = true;
898
+ this.#hooks.changed(kind, true, kept);
899
+ return;
900
+ }
901
+ if (kept && !hasLiveTrack(kept)) {
902
+ stopTracks(kept);
903
+ this.#streams.microphone = null;
904
+ this.#devices.microphone = null;
905
+ }
906
+ }
907
+ const generation = this.#generation;
908
+ const stream = await this.#capture(kind, deviceId);
909
+ if (generation !== this.#generation || this.#released || !this.#hooks.announce(kind, true)) {
910
+ stopTracks(stream);
911
+ throw new MeetingError("not_joined");
912
+ }
913
+ const replaced = this.#streams[kind];
914
+ this.#streams[kind] = stream;
915
+ this.#devices[kind] = kind === "screen" ? null : deviceOf(stream, deviceId);
916
+ this.#state[kind] = true;
917
+ this.#watchEnded(kind, stream);
918
+ this.#hooks.changed(kind, true, stream);
919
+ if (replaced) stopTracks(replaced);
920
+ }
921
+ #turnOff(kind, announce) {
922
+ const stream = this.#streams[kind];
923
+ if (kind === "microphone") {
924
+ if (stream) setTracksEnabled(stream, false);
925
+ } else {
926
+ if (stream) stopTracks(stream);
927
+ this.#streams[kind] = null;
928
+ this.#devices[kind] = null;
929
+ }
930
+ this.#state[kind] = false;
931
+ const sent = !announce || this.#hooks.announce(kind, false);
932
+ this.#hooks.changed(kind, false, null);
933
+ if (!sent) throw new MeetingError("not_joined");
934
+ }
935
+ async #capture(kind, deviceId) {
936
+ const devices = this.#env.mediaDevices;
937
+ try {
938
+ if (kind === "screen") {
939
+ if (typeof devices?.getDisplayMedia !== "function") throw new MeetingError("unsupported_environment");
940
+ return await devices.getDisplayMedia({ video: true, audio: false });
941
+ }
942
+ if (typeof devices?.getUserMedia !== "function") throw new MeetingError("unsupported_environment");
943
+ const track = deviceId === void 0 ? true : { deviceId: { exact: deviceId } };
944
+ return await devices.getUserMedia(kind === "camera" ? { video: track, audio: false } : { audio: track, video: false });
945
+ } catch (error) {
946
+ const mapped = mapMediaError(kind, error);
947
+ this.#log.warn("capture failed", { kind, code: mapped.code });
948
+ throw mapped;
949
+ }
950
+ }
951
+ /** A capture the browser or device ended (unplugged, revoked, "Stop sharing") turns that kind off and is announced. */
952
+ #watchEnded(kind, stream) {
953
+ const onEnded = () => {
954
+ if (this.#streams[kind] !== stream) return;
955
+ if (!this.#state[kind]) {
956
+ if (kind === "microphone") {
957
+ this.#streams.microphone = null;
958
+ this.#devices.microphone = null;
959
+ }
960
+ return;
961
+ }
962
+ this.#log.info(kind === "screen" ? "screen share ended by the browser" : "capture ended", { kind });
963
+ this.set(kind, false).catch(() => void 0);
964
+ };
965
+ for (const track of stream.getTracks()) track.addEventListener("ended", onEnded, { once: true });
966
+ }
967
+ };
968
+
969
+ // src/internal/log.ts
970
+ var ALLOWED_KEYS = /* @__PURE__ */ new Set([
971
+ "state",
972
+ "code",
973
+ "reason",
974
+ "kind",
975
+ "participantId",
976
+ "meetingId",
977
+ "closeCode",
978
+ "messageType",
979
+ "durationMs",
980
+ "attempt",
981
+ "path",
982
+ "localPath",
983
+ "remotePath"
984
+ ]);
985
+ var JWT_SHAPE = /[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/;
986
+ var NEGOTIATION_CONTENT = /sdp|candidate/i;
987
+ function isSafeString(value) {
988
+ return !JWT_SHAPE.test(value) && !NEGOTIATION_CONTENT.test(value);
989
+ }
990
+ function sanitize(fields) {
991
+ if (!fields) return void 0;
992
+ const out = {};
993
+ for (const [key, value] of Object.entries(fields)) {
994
+ if (!ALLOWED_KEYS.has(key) || value === void 0) continue;
995
+ if (typeof value === "string" && !isSafeString(value)) continue;
996
+ out[key] = value;
997
+ }
998
+ return out;
999
+ }
1000
+ var Log = class {
1001
+ #logger;
1002
+ constructor(logger) {
1003
+ this.#logger = logger;
1004
+ }
1005
+ debug(message, fields) {
1006
+ this.#write("debug", message, fields);
1007
+ }
1008
+ info(message, fields) {
1009
+ this.#write("info", message, fields);
1010
+ }
1011
+ warn(message, fields) {
1012
+ this.#write("warn", message, fields);
1013
+ }
1014
+ error(message, fields) {
1015
+ this.#write("error", message, fields);
1016
+ }
1017
+ #write(level, message, fields) {
1018
+ const logger = this.#logger;
1019
+ if (!logger) return;
1020
+ try {
1021
+ const method = logger[level];
1022
+ if (typeof method !== "function") return;
1023
+ const safe = sanitize(fields);
1024
+ if (safe) method.call(logger, message, safe);
1025
+ else method.call(logger, message);
1026
+ } catch {
1027
+ }
1028
+ }
1029
+ };
1030
+
1031
+ // src/internal/mediaDelivery.ts
1032
+ var NO_REMOTE_MEDIA = Object.freeze({ camera: null, microphone: null, screen: null });
1033
+ var SignalingOnlyMediaDelivery = class {
1034
+ attach() {
1035
+ }
1036
+ peersPresent() {
1037
+ }
1038
+ peerPresent() {
1039
+ }
1040
+ peerMediaChanged() {
1041
+ }
1042
+ peerLeft() {
1043
+ }
1044
+ peerReconnected() {
1045
+ }
1046
+ onPeerFrame() {
1047
+ }
1048
+ iceServersUpdated() {
1049
+ }
1050
+ localMediaChanged() {
1051
+ }
1052
+ supportsScreenShare() {
1053
+ return false;
1054
+ }
1055
+ remoteMedia() {
1056
+ return NO_REMOTE_MEDIA;
1057
+ }
1058
+ connectionStatus() {
1059
+ return null;
1060
+ }
1061
+ close() {
1062
+ }
1063
+ };
1064
+
1065
+ // src/internal/roster.ts
1066
+ function snapshot(entry) {
1067
+ const media = Object.freeze({ ...entry.media });
1068
+ const participant = {
1069
+ id: entry.id,
1070
+ role: entry.role,
1071
+ media
1072
+ };
1073
+ if (entry.displayName !== void 0) participant.displayName = entry.displayName;
1074
+ return Object.freeze(participant);
1075
+ }
1076
+ var Roster = class {
1077
+ #entries = /* @__PURE__ */ new Map();
1078
+ #committed = false;
1079
+ #cache;
1080
+ get committed() {
1081
+ return this.#committed;
1082
+ }
1083
+ upsertJoined(id, role2, displayName) {
1084
+ const existing = this.#entries.get(id);
1085
+ this.#cache = void 0;
1086
+ if (existing) {
1087
+ existing.role = role2;
1088
+ if (displayName !== void 0) existing.displayName = displayName;
1089
+ else delete existing.displayName;
1090
+ return this.#committed ? "updated" : "buffered";
1091
+ }
1092
+ const entry = { id, role: role2, media: { camera: false, microphone: false, screen: false } };
1093
+ if (displayName !== void 0) entry.displayName = displayName;
1094
+ this.#entries.set(id, entry);
1095
+ return this.#committed ? "added" : "buffered";
1096
+ }
1097
+ /** Returns true when the participant's state changed. Unknown participants are ignored after commit. */
1098
+ setMedia(id, kind, enabled) {
1099
+ let entry = this.#entries.get(id);
1100
+ if (!entry) {
1101
+ if (this.#committed) return false;
1102
+ this.upsertJoined(id, "participant");
1103
+ entry = this.#entries.get(id);
1104
+ if (!entry) return false;
1105
+ }
1106
+ if (entry.media[kind] === enabled) return false;
1107
+ entry.media[kind] = enabled;
1108
+ this.#cache = void 0;
1109
+ return true;
1110
+ }
1111
+ remove(id) {
1112
+ const removed = this.#entries.delete(id);
1113
+ if (removed) this.#cache = void 0;
1114
+ return removed;
1115
+ }
1116
+ commit() {
1117
+ this.#committed = true;
1118
+ this.#cache = void 0;
1119
+ }
1120
+ get(id) {
1121
+ const entry = this.#entries.get(id);
1122
+ return entry ? snapshot(entry) : void 0;
1123
+ }
1124
+ /** Committed participants in arrival order; empty before commit. */
1125
+ list() {
1126
+ if (!this.#committed) return Object.freeze([]);
1127
+ this.#cache ??= Object.freeze([...this.#entries.values()].map(snapshot));
1128
+ return this.#cache;
1129
+ }
1130
+ clear() {
1131
+ this.#entries.clear();
1132
+ this.#cache = void 0;
1133
+ }
1134
+ };
1135
+
1136
+ // src/internal/signalingSession.ts
1137
+ var SIGNALING_SUBPROTOCOL = "strevokit.signaling.v1";
1138
+ var TOKEN_SUBPROTOCOL_PREFIX = "strevokit.join-token.";
1139
+ var ABNORMAL_CLOSE = 1006;
1140
+ var OUTBOUND_TYPES = Object.freeze([
1141
+ "participant.join",
1142
+ "participant.leave",
1143
+ "meeting.end",
1144
+ "media.camera.started",
1145
+ "media.camera.stopped",
1146
+ "media.microphone.started",
1147
+ "media.microphone.stopped",
1148
+ "media.screen.started",
1149
+ "media.screen.stopped"
1150
+ ]);
1151
+ var PEER_OUTBOUND_TYPES = Object.freeze([
1152
+ "webrtc.offer",
1153
+ "webrtc.answer",
1154
+ "webrtc.ice_candidate",
1155
+ "webrtc.connected",
1156
+ "webrtc.disconnected",
1157
+ "webrtc.failed",
1158
+ "webrtc.ice_restart",
1159
+ "webrtc.ice_restart_result"
1160
+ ]);
1161
+ var SignalingSession = class {
1162
+ #env;
1163
+ #url;
1164
+ #addressing;
1165
+ #log;
1166
+ #token;
1167
+ #callbacks;
1168
+ #socket;
1169
+ #opened = false;
1170
+ constructor(options) {
1171
+ this.#env = options.env;
1172
+ this.#url = `${options.realtimeUrl}/v1/meetings/${options.addressing.meetingId}/signaling`;
1173
+ this.#addressing = options.addressing;
1174
+ this.#log = options.log;
1175
+ this.#token = options.token;
1176
+ this.#callbacks = options.callbacks;
1177
+ }
1178
+ open() {
1179
+ const token = this.#token;
1180
+ this.#token = void 0;
1181
+ const Socket = this.#env.WebSocket;
1182
+ if (!Socket || token === void 0) {
1183
+ this.#fail();
1184
+ return;
1185
+ }
1186
+ let socket;
1187
+ try {
1188
+ socket = new Socket(this.#url, [SIGNALING_SUBPROTOCOL, `${TOKEN_SUBPROTOCOL_PREFIX}${token}`]);
1189
+ } catch {
1190
+ this.#log.warn("connection could not be created");
1191
+ this.#fail();
1192
+ return;
1193
+ }
1194
+ this.#socket = socket;
1195
+ socket.onopen = () => this.#handleOpen();
1196
+ socket.onmessage = (event) => this.#handleFrame(event.data);
1197
+ socket.onclose = (event) => this.#handleClose(event.code, event.reason);
1198
+ socket.onerror = () => void 0;
1199
+ }
1200
+ /** Sends an empty-payload frame. Returns false when the connection is not open or the frame cannot be built. */
1201
+ send(type) {
1202
+ const socket = this.#socket;
1203
+ if (!socket || !this.#opened || !this.#callbacks) return false;
1204
+ try {
1205
+ const message = createSignalingMessage(type, {
1206
+ meetingId: this.#addressing.meetingId,
1207
+ participantId: this.#addressing.participantId,
1208
+ payload: {}
1209
+ });
1210
+ socket.send(encodeSignalingMessage(message));
1211
+ return true;
1212
+ } catch {
1213
+ this.#log.warn("frame could not be sent", { messageType: type });
1214
+ return false;
1215
+ }
1216
+ }
1217
+ /**
1218
+ * Sends a media frame. Returns false when the connection is not open or the frame fails validation. The payload
1219
+ * is never logged.
1220
+ */
1221
+ sendPeer(type, payload) {
1222
+ const socket = this.#socket;
1223
+ if (!socket || !this.#opened || !this.#callbacks) return false;
1224
+ try {
1225
+ const message = createSignalingMessage(type, {
1226
+ meetingId: this.#addressing.meetingId,
1227
+ participantId: this.#addressing.participantId,
1228
+ payload
1229
+ });
1230
+ socket.send(encodeSignalingMessage(message));
1231
+ return true;
1232
+ } catch {
1233
+ this.#log.debug("frame could not be sent", { messageType: type });
1234
+ return false;
1235
+ }
1236
+ }
1237
+ /** Closes the connection. No further callbacks are made. */
1238
+ close(code = 1e3) {
1239
+ this.#callbacks = void 0;
1240
+ const socket = this.#socket;
1241
+ if (!socket) return;
1242
+ try {
1243
+ socket.close(code);
1244
+ } catch {
1245
+ }
1246
+ }
1247
+ #handleOpen() {
1248
+ if (!this.#callbacks) return;
1249
+ if (this.#socket?.protocol !== SIGNALING_SUBPROTOCOL) {
1250
+ this.#log.warn("unexpected subprotocol negotiated");
1251
+ const socket = this.#socket;
1252
+ this.#fail();
1253
+ try {
1254
+ socket?.close(1e3);
1255
+ } catch {
1256
+ }
1257
+ return;
1258
+ }
1259
+ this.#opened = true;
1260
+ }
1261
+ #handleFrame(data) {
1262
+ const callbacks = this.#callbacks;
1263
+ if (!callbacks) return;
1264
+ const result = decodeSignalingFrame(data, { direction: "server_to_client" });
1265
+ try {
1266
+ if (result.ok) callbacks.onMessage(result.message);
1267
+ else {
1268
+ this.#log.debug("invalid frame received", { code: result.error.code });
1269
+ callbacks.onProtocolError();
1270
+ }
1271
+ } catch {
1272
+ this.#log.error("frame handling failed");
1273
+ }
1274
+ }
1275
+ #handleClose(code, reason) {
1276
+ const callbacks = this.#callbacks;
1277
+ this.#callbacks = void 0;
1278
+ if (!callbacks) return;
1279
+ try {
1280
+ callbacks.onClose(typeof code === "number" ? code : ABNORMAL_CLOSE, typeof reason === "string" ? reason : "", this.#opened);
1281
+ } catch {
1282
+ this.#log.error("close handling failed");
1283
+ }
1284
+ }
1285
+ #fail() {
1286
+ const callbacks = this.#callbacks;
1287
+ this.#callbacks = void 0;
1288
+ if (!callbacks) return;
1289
+ try {
1290
+ callbacks.onClose(ABNORMAL_CLOSE, "", false);
1291
+ } catch {
1292
+ this.#log.error("close handling failed");
1293
+ }
1294
+ }
1295
+ };
1296
+
1297
+ // src/internal/engine.ts
1298
+ var LEAVE_FALLBACK_MS = 5e3;
1299
+ var RESTRICTED_PENDING_MS = 2e3;
1300
+ var END_MEETING_TIMEOUT_MS = 1e4;
1301
+ var MEDIA_KINDS = ["camera", "microphone", "screen"];
1302
+ function deferred() {
1303
+ let resolve;
1304
+ let reject2;
1305
+ const promise = new Promise((res, rej) => {
1306
+ resolve = res;
1307
+ reject2 = rej;
1308
+ });
1309
+ return { promise, resolve, reject: reject2 };
1310
+ }
1311
+ function mediaFrame(type) {
1312
+ const match = /^media\.(camera|microphone|screen)\.(started|stopped)$/.exec(type);
1313
+ if (!match) return void 0;
1314
+ return { kind: match[1], enabled: match[2] === "started" };
1315
+ }
1316
+ var MeetingEngineImpl = class {
1317
+ #realtimeUrl;
1318
+ #joinTimeoutMs;
1319
+ #addressing;
1320
+ #env;
1321
+ #createDelivery;
1322
+ #delivery = new SignalingOnlyMediaDelivery();
1323
+ #log;
1324
+ #emitter;
1325
+ #roster = new Roster();
1326
+ #media;
1327
+ #token;
1328
+ #state = "idle";
1329
+ #disposed = false;
1330
+ #session;
1331
+ #self = null;
1332
+ #role;
1333
+ #join;
1334
+ #leave;
1335
+ #joinTimer;
1336
+ #leaveTimer;
1337
+ #endMeeting;
1338
+ #endMeetingTimer;
1339
+ #pendingRestricted;
1340
+ constructor(config) {
1341
+ this.#token = config.token;
1342
+ this.#realtimeUrl = config.realtimeUrl;
1343
+ this.#joinTimeoutMs = config.joinTimeoutMs;
1344
+ this.#addressing = config.addressing;
1345
+ this.#env = config.env;
1346
+ this.#createDelivery = config.createMediaDelivery;
1347
+ this.#log = new Log(config.logger);
1348
+ this.#emitter = new Emitter(this.#log);
1349
+ this.#media = new LocalMediaController(
1350
+ config.env,
1351
+ {
1352
+ announce: (kind, enabled) => this.#announce(kind, enabled),
1353
+ changed: (kind, enabled, stream) => {
1354
+ this.#emit("local_media_changed", { kind, enabled, stream });
1355
+ this.#delivery.localMediaChanged(kind, stream);
1356
+ }
1357
+ },
1358
+ this.#log
1359
+ );
1360
+ }
1361
+ get state() {
1362
+ return this.#state;
1363
+ }
1364
+ get self() {
1365
+ return this.#self;
1366
+ }
1367
+ get participants() {
1368
+ return this.#roster.list();
1369
+ }
1370
+ get localMediaState() {
1371
+ return this.#media.state;
1372
+ }
1373
+ get localMedia() {
1374
+ return this.#media.streams;
1375
+ }
1376
+ remoteMedia(participantId) {
1377
+ return this.#delivery.remoteMedia(participantId);
1378
+ }
1379
+ mediaConnectionStatus(participantId) {
1380
+ return this.#delivery.connectionStatus(participantId);
1381
+ }
1382
+ join() {
1383
+ if (this.#state === "closed") return Promise.reject(new MeetingError("engine_closed"));
1384
+ if (this.#state !== "idle") return Promise.reject(new MeetingError("already_joined"));
1385
+ const token = this.#token;
1386
+ this.#token = void 0;
1387
+ if (!this.#env.WebSocket || token === void 0) return Promise.reject(new MeetingError("unsupported_environment"));
1388
+ const join = deferred();
1389
+ this.#join = join;
1390
+ this.#setState("connecting");
1391
+ if (!this.#is("connecting")) return join.promise;
1392
+ this.#joinTimer = this.#env.setTimeout(() => this.#failJoin(new MeetingError("join_timeout")), this.#joinTimeoutMs);
1393
+ const session = new SignalingSession({
1394
+ env: this.#env,
1395
+ realtimeUrl: this.#realtimeUrl,
1396
+ token,
1397
+ addressing: this.#addressing,
1398
+ log: this.#log,
1399
+ callbacks: {
1400
+ onMessage: (message) => this.#onMessage(message),
1401
+ onProtocolError: () => this.#onProtocolError(),
1402
+ onClose: (code) => this.#onClose(session, code)
1403
+ }
1404
+ });
1405
+ this.#session = session;
1406
+ session.open();
1407
+ return join.promise;
1408
+ }
1409
+ leave() {
1410
+ switch (this.#state) {
1411
+ case "idle":
1412
+ case "closed":
1413
+ return Promise.resolve();
1414
+ case "leaving":
1415
+ return this.#leave?.promise ?? Promise.resolve();
1416
+ case "connecting":
1417
+ case "joining":
1418
+ this.#failJoin(new MeetingError("join_cancelled"));
1419
+ return Promise.resolve();
1420
+ case "joined":
1421
+ break;
1422
+ }
1423
+ const leave = deferred();
1424
+ this.#leave = leave;
1425
+ this.#media.releaseAll();
1426
+ this.#releaseRemoteMedia();
1427
+ this.#setState("leaving");
1428
+ if (!this.#is("leaving")) return leave.promise;
1429
+ if (!this.#session?.send("participant.leave")) {
1430
+ this.#finish("left");
1431
+ return leave.promise;
1432
+ }
1433
+ this.#leaveTimer = this.#env.setTimeout(() => this.#finish("left"), LEAVE_FALLBACK_MS);
1434
+ return leave.promise;
1435
+ }
1436
+ endMeeting() {
1437
+ if (this.#state !== "joined") return Promise.reject(new MeetingError("not_joined"));
1438
+ if (this.#role !== "host") return Promise.reject(new MeetingError("not_permitted"));
1439
+ if (this.#endMeeting) return this.#endMeeting.promise;
1440
+ const pending = deferred();
1441
+ this.#endMeeting = pending;
1442
+ if (!this.#session?.send("meeting.end")) {
1443
+ this.#endMeeting = void 0;
1444
+ return Promise.reject(new MeetingError("service_unavailable"));
1445
+ }
1446
+ this.#endMeetingTimer = this.#env.setTimeout(() => {
1447
+ const p = this.#endMeeting;
1448
+ this.#endMeeting = void 0;
1449
+ this.#endMeetingTimer = void 0;
1450
+ p?.reject(new MeetingError("service_unavailable"));
1451
+ }, END_MEETING_TIMEOUT_MS);
1452
+ return pending.promise;
1453
+ }
1454
+ enableCamera(options) {
1455
+ return this.#mediaCall("camera", true, options?.deviceId);
1456
+ }
1457
+ disableCamera() {
1458
+ return this.#mediaCall("camera", false);
1459
+ }
1460
+ mute() {
1461
+ return this.#mediaCall("microphone", false);
1462
+ }
1463
+ unmute(options) {
1464
+ return this.#mediaCall("microphone", true, options?.deviceId);
1465
+ }
1466
+ startScreenShare() {
1467
+ return this.#mediaCall("screen", true);
1468
+ }
1469
+ stopScreenShare() {
1470
+ return this.#mediaCall("screen", false);
1471
+ }
1472
+ on(type, listener) {
1473
+ if (this.#disposed) return () => void 0;
1474
+ return this.#emitter.on(type, listener);
1475
+ }
1476
+ dispose() {
1477
+ if (this.#disposed) return;
1478
+ this.#disposed = true;
1479
+ this.#emitter.clear();
1480
+ const end = this.#endMeeting;
1481
+ this.#endMeeting = void 0;
1482
+ this.#teardown();
1483
+ this.#state = "closed";
1484
+ const join = this.#join;
1485
+ this.#join = void 0;
1486
+ if (join) {
1487
+ join.promise.catch(() => void 0);
1488
+ join.reject(new MeetingError("join_cancelled"));
1489
+ }
1490
+ this.#leave?.resolve();
1491
+ end?.reject(new MeetingError("engine_closed"));
1492
+ }
1493
+ #mediaCall(kind, enabled, deviceId) {
1494
+ if (this.#state !== "joined") return Promise.reject(new MeetingError("not_joined"));
1495
+ if (kind === "screen") {
1496
+ const type = enabled ? "media.screen.started" : "media.screen.stopped";
1497
+ if (this.#role === void 0 || !isRoleAllowed(type, this.#role)) {
1498
+ return Promise.reject(new MeetingError("not_permitted"));
1499
+ }
1500
+ if (enabled && !this.#delivery.supportsScreenShare()) {
1501
+ return Promise.reject(new MeetingError("unsupported_environment"));
1502
+ }
1503
+ }
1504
+ return this.#media.set(kind, enabled, typeof deviceId === "string" && deviceId !== "" ? deviceId : void 0);
1505
+ }
1506
+ #announce(kind, enabled) {
1507
+ if (this.#state !== "joined") return false;
1508
+ const type = `media.${kind}.${enabled ? "started" : "stopped"}`;
1509
+ if (!this.#session?.send(type)) return false;
1510
+ if (this.#pendingRestricted?.kind === kind) this.#clearPendingRestricted();
1511
+ if (ROLE_POLICY[type]) {
1512
+ this.#pendingRestricted = {
1513
+ kind,
1514
+ timer: this.#env.setTimeout(() => {
1515
+ this.#pendingRestricted = void 0;
1516
+ }, RESTRICTED_PENDING_MS)
1517
+ };
1518
+ }
1519
+ return true;
1520
+ }
1521
+ #clearPendingRestricted() {
1522
+ if (this.#pendingRestricted) this.#env.clearTimeout(this.#pendingRestricted.timer);
1523
+ this.#pendingRestricted = void 0;
1524
+ }
1525
+ #onMessage(message) {
1526
+ const state = this.#state;
1527
+ if (state === "closed" || state === "idle") return;
1528
+ const from = message.participantId;
1529
+ const isSelf = from === this.#addressing.participantId;
1530
+ switch (message.type) {
1531
+ case "session.authenticated": {
1532
+ if (state !== "connecting") return;
1533
+ this.#role = message.payload.role;
1534
+ this.#setState("joining");
1535
+ if (!this.#is("joining")) return;
1536
+ if (!this.#session?.send("participant.join")) this.#failJoin(new MeetingError("connection_failed"));
1537
+ return;
1538
+ }
1539
+ case "session.welcome": {
1540
+ if (state !== "joining") return;
1541
+ this.#clearTimer("join");
1542
+ this.#roster.commit();
1543
+ const payload = message.payload;
1544
+ const self = {
1545
+ id: this.#addressing.participantId,
1546
+ role: payload.role,
1547
+ joinedAt: payload.joinedAt
1548
+ };
1549
+ if (payload.displayName !== void 0) self.displayName = payload.displayName;
1550
+ this.#self = Object.freeze(self);
1551
+ this.#role = payload.role;
1552
+ const join = this.#join;
1553
+ this.#join = void 0;
1554
+ this.#setState("joined");
1555
+ this.#emit("joined", { self: this.#self, participants: this.#roster.list() });
1556
+ join?.resolve();
1557
+ if (this.#is("joined")) {
1558
+ const expiresAt = payload.iceServersExpireAt !== void 0 ? Date.parse(payload.iceServersExpireAt) : NaN;
1559
+ this.#startDelivery(payload.mediaMode, payload.iceServers ?? [], Number.isFinite(expiresAt) ? expiresAt : null);
1560
+ }
1561
+ return;
1562
+ }
1563
+ case "session.ice_servers": {
1564
+ if (!this.#is("joined")) {
1565
+ this.#log.debug("frame ignored", { messageType: message.type, state });
1566
+ return;
1567
+ }
1568
+ const expiresAt = Date.parse(message.payload.expiresAt);
1569
+ if (Number.isFinite(expiresAt)) this.#delivery.iceServersUpdated(message.payload.iceServers, expiresAt);
1570
+ return;
1571
+ }
1572
+ case "participant.joined": {
1573
+ if (isSelf || state === "connecting") return;
1574
+ const result = this.#roster.upsertJoined(from, message.payload.role, message.payload.displayName);
1575
+ const participant = this.#roster.get(from);
1576
+ if (result === "added" && participant) {
1577
+ this.#emit("participant_joined", { participant });
1578
+ const present = this.#roster.committed && this.#is("joined") ? this.#roster.get(from) : void 0;
1579
+ if (present) this.#delivery.peerPresent(from, present.media);
1580
+ }
1581
+ return;
1582
+ }
1583
+ case "participant.left": {
1584
+ if (isSelf || state === "connecting") return;
1585
+ if (this.#roster.remove(from) && this.#roster.committed) {
1586
+ this.#delivery.peerLeft(from);
1587
+ this.#emit("participant_left", { participantId: from, reason: mapLeftReason(message.payload.reason) });
1588
+ }
1589
+ return;
1590
+ }
1591
+ case "participant.reconnected": {
1592
+ if (isSelf || !this.#roster.committed) return;
1593
+ this.#delivery.peerReconnected(from);
1594
+ this.#emit("participant_reconnected", { participantId: from });
1595
+ return;
1596
+ }
1597
+ case "media.camera.started":
1598
+ case "media.camera.stopped":
1599
+ case "media.microphone.started":
1600
+ case "media.microphone.stopped":
1601
+ case "media.screen.started":
1602
+ case "media.screen.stopped": {
1603
+ const change = mediaFrame(message.type);
1604
+ if (isSelf || state === "connecting" || !change) return;
1605
+ if (this.#roster.setMedia(from, change.kind, change.enabled) && this.#roster.committed) {
1606
+ this.#emit("participant_media_changed", { participantId: from, kind: change.kind, enabled: change.enabled });
1607
+ this.#delivery.peerMediaChanged(from, change.kind, change.enabled);
1608
+ }
1609
+ return;
1610
+ }
1611
+ case "meeting.ended":
1612
+ case "meeting.terminated": {
1613
+ this.#log.info("meeting ended", { messageType: message.type });
1614
+ if (state === "connecting" || state === "joining") this.#failJoin(new MeetingError("meeting_ended"));
1615
+ else this.#finish("meeting_ended");
1616
+ return;
1617
+ }
1618
+ case "session.rejected": {
1619
+ const payload = message.payload;
1620
+ const isEndMeetingRejection = payload.ref_type === "meeting.end" || payload.refType === "meeting.end" || this.#endMeeting !== void 0;
1621
+ if (this.#endMeeting && isEndMeetingRejection) {
1622
+ const deferred2 = this.#endMeeting;
1623
+ this.#clearEndMeetingTimer();
1624
+ this.#endMeeting = void 0;
1625
+ let mappedCode = "service_unavailable";
1626
+ if (payload.code === "role_not_allowed") mappedCode = "not_permitted";
1627
+ else if (payload.code === "rate_limited") mappedCode = "rate_limited";
1628
+ deferred2.reject(new MeetingError(mappedCode));
1629
+ return;
1630
+ }
1631
+ this.#onRejected(payload.code);
1632
+ return;
1633
+ }
1634
+ case "webrtc.offer":
1635
+ case "webrtc.answer":
1636
+ case "webrtc.ice_candidate":
1637
+ case "webrtc.ice_restart":
1638
+ this.#delivery.onPeerFrame(message);
1639
+ return;
1640
+ default:
1641
+ this.#log.debug("frame ignored", { messageType: message.type });
1642
+ }
1643
+ }
1644
+ #startDelivery(mediaMode, iceServers2, iceServersExpireAt) {
1645
+ const delivery = this.#createDelivery(mediaMode, { env: this.#env, log: this.#log });
1646
+ this.#delivery = delivery;
1647
+ delivery.attach({
1648
+ participantId: this.#addressing.participantId,
1649
+ iceServers: iceServers2,
1650
+ iceServersExpireAt,
1651
+ send: (type, payload) => this.#delivery === delivery && (this.#session?.sendPeer(type, payload) ?? false),
1652
+ emit: (type, event) => {
1653
+ if (this.#delivery === delivery) this.#emit(type, event);
1654
+ }
1655
+ });
1656
+ const local = this.#media.streams;
1657
+ for (const kind of MEDIA_KINDS) {
1658
+ const stream = local[kind];
1659
+ if (stream) delivery.localMediaChanged(kind, stream);
1660
+ }
1661
+ if (this.#delivery !== delivery || !this.#is("joined")) return;
1662
+ delivery.peersPresent(this.#roster.list().map((participant) => ({ id: participant.id, media: participant.media })));
1663
+ }
1664
+ /** Reports every remote media and connection status as gone, then closes the delivery. */
1665
+ #releaseRemoteMedia() {
1666
+ const delivery = this.#delivery;
1667
+ for (const participant of this.#roster.list()) {
1668
+ const media = delivery.remoteMedia(participant.id);
1669
+ for (const kind of MEDIA_KINDS) {
1670
+ if (media[kind]) this.#emit("remote_media_changed", { participantId: participant.id, kind, stream: null });
1671
+ }
1672
+ if (delivery.connectionStatus(participant.id) !== null) {
1673
+ this.#emit("media_connection_changed", { participantId: participant.id, status: null });
1674
+ }
1675
+ }
1676
+ delivery.close();
1677
+ }
1678
+ #onRejected(code) {
1679
+ const error = mapRejection(code);
1680
+ if (this.#state === "connecting" || this.#state === "joining") {
1681
+ this.#failJoin(error);
1682
+ return;
1683
+ }
1684
+ if (code === "role_not_allowed") {
1685
+ const pending = this.#pendingRestricted;
1686
+ this.#clearPendingRestricted();
1687
+ if (pending) this.#media.rollback(pending.kind);
1688
+ }
1689
+ this.#log.warn("request rejected", { code: error.code });
1690
+ this.#emit("error", { error });
1691
+ }
1692
+ #onProtocolError() {
1693
+ if (this.#state === "closed") return;
1694
+ this.#emit("error", { error: new MeetingError("protocol_error") });
1695
+ }
1696
+ #onClose(session, code) {
1697
+ if (this.#session !== session) return;
1698
+ this.#session = void 0;
1699
+ this.#log.info("connection closed", { closeCode: code });
1700
+ switch (this.#state) {
1701
+ case "connecting":
1702
+ case "joining": {
1703
+ const { error } = mapClose(code, "before_joined");
1704
+ this.#failJoin(error ?? new MeetingError("connection_failed"));
1705
+ return;
1706
+ }
1707
+ case "joined": {
1708
+ const { reason, error } = mapClose(code, "after_joined");
1709
+ this.#finish(reason, error);
1710
+ return;
1711
+ }
1712
+ case "leaving":
1713
+ this.#finish("left");
1714
+ return;
1715
+ default:
1716
+ return;
1717
+ }
1718
+ }
1719
+ #failJoin(error) {
1720
+ if (this.#state !== "connecting" && this.#state !== "joining") return;
1721
+ const join = this.#join;
1722
+ this.#join = void 0;
1723
+ this.#teardown();
1724
+ this.#log.warn("join failed", { code: error.code });
1725
+ this.#setState("closed");
1726
+ this.#emit("closed", { reason: "join_failed", error });
1727
+ join?.reject(error);
1728
+ }
1729
+ #finish(reason, error) {
1730
+ if (this.#state === "closed") return;
1731
+ const end = this.#endMeeting;
1732
+ this.#endMeeting = void 0;
1733
+ this.#teardown();
1734
+ this.#setState("closed");
1735
+ this.#emit("closed", error ? { reason, error } : { reason });
1736
+ this.#leave?.resolve();
1737
+ if (end) {
1738
+ if (reason === "meeting_ended") end.resolve();
1739
+ else end.reject(error ?? new MeetingError("engine_closed"));
1740
+ }
1741
+ }
1742
+ #teardown() {
1743
+ this.#clearTimer("join");
1744
+ this.#clearTimer("leave");
1745
+ this.#clearEndMeetingTimer();
1746
+ this.#clearPendingRestricted();
1747
+ this.#media.releaseAll();
1748
+ this.#delivery.close();
1749
+ const session = this.#session;
1750
+ this.#session = void 0;
1751
+ session?.close();
1752
+ this.#roster.clear();
1753
+ }
1754
+ #clearEndMeetingTimer() {
1755
+ if (this.#endMeetingTimer !== void 0) this.#env.clearTimeout(this.#endMeetingTimer);
1756
+ this.#endMeetingTimer = void 0;
1757
+ }
1758
+ #clearTimer(which) {
1759
+ const handle = which === "join" ? this.#joinTimer : this.#leaveTimer;
1760
+ if (handle !== void 0) this.#env.clearTimeout(handle);
1761
+ if (which === "join") this.#joinTimer = void 0;
1762
+ else this.#leaveTimer = void 0;
1763
+ }
1764
+ /** Re-reads the state after emitting, since listeners may have changed it. */
1765
+ #is(state) {
1766
+ return this.#state === state;
1767
+ }
1768
+ #setState(next) {
1769
+ const previous = this.#state;
1770
+ if (previous === next) return;
1771
+ this.#state = next;
1772
+ this.#log.info("state changed", { state: next });
1773
+ this.#emit("state_changed", { state: next, previous });
1774
+ }
1775
+ #emit(type, event) {
1776
+ if (!this.#disposed) this.#emitter.emit(type, event);
1777
+ }
1778
+ };
1779
+
1780
+ // src/internal/selectedPair.ts
1781
+ async function selectedPair(pc) {
1782
+ const reports = await statsOf(pc);
1783
+ if (!reports) return null;
1784
+ const byId = new Map(reports.map((report) => [report.id, report]));
1785
+ const selectedId = reports.find((r) => r.type === "transport" && r.selectedCandidatePairId)?.selectedCandidatePairId;
1786
+ const pair = (selectedId ? byId.get(selectedId) : void 0) ?? reports.find((r) => r.type === "candidate-pair" && r.state === "succeeded" && r.nominated === true);
1787
+ if (!pair?.localCandidateId || !pair.remoteCandidateId) return null;
1788
+ const localType = byId.get(pair.localCandidateId)?.candidateType;
1789
+ const remoteType = byId.get(pair.remoteCandidateId)?.candidateType;
1790
+ if (!localType || !remoteType) return null;
1791
+ return { localId: pair.localCandidateId, localType, remoteType };
1792
+ }
1793
+ async function localCandidateIds(pc) {
1794
+ const reports = await statsOf(pc);
1795
+ if (!reports) return null;
1796
+ return new Set(reports.filter((r) => r.type === "local-candidate").map((r) => r.id));
1797
+ }
1798
+ async function statsOf(pc) {
1799
+ try {
1800
+ const collection = await pc.getStats();
1801
+ const reports = [];
1802
+ collection.forEach((report) => reports.push(report));
1803
+ return reports;
1804
+ } catch {
1805
+ return null;
1806
+ }
1807
+ }
1808
+ function pathReport(pair) {
1809
+ const localPath = kindOf(pair.localType);
1810
+ const remotePath = kindOf(pair.remoteType);
1811
+ return { path: localPath === "relayed" || remotePath === "relayed" ? "relayed" : "direct", localPath, remotePath };
1812
+ }
1813
+ function samePath(a, b) {
1814
+ return a.path === b.path && a.localPath === b.localPath && a.remotePath === b.remotePath;
1815
+ }
1816
+ function kindOf(candidateType) {
1817
+ return candidateType === "relay" ? "relayed" : "direct";
1818
+ }
1819
+
1820
+ // src/internal/peerLink.ts
1821
+ var MAX_QUEUED_CANDIDATES = 64;
1822
+ var CONNECTION_TIMEOUT_MS = 3e4;
1823
+ var SWITCH_ATTEMPT_TIMEOUT_MS = 15e3;
1824
+ var SWITCH_RETRY_DELAYS_MS = [5e3, 1e4, 2e4];
1825
+ var SWITCH_DEADLINE_MARGIN_MS = 5e3;
1826
+ var SWITCH_POLL_MS = 1e3;
1827
+ var SWITCH_INTERRUPTION_HOLD_MS = 5e3;
1828
+ var PATH_RECHECK_DELAYS_MS = [2e3, 1e4];
1829
+ var PATH_RECHECK_INTERVAL_MS = 3e4;
1830
+ function copyServers(iceServers2) {
1831
+ return iceServers2.map((server) => {
1832
+ const copy = { urls: [...server.urls] };
1833
+ if (server.username !== void 0) copy.username = server.username;
1834
+ if (server.credential !== void 0) copy.credential = server.credential;
1835
+ return copy;
1836
+ });
1837
+ }
1838
+ function roleFor(selfId, peerId) {
1839
+ return selfId < peerId ? "offerer" : "answerer";
1840
+ }
1841
+ function coarseState(pc) {
1842
+ const state = typeof pc.connectionState === "string" ? pc.connectionState : pc.iceConnectionState;
1843
+ switch (state) {
1844
+ case "connected":
1845
+ case "completed":
1846
+ return "connected";
1847
+ case "disconnected":
1848
+ return "disconnected";
1849
+ case "failed":
1850
+ return "failed";
1851
+ case "closed":
1852
+ return "closed";
1853
+ default:
1854
+ return "connecting";
1855
+ }
1856
+ }
1857
+ var PeerLink = class {
1858
+ peerId;
1859
+ role;
1860
+ #status = "connecting";
1861
+ #everConnected = false;
1862
+ #closed = false;
1863
+ #pc;
1864
+ #env;
1865
+ #log;
1866
+ #callbacks;
1867
+ #local;
1868
+ #queued = [];
1869
+ #remoteDescriptionSet = false;
1870
+ #offerHandled = false;
1871
+ #audio;
1872
+ #video;
1873
+ #screen;
1874
+ #streams = { camera: null, microphone: null, screen: null };
1875
+ #timer;
1876
+ #chain = Promise.resolve();
1877
+ #switch = null;
1878
+ #restarting = false;
1879
+ #interruptionHold;
1880
+ #lastReport = null;
1881
+ #recheckTimer;
1882
+ /** Throws when the connection cannot be created; the owner treats that as a negotiation failure. */
1883
+ constructor(options) {
1884
+ this.peerId = options.peerId;
1885
+ this.role = roleFor(options.selfId, options.peerId);
1886
+ this.#env = options.env;
1887
+ this.#log = options.log;
1888
+ this.#callbacks = options.callbacks;
1889
+ const { local } = options;
1890
+ this.#local = { ...local };
1891
+ const PeerConnection = options.env.RTCPeerConnection;
1892
+ if (!PeerConnection) throw new Error("peer connections unavailable");
1893
+ this.#pc = new PeerConnection({ iceServers: copyServers(options.iceServers) });
1894
+ this.#pc.onicecandidate = (event) => this.#onLocalCandidate(event.candidate);
1895
+ this.#pc.onconnectionstatechange = () => this.#onStateChange();
1896
+ this.#pc.oniceconnectionstatechange = () => {
1897
+ if (typeof this.#pc.connectionState !== "string") this.#onStateChange();
1898
+ };
1899
+ this.#timer = this.#env.setTimeout(() => {
1900
+ this.#timer = void 0;
1901
+ if (!this.#everConnected) this.#fail("connection_timeout");
1902
+ }, options.timeoutMs);
1903
+ this.#log.debug("media connection created", { participantId: this.peerId });
1904
+ }
1905
+ get status() {
1906
+ return this.#status;
1907
+ }
1908
+ get everConnected() {
1909
+ return this.#everConnected;
1910
+ }
1911
+ /** Usable for rendering: connected at least once, and neither failed nor closed. */
1912
+ get usable() {
1913
+ return this.#everConnected && !this.#closed && this.#status !== "failed";
1914
+ }
1915
+ /** The stable stream holding the received track of `kind`, once the transceivers exist. */
1916
+ remoteStream(kind) {
1917
+ return this.#streams[kind];
1918
+ }
1919
+ /** Offerer: adds the transceivers (audio → camera → screen) and sends the offer. */
1920
+ start() {
1921
+ if (this.role !== "offerer" || this.#audio) return;
1922
+ this.#run(async () => {
1923
+ this.#audio = this.#pc.addTransceiver("audio", { direction: "sendrecv" });
1924
+ this.#video = this.#pc.addTransceiver("video", { direction: "sendrecv" });
1925
+ this.#screen = this.#pc.addTransceiver("video", { direction: "sendrecv" });
1926
+ this.#bindStreams();
1927
+ await this.#attachAll();
1928
+ const offer = await this.#pc.createOffer();
1929
+ await this.#pc.setLocalDescription(offer);
1930
+ if (this.#closed) return;
1931
+ const sdp2 = this.#pc.localDescription?.sdp ?? offer.sdp ?? "";
1932
+ this.#callbacks.sendOffer({ targetParticipantId: this.peerId, sdp: sdp2 });
1933
+ });
1934
+ }
1935
+ /**
1936
+ * Answerer: applies the offer and sends the answer. A later offer (a restart) is answered when no other
1937
+ * negotiation is in progress, keeping the transceivers and received streams; otherwise it is ignored.
1938
+ */
1939
+ acceptOffer(sdp2) {
1940
+ if (this.role !== "answerer") {
1941
+ this.#log.debug("frame ignored", { messageType: "webrtc.offer", reason: "offerer" });
1942
+ return;
1943
+ }
1944
+ if (this.#offerHandled) {
1945
+ this.#acceptReoffer(sdp2);
1946
+ return;
1947
+ }
1948
+ this.#offerHandled = true;
1949
+ this.#run(async () => {
1950
+ await this.#pc.setRemoteDescription({ type: "offer", sdp: sdp2 });
1951
+ this.#remoteDescriptionSet = true;
1952
+ const transceivers = this.#pc.getTransceivers();
1953
+ this.#audio = transceivers[0];
1954
+ this.#video = transceivers[1];
1955
+ this.#screen = transceivers[2];
1956
+ for (const transceiver of [this.#audio, this.#video, this.#screen]) {
1957
+ if (transceiver) transceiver.direction = "sendrecv";
1958
+ }
1959
+ this.#bindStreams();
1960
+ await this.#attachAll();
1961
+ const answer = await this.#pc.createAnswer();
1962
+ await this.#pc.setLocalDescription(answer);
1963
+ if (this.#closed) return;
1964
+ const answerSdp = this.#pc.localDescription?.sdp ?? answer.sdp ?? "";
1965
+ this.#callbacks.sendAnswer({ targetParticipantId: this.peerId, sdp: answerSdp });
1966
+ await this.#flushCandidates();
1967
+ });
1968
+ }
1969
+ /** Offerer: applies the answer while an offer is outstanding; otherwise ignored. */
1970
+ acceptAnswer(sdp2) {
1971
+ if (this.role !== "offerer") {
1972
+ this.#log.debug("frame ignored", { messageType: "webrtc.answer", reason: "answerer" });
1973
+ return;
1974
+ }
1975
+ this.#run(async () => {
1976
+ if (this.#pc.signalingState !== "have-local-offer") {
1977
+ this.#log.debug("frame ignored", { messageType: "webrtc.answer", state: this.#pc.signalingState });
1978
+ return;
1979
+ }
1980
+ if (!this.#restarting) {
1981
+ await this.#pc.setRemoteDescription({ type: "answer", sdp: sdp2 });
1982
+ this.#remoteDescriptionSet = true;
1983
+ await this.#flushCandidates();
1984
+ return;
1985
+ }
1986
+ this.#restarting = false;
1987
+ try {
1988
+ await this.#pc.setRemoteDescription({ type: "answer", sdp: sdp2 });
1989
+ } catch {
1990
+ this.#restartFailed();
1991
+ return;
1992
+ }
1993
+ await this.#flushCandidates();
1994
+ });
1995
+ }
1996
+ /** Replaces the server list for future candidate gathering. The current connection is untouched. */
1997
+ updateIceServers(iceServers2) {
1998
+ if (this.#closed) return;
1999
+ try {
2000
+ this.#pc.setConfiguration({ ...this.#pc.getConfiguration(), iceServers: copyServers(iceServers2) });
2001
+ } catch {
2002
+ this.#log.debug("negotiation step failed", { participantId: this.peerId, reason: "configuration" });
2003
+ }
2004
+ }
2005
+ /**
2006
+ * Moves to fresh candidates before `deadlineMs` when this side sends through a relay, whose allocation is tied to
2007
+ * the credentials about to expire. Direct paths and links that are not connected are left alone.
2008
+ */
2009
+ planSwitch(deadlineMs) {
2010
+ if (this.#closed || this.#status !== "connected") return;
2011
+ const active = this.#switch;
2012
+ if (active) {
2013
+ active.deadline = deadlineMs;
2014
+ return;
2015
+ }
2016
+ void selectedPair(this.#pc).then((pair) => {
2017
+ if (this.#closed || this.#switch || this.#status !== "connected" || pair?.localType !== "relay") return;
2018
+ const started = {
2019
+ deadline: deadlineMs,
2020
+ attempt: 0,
2021
+ known: /* @__PURE__ */ new Set(),
2022
+ attemptTimer: void 0,
2023
+ pollTimer: void 0,
2024
+ retryTimer: void 0
2025
+ };
2026
+ this.#switch = started;
2027
+ this.#log.debug("media path switch started", { participantId: this.peerId });
2028
+ void this.#beginAttempt(started);
2029
+ });
2030
+ }
2031
+ /**
2032
+ * Offerer: renegotiates with fresh candidates. Joins a restart whose answer is still outstanding instead of starting
2033
+ * another; a restart that already finished may predate the other side's fresh servers, so it does not count.
2034
+ */
2035
+ restart(reason) {
2036
+ if (this.role !== "offerer" || this.#closed || this.#restarting) return;
2037
+ this.#restarting = true;
2038
+ this.#log.debug("media connection restart", { participantId: this.peerId, reason });
2039
+ this.#chain = this.#chain.then(async () => {
2040
+ if (this.#closed) return;
2041
+ try {
2042
+ const offer = await this.#pc.createOffer({ iceRestart: true });
2043
+ await this.#pc.setLocalDescription(offer);
2044
+ if (this.#closed) return;
2045
+ const sdp2 = this.#pc.localDescription?.sdp ?? offer.sdp ?? "";
2046
+ this.#callbacks.sendOffer({ targetParticipantId: this.peerId, sdp: sdp2 });
2047
+ } catch {
2048
+ this.#restarting = false;
2049
+ this.#restartFailed();
2050
+ }
2051
+ });
2052
+ }
2053
+ addRemoteCandidate(candidate) {
2054
+ if (this.#closed) return;
2055
+ const copy = {
2056
+ candidate: candidate.candidate,
2057
+ sdpMid: candidate.sdpMid,
2058
+ sdpMLineIndex: candidate.sdpMLineIndex,
2059
+ ...candidate.usernameFragment !== void 0 ? { usernameFragment: candidate.usernameFragment } : {}
2060
+ };
2061
+ if (!this.#remoteDescriptionSet) {
2062
+ if (this.#queued.length >= MAX_QUEUED_CANDIDATES) {
2063
+ this.#log.debug("late candidate dropped", { participantId: this.peerId, reason: "queue_full" });
2064
+ return;
2065
+ }
2066
+ this.#queued.push(copy);
2067
+ return;
2068
+ }
2069
+ void this.#applyCandidate(copy);
2070
+ }
2071
+ /** Sends `track` for `kind` (or nothing for `null`). Skipped when that track is already being sent. */
2072
+ attachLocal(kind, track) {
2073
+ this.#local[kind] = track;
2074
+ if (this.#closed) return;
2075
+ const sender = (kind === "camera" ? this.#video : kind === "screen" ? this.#screen : this.#audio)?.sender;
2076
+ if (!sender || sender.track === track) return;
2077
+ sender.replaceTrack(track).catch(() => {
2078
+ this.#log.debug("negotiation step failed", { participantId: this.peerId, kind, reason: "replace_track" });
2079
+ });
2080
+ }
2081
+ /** Closes the connection and stops received tracks. Reports nothing. Idempotent. */
2082
+ close() {
2083
+ if (this.#closed) return;
2084
+ this.#closed = true;
2085
+ this.#clearTimer();
2086
+ this.#endSwitch();
2087
+ this.#clearInterruptionHold();
2088
+ this.#clearRecheck();
2089
+ this.#pc.onicecandidate = null;
2090
+ this.#pc.onconnectionstatechange = null;
2091
+ this.#pc.oniceconnectionstatechange = null;
2092
+ for (const stream of Object.values(this.#streams)) {
2093
+ for (const track of stream?.getTracks() ?? []) {
2094
+ try {
2095
+ track.stop();
2096
+ } catch {
2097
+ }
2098
+ }
2099
+ }
2100
+ try {
2101
+ this.#pc.close();
2102
+ } catch {
2103
+ }
2104
+ }
2105
+ #run(step) {
2106
+ this.#chain = this.#chain.then(async () => {
2107
+ if (this.#closed) return;
2108
+ try {
2109
+ await step();
2110
+ } catch {
2111
+ if (this.#closed) return;
2112
+ this.#log.debug("negotiation step failed", { participantId: this.peerId });
2113
+ this.#fail("negotiation_failed");
2114
+ }
2115
+ });
2116
+ }
2117
+ #acceptReoffer(sdp2) {
2118
+ this.#run(async () => {
2119
+ if (this.#pc.signalingState !== "stable") {
2120
+ this.#log.debug("frame ignored", { messageType: "webrtc.offer", reason: "busy" });
2121
+ return;
2122
+ }
2123
+ try {
2124
+ await this.#pc.setRemoteDescription({ type: "offer", sdp: sdp2 });
2125
+ const answer = await this.#pc.createAnswer();
2126
+ await this.#pc.setLocalDescription(answer);
2127
+ if (this.#closed) return;
2128
+ const answerSdp = this.#pc.localDescription?.sdp ?? answer.sdp ?? "";
2129
+ this.#callbacks.sendAnswer({ targetParticipantId: this.peerId, sdp: answerSdp });
2130
+ } catch {
2131
+ this.#restartFailed();
2132
+ return;
2133
+ }
2134
+ await this.#flushCandidates();
2135
+ });
2136
+ }
2137
+ /** A restart negotiation step failed: the connection stays as it is and the current attempt, if any, fails. */
2138
+ #restartFailed() {
2139
+ if (this.#closed) return;
2140
+ this.#log.debug("negotiation step failed", { participantId: this.peerId, reason: "restart" });
2141
+ const current = this.#switch;
2142
+ if (current?.attemptTimer !== void 0) this.#attemptFailed(current);
2143
+ }
2144
+ async #beginAttempt(current) {
2145
+ current.attempt += 1;
2146
+ const known = await localCandidateIds(this.#pc);
2147
+ if (this.#switch !== current || this.#closed) return;
2148
+ current.known = known ?? /* @__PURE__ */ new Set();
2149
+ current.attemptTimer = this.#env.setTimeout(() => this.#attemptFailed(current), SWITCH_ATTEMPT_TIMEOUT_MS);
2150
+ if (this.role === "offerer") {
2151
+ this.restart("refresh");
2152
+ } else if (!this.#callbacks.requestRestart()) {
2153
+ this.#endSwitch();
2154
+ return;
2155
+ }
2156
+ this.#schedulePoll(current);
2157
+ }
2158
+ #schedulePoll(current) {
2159
+ current.pollTimer = this.#env.setTimeout(() => {
2160
+ current.pollTimer = void 0;
2161
+ void this.#checkSwitch(current);
2162
+ }, SWITCH_POLL_MS);
2163
+ }
2164
+ async #checkSwitch(current) {
2165
+ if (this.#switch !== current || current.attemptTimer === void 0) return;
2166
+ const pair = coarseState(this.#pc) === "connected" ? await selectedPair(this.#pc) : null;
2167
+ if (this.#switch !== current || current.attemptTimer === void 0) return;
2168
+ if (!pair || current.known.has(pair.localId)) {
2169
+ this.#schedulePoll(current);
2170
+ return;
2171
+ }
2172
+ this.#endSwitch();
2173
+ const report = pathReport(pair);
2174
+ this.#log.debug("media path switch completed", { participantId: this.peerId, attempt: current.attempt, ...report });
2175
+ this.#sendPath(report);
2176
+ this.#callbacks.switchResult("completed");
2177
+ }
2178
+ #attemptFailed(current) {
2179
+ if (this.#switch !== current || this.#closed) return;
2180
+ this.#clearSwitchTimers(current);
2181
+ this.#restarting = false;
2182
+ this.#log.debug("media path switch attempt failed", { participantId: this.peerId, attempt: current.attempt });
2183
+ this.#callbacks.switchResult("failed");
2184
+ if (this.#switch !== current || this.#closed) return;
2185
+ const delay = SWITCH_RETRY_DELAYS_MS[Math.min(current.attempt, SWITCH_RETRY_DELAYS_MS.length) - 1] ?? 0;
2186
+ if (this.#env.now() + delay >= current.deadline - SWITCH_DEADLINE_MARGIN_MS) {
2187
+ this.#endSwitch();
2188
+ this.#log.debug("media path switch stopped", { participantId: this.peerId, attempt: current.attempt });
2189
+ return;
2190
+ }
2191
+ current.retryTimer = this.#env.setTimeout(() => {
2192
+ current.retryTimer = void 0;
2193
+ if (this.#switch === current && !this.#closed) void this.#beginAttempt(current);
2194
+ }, delay);
2195
+ }
2196
+ #endSwitch() {
2197
+ const current = this.#switch;
2198
+ if (!current) return;
2199
+ this.#switch = null;
2200
+ this.#clearSwitchTimers(current);
2201
+ }
2202
+ #clearSwitchTimers(current) {
2203
+ for (const timer of [current.attemptTimer, current.pollTimer, current.retryTimer]) {
2204
+ if (timer !== void 0) this.#env.clearTimeout(timer);
2205
+ }
2206
+ current.attemptTimer = void 0;
2207
+ current.pollTimer = void 0;
2208
+ current.retryTimer = void 0;
2209
+ }
2210
+ #clearInterruptionHold() {
2211
+ if (this.#interruptionHold !== void 0) this.#env.clearTimeout(this.#interruptionHold);
2212
+ this.#interruptionHold = void 0;
2213
+ }
2214
+ #bindStreams() {
2215
+ const Stream = this.#env.MediaStream;
2216
+ if (!Stream) return;
2217
+ if (this.#audio && !this.#streams.microphone) this.#streams.microphone = new Stream([this.#audio.receiver.track]);
2218
+ if (this.#video && !this.#streams.camera) this.#streams.camera = new Stream([this.#video.receiver.track]);
2219
+ if (this.#screen && !this.#streams.screen) this.#streams.screen = new Stream([this.#screen.receiver.track]);
2220
+ }
2221
+ async #attachAll() {
2222
+ const pairs = [
2223
+ [this.#audio, this.#local.microphone],
2224
+ [this.#video, this.#local.camera],
2225
+ [this.#screen, this.#local.screen]
2226
+ ];
2227
+ for (const [transceiver, track] of pairs) {
2228
+ if (transceiver && track && transceiver.sender.track !== track) await transceiver.sender.replaceTrack(track);
2229
+ }
2230
+ }
2231
+ async #flushCandidates() {
2232
+ for (const candidate of this.#queued.splice(0)) await this.#applyCandidate(candidate);
2233
+ }
2234
+ async #applyCandidate(candidate) {
2235
+ if (this.#closed) return;
2236
+ try {
2237
+ await this.#pc.addIceCandidate(candidate);
2238
+ } catch {
2239
+ this.#log.debug("late candidate dropped", { participantId: this.peerId, reason: "rejected" });
2240
+ }
2241
+ }
2242
+ #onLocalCandidate(candidate) {
2243
+ if (this.#closed || !candidate || candidate.candidate === "") return;
2244
+ const payload = {
2245
+ candidate: candidate.candidate,
2246
+ sdpMid: candidate.sdpMid ?? null,
2247
+ sdpMLineIndex: candidate.sdpMLineIndex ?? null
2248
+ };
2249
+ if (typeof candidate.usernameFragment === "string") payload.usernameFragment = candidate.usernameFragment;
2250
+ this.#callbacks.sendCandidate({ targetParticipantId: this.peerId, candidate: payload });
2251
+ }
2252
+ #onStateChange() {
2253
+ if (this.#closed || this.#status === "failed") return;
2254
+ const state = coarseState(this.#pc);
2255
+ this.#log.debug("media connection state", { participantId: this.peerId, state });
2256
+ switch (state) {
2257
+ case "connected":
2258
+ this.#everConnected = true;
2259
+ this.#clearTimer();
2260
+ this.#clearInterruptionHold();
2261
+ if (this.#setStatus("connected")) void this.#reportPath();
2262
+ return;
2263
+ case "disconnected":
2264
+ if (!this.#everConnected) return;
2265
+ if (this.#switch && this.#status === "connected") {
2266
+ this.#interruptionHold ??= this.#env.setTimeout(() => {
2267
+ this.#interruptionHold = void 0;
2268
+ if (!this.#closed && coarseState(this.#pc) !== "connected") this.#setStatus("interrupted");
2269
+ }, SWITCH_INTERRUPTION_HOLD_MS);
2270
+ return;
2271
+ }
2272
+ this.#setStatus("interrupted");
2273
+ return;
2274
+ case "failed":
2275
+ this.#fail("connection_failed");
2276
+ return;
2277
+ default:
2278
+ return;
2279
+ }
2280
+ }
2281
+ /** True when the status changed. */
2282
+ #setStatus(status, reason) {
2283
+ if (this.#status === status) return false;
2284
+ this.#status = status;
2285
+ this.#callbacks.statusChanged(status, reason);
2286
+ return true;
2287
+ }
2288
+ async #reportPath() {
2289
+ const pair = await selectedPair(this.#pc);
2290
+ if (this.#closed || this.#status !== "connected") return;
2291
+ this.#sendPath(pair ? pathReport(pair) : null);
2292
+ }
2293
+ /** Reports the path and reads it again on the recheck schedule, starting over from the first delay. */
2294
+ #sendPath(report) {
2295
+ this.#lastReport = report;
2296
+ this.#callbacks.connected(report);
2297
+ this.#scheduleRecheck(0);
2298
+ }
2299
+ #scheduleRecheck(step) {
2300
+ this.#clearRecheck();
2301
+ const at = PATH_RECHECK_DELAYS_MS[step];
2302
+ const delay = at === void 0 ? PATH_RECHECK_INTERVAL_MS : at - (PATH_RECHECK_DELAYS_MS[step - 1] ?? 0);
2303
+ this.#recheckTimer = this.#env.setTimeout(() => {
2304
+ this.#recheckTimer = void 0;
2305
+ void this.#recheckPath(step);
2306
+ }, delay);
2307
+ }
2308
+ /**
2309
+ * The browser can settle on the path after the first report, for example when the other side's relay address was
2310
+ * first seen as peer-reflexive. A changed path is reported again; an unchanged one sends nothing.
2311
+ */
2312
+ async #recheckPath(step) {
2313
+ if (this.#closed) return;
2314
+ const idle = () => this.#status !== "connected" || this.#switch !== null;
2315
+ const pair = idle() ? null : await selectedPair(this.#pc);
2316
+ if (this.#closed || this.#recheckTimer !== void 0) return;
2317
+ const report = pair && !idle() ? pathReport(pair) : null;
2318
+ if (report && !(this.#lastReport && samePath(report, this.#lastReport))) {
2319
+ this.#log.debug("media path updated", { participantId: this.peerId, ...report });
2320
+ this.#sendPath(report);
2321
+ return;
2322
+ }
2323
+ this.#scheduleRecheck(step + 1);
2324
+ }
2325
+ #clearRecheck() {
2326
+ if (this.#recheckTimer !== void 0) this.#env.clearTimeout(this.#recheckTimer);
2327
+ this.#recheckTimer = void 0;
2328
+ }
2329
+ #fail(reason) {
2330
+ if (this.#closed || this.#status === "failed") return;
2331
+ this.#clearTimer();
2332
+ this.#setStatus("failed", reason);
2333
+ this.close();
2334
+ }
2335
+ #clearTimer() {
2336
+ if (this.#timer !== void 0) this.#env.clearTimeout(this.#timer);
2337
+ this.#timer = void 0;
2338
+ }
2339
+ };
2340
+
2341
+ // src/internal/remoteMedia.ts
2342
+ var KINDS2 = ["camera", "microphone", "screen"];
2343
+ function computeRemoteMedia(inputs) {
2344
+ if (!inputs.active || !inputs.usable) return NO_REMOTE_MEDIA;
2345
+ const camera = inputs.announced.camera ? inputs.streams.camera : null;
2346
+ const microphone = inputs.announced.microphone ? inputs.streams.microphone : null;
2347
+ const screen = inputs.announced.screen ? inputs.streams.screen : null;
2348
+ if (camera === null && microphone === null && screen === null) return NO_REMOTE_MEDIA;
2349
+ return Object.freeze({ camera, microphone, screen });
2350
+ }
2351
+ var RemoteMediaTracker = class {
2352
+ #current = /* @__PURE__ */ new Map();
2353
+ get(participantId) {
2354
+ return this.#current.get(participantId) ?? NO_REMOTE_MEDIA;
2355
+ }
2356
+ update(participantId, next, emit) {
2357
+ const previous = this.get(participantId);
2358
+ if (next === NO_REMOTE_MEDIA) this.#current.delete(participantId);
2359
+ else this.#current.set(participantId, next);
2360
+ for (const kind of KINDS2) {
2361
+ if (previous[kind] !== next[kind]) emit({ participantId, kind, stream: next[kind] });
2362
+ }
2363
+ }
2364
+ /** Forgets everything without reporting. */
2365
+ clear() {
2366
+ this.#current.clear();
2367
+ }
2368
+ };
2369
+
2370
+ // src/internal/peerMediaDelivery.ts
2371
+ var EARLY_FRAME_HOLD_MS = 5e3;
2372
+ var MAX_EARLY_CANDIDATES = 64;
2373
+ var NOTHING_ANNOUNCED = Object.freeze({ camera: false, microphone: false, screen: false });
2374
+ var PeerMediaDelivery = class {
2375
+ #env;
2376
+ #log;
2377
+ #timeoutMs;
2378
+ #context;
2379
+ /** The latest server list; links created later use it. */
2380
+ #iceServers = [];
2381
+ /** Whether the platform relays restart requests and results (it announces credential expiry). */
2382
+ #serverSupportsRestart = false;
2383
+ #inert = false;
2384
+ #closed = false;
2385
+ #present = /* @__PURE__ */ new Map();
2386
+ #activeId = null;
2387
+ #link = null;
2388
+ #status = /* @__PURE__ */ new Map();
2389
+ #remote = new RemoteMediaTracker();
2390
+ #limitReported = /* @__PURE__ */ new Set();
2391
+ #early = /* @__PURE__ */ new Map();
2392
+ #heldOffer;
2393
+ #local = { camera: null, microphone: null, screen: null };
2394
+ constructor(options) {
2395
+ this.#env = options.env;
2396
+ this.#log = options.log;
2397
+ this.#timeoutMs = options.connectionTimeoutMs ?? CONNECTION_TIMEOUT_MS;
2398
+ }
2399
+ attach(context) {
2400
+ if (this.#closed || this.#context) return;
2401
+ this.#context = context;
2402
+ this.#iceServers = context.iceServers;
2403
+ this.#serverSupportsRestart = context.iceServersExpireAt !== null;
2404
+ if (!this.#env.RTCPeerConnection || !this.#env.MediaStream) {
2405
+ this.#inert = true;
2406
+ this.#log.warn("media connections unsupported");
2407
+ this.#emit("error", { error: new MeetingError("unsupported_environment") });
2408
+ }
2409
+ }
2410
+ peersPresent(entries) {
2411
+ if (!this.#active()) return;
2412
+ for (const { id, media } of entries) {
2413
+ this.#present.set(id, { ...media });
2414
+ this.#limitReported.delete(id);
2415
+ }
2416
+ this.#evaluate();
2417
+ const held = this.#heldOffer;
2418
+ if (held && entries.some((entry) => entry.id === held.from)) {
2419
+ this.#dropHeldOffer();
2420
+ if (held.from === this.#activeId) this.#handleOffer(held.from, held.sdp);
2421
+ }
2422
+ }
2423
+ peerPresent(participantId, media) {
2424
+ this.peersPresent([{ id: participantId, media }]);
2425
+ }
2426
+ peerMediaChanged(participantId, kind, enabled) {
2427
+ const announced = this.#present.get(participantId);
2428
+ if (!this.#active() || !announced) return;
2429
+ this.#present.set(participantId, { ...announced, [kind]: enabled });
2430
+ this.#refreshRemote(participantId);
2431
+ }
2432
+ peerLeft(participantId) {
2433
+ if (!this.#active()) return;
2434
+ this.#present.delete(participantId);
2435
+ this.#limitReported.delete(participantId);
2436
+ this.#dropEarly(participantId);
2437
+ if (this.#heldOffer?.from === participantId) this.#dropHeldOffer();
2438
+ if (participantId === this.#activeId) {
2439
+ this.#closeLink();
2440
+ this.#activeId = null;
2441
+ }
2442
+ this.#setStatus(participantId, null);
2443
+ this.#evaluate();
2444
+ }
2445
+ peerReconnected(participantId) {
2446
+ if (!this.#active() || participantId !== this.#activeId) return;
2447
+ this.#dropEarly(participantId);
2448
+ this.#closeLink();
2449
+ this.#setStatus(participantId, null);
2450
+ this.#startPairing();
2451
+ }
2452
+ onPeerFrame(message) {
2453
+ if (!this.#active()) return;
2454
+ const from = message.participantId;
2455
+ if (!this.#present.has(from)) {
2456
+ this.#holdEarly(message);
2457
+ return;
2458
+ }
2459
+ if (from !== this.#activeId) {
2460
+ this.#log.debug("frame ignored", { messageType: message.type, reason: "not_paired" });
2461
+ return;
2462
+ }
2463
+ switch (message.type) {
2464
+ case "webrtc.offer":
2465
+ this.#handleOffer(from, message.payload.sdp);
2466
+ return;
2467
+ case "webrtc.answer":
2468
+ if (this.#link) this.#link.acceptAnswer(message.payload.sdp);
2469
+ else this.#log.debug("frame ignored", { messageType: message.type, reason: "no_connection" });
2470
+ return;
2471
+ case "webrtc.ice_candidate":
2472
+ if (this.#link) this.#link.addRemoteCandidate(message.payload.candidate);
2473
+ else this.#addEarlyCandidate(from, message.payload.candidate);
2474
+ return;
2475
+ case "webrtc.ice_restart":
2476
+ if (this.#link?.role === "offerer") this.#link.restart("peer_request");
2477
+ else this.#log.debug("frame ignored", { messageType: message.type, reason: this.#link ? "answerer" : "no_connection" });
2478
+ return;
2479
+ default:
2480
+ return;
2481
+ }
2482
+ }
2483
+ iceServersUpdated(iceServers2, expiresAt) {
2484
+ if (!this.#active()) return;
2485
+ this.#iceServers = iceServers2;
2486
+ const link = this.#link;
2487
+ if (!link) return;
2488
+ link.updateIceServers(iceServers2);
2489
+ link.planSwitch(expiresAt);
2490
+ }
2491
+ localMediaChanged(kind, stream) {
2492
+ if (this.#closed) return;
2493
+ if (kind === "screen") {
2494
+ const track2 = stream?.getVideoTracks()[0] ?? null;
2495
+ this.#local.screen = track2;
2496
+ this.#link?.attachLocal("screen", track2);
2497
+ return;
2498
+ }
2499
+ const track = (kind === "camera" ? stream?.getVideoTracks()[0] : stream?.getAudioTracks()[0]) ?? null;
2500
+ if (kind === "microphone" && track === null) return;
2501
+ this.#local[kind] = track;
2502
+ this.#link?.attachLocal(kind, track);
2503
+ }
2504
+ supportsScreenShare() {
2505
+ return this.#active();
2506
+ }
2507
+ remoteMedia(participantId) {
2508
+ return this.#remote.get(participantId);
2509
+ }
2510
+ connectionStatus(participantId) {
2511
+ return this.#status.get(participantId) ?? null;
2512
+ }
2513
+ close() {
2514
+ if (this.#closed) return;
2515
+ this.#closed = true;
2516
+ const link = this.#link;
2517
+ this.#link = null;
2518
+ link?.close();
2519
+ for (const participantId of [...this.#early.keys()]) this.#dropEarly(participantId);
2520
+ this.#dropHeldOffer();
2521
+ this.#present.clear();
2522
+ this.#status.clear();
2523
+ this.#remote.clear();
2524
+ this.#activeId = null;
2525
+ }
2526
+ #active() {
2527
+ return this.#context !== void 0 && !this.#inert && !this.#closed;
2528
+ }
2529
+ /** The single-peer rule, applied after every presence change. */
2530
+ #evaluate() {
2531
+ const others = this.#present.size;
2532
+ if (this.#activeId !== null && others >= 2 && !this.#keepsPair()) {
2533
+ const abandoned = this.#activeId;
2534
+ this.#closeLink();
2535
+ this.#activeId = null;
2536
+ this.#setStatus(abandoned, null);
2537
+ this.#log.debug("media connection abandoned", { participantId: abandoned });
2538
+ }
2539
+ if (this.#activeId === null && others === 1) {
2540
+ const [only] = this.#present.keys();
2541
+ if (only !== void 0) {
2542
+ this.#activeId = only;
2543
+ this.#startPairing();
2544
+ }
2545
+ }
2546
+ if (others >= 2) {
2547
+ for (const participantId of this.#present.keys()) {
2548
+ if (participantId === this.#activeId && this.#keepsPair()) continue;
2549
+ if (this.#limitReported.has(participantId)) continue;
2550
+ this.#limitReported.add(participantId);
2551
+ this.#log.info("media not carried", { participantId, code: "media_participant_limit" });
2552
+ this.#emit("error", { error: new MeetingError("media_participant_limit") });
2553
+ }
2554
+ }
2555
+ }
2556
+ /** A pair is kept once its link has connected, until the peer leaves, reconnects, or the link fails. */
2557
+ #keepsPair() {
2558
+ return this.#link !== null && this.#link.everConnected && this.#link.status !== "failed";
2559
+ }
2560
+ #startPairing() {
2561
+ const peerId = this.#activeId;
2562
+ const self = this.#context?.participantId;
2563
+ if (peerId === null || self === void 0) return;
2564
+ if (roleFor(self, peerId) !== "offerer") return;
2565
+ this.#createLink(peerId)?.start();
2566
+ }
2567
+ #handleOffer(from, sdp2) {
2568
+ const self = this.#context?.participantId;
2569
+ if (self === void 0) return;
2570
+ if (roleFor(self, from) === "offerer") {
2571
+ this.#log.debug("frame ignored", { messageType: "webrtc.offer", reason: "offerer" });
2572
+ return;
2573
+ }
2574
+ const link = this.#link ?? this.#createLink(from);
2575
+ link?.acceptOffer(sdp2);
2576
+ }
2577
+ #createLink(peerId) {
2578
+ const context = this.#context;
2579
+ if (!context) return null;
2580
+ let link;
2581
+ try {
2582
+ link = new PeerLink({
2583
+ env: this.#env,
2584
+ log: this.#log,
2585
+ selfId: context.participantId,
2586
+ peerId,
2587
+ iceServers: this.#iceServers,
2588
+ timeoutMs: this.#timeoutMs,
2589
+ local: this.#local,
2590
+ callbacks: {
2591
+ sendOffer: (payload) => {
2592
+ if (this.#link === link) context.send("webrtc.offer", payload);
2593
+ },
2594
+ sendAnswer: (payload) => {
2595
+ if (this.#link === link) context.send("webrtc.answer", payload);
2596
+ },
2597
+ sendCandidate: (payload) => {
2598
+ if (this.#link === link) context.send("webrtc.ice_candidate", payload);
2599
+ },
2600
+ statusChanged: (status, reason) => {
2601
+ if (this.#link === link) this.#onLinkStatus(link, status, reason);
2602
+ },
2603
+ connected: (report) => {
2604
+ if (this.#link === link) context.send("webrtc.connected", { peerParticipantId: peerId, ...report });
2605
+ },
2606
+ requestRestart: () => this.#link === link && this.#serverSupportsRestart && context.send("webrtc.ice_restart", { targetParticipantId: peerId }),
2607
+ switchResult: (outcome) => {
2608
+ if (this.#link === link && this.#serverSupportsRestart) {
2609
+ context.send("webrtc.ice_restart_result", { peerParticipantId: peerId, outcome });
2610
+ }
2611
+ }
2612
+ }
2613
+ });
2614
+ } catch {
2615
+ this.#log.debug("negotiation step failed", { participantId: peerId, reason: "create" });
2616
+ this.#reportFailure(peerId, "negotiation_failed");
2617
+ return null;
2618
+ }
2619
+ this.#link = link;
2620
+ this.#setStatus(peerId, "connecting");
2621
+ const early = this.#early.get(peerId);
2622
+ if (early) {
2623
+ this.#dropEarly(peerId);
2624
+ for (const candidate of early.candidates) link.addRemoteCandidate(candidate);
2625
+ }
2626
+ return link;
2627
+ }
2628
+ #onLinkStatus(link, status, reason) {
2629
+ const peerParticipantId = link.peerId;
2630
+ switch (status) {
2631
+ case "connected":
2632
+ this.#setStatus(peerParticipantId, "connected");
2633
+ this.#refreshRemote(peerParticipantId);
2634
+ return;
2635
+ case "interrupted":
2636
+ this.#context?.send("webrtc.disconnected", { peerParticipantId });
2637
+ this.#setStatus(peerParticipantId, "interrupted");
2638
+ return;
2639
+ case "failed":
2640
+ this.#link = null;
2641
+ link.close();
2642
+ this.#reportFailure(peerParticipantId, reason ?? "connection_failed");
2643
+ return;
2644
+ default:
2645
+ return;
2646
+ }
2647
+ }
2648
+ /** Reports a failed link. The pair stays as it is; nothing is retried until the peer reconnects or rejoins. */
2649
+ #reportFailure(peerParticipantId, reason) {
2650
+ this.#log.warn("media connection failed", { participantId: peerParticipantId, reason });
2651
+ this.#context?.send("webrtc.failed", { peerParticipantId, reason });
2652
+ this.#setStatus(peerParticipantId, "failed");
2653
+ this.#refreshRemote(peerParticipantId);
2654
+ this.#emit("error", { error: new MeetingError("media_connection_failed") });
2655
+ }
2656
+ /** Closes the current link without reporting a failure, and reports its remote media as gone. */
2657
+ #closeLink() {
2658
+ const link = this.#link;
2659
+ if (!link) return;
2660
+ this.#link = null;
2661
+ link.close();
2662
+ this.#refreshRemote(link.peerId);
2663
+ }
2664
+ #refreshRemote(participantId) {
2665
+ const link = this.#link;
2666
+ const active = participantId === this.#activeId && link !== null && link.peerId === participantId;
2667
+ const next = computeRemoteMedia({
2668
+ active,
2669
+ usable: active && link.usable,
2670
+ announced: this.#present.get(participantId) ?? NOTHING_ANNOUNCED,
2671
+ streams: {
2672
+ camera: link?.remoteStream("camera") ?? null,
2673
+ microphone: link?.remoteStream("microphone") ?? null,
2674
+ screen: link?.remoteStream("screen") ?? null
2675
+ }
2676
+ });
2677
+ this.#remote.update(participantId, next, (event) => this.#emit("remote_media_changed", event));
2678
+ }
2679
+ #setStatus(participantId, status) {
2680
+ const previous = this.#status.get(participantId) ?? null;
2681
+ if (previous === status) return;
2682
+ if (status === null) this.#status.delete(participantId);
2683
+ else this.#status.set(participantId, status);
2684
+ this.#emit("media_connection_changed", { participantId, status });
2685
+ }
2686
+ #holdEarly(message) {
2687
+ switch (message.type) {
2688
+ case "webrtc.offer": {
2689
+ this.#dropHeldOffer();
2690
+ const from = message.participantId;
2691
+ this.#heldOffer = {
2692
+ from,
2693
+ sdp: message.payload.sdp,
2694
+ timer: this.#env.setTimeout(() => {
2695
+ if (this.#heldOffer?.from === from) this.#heldOffer = void 0;
2696
+ }, EARLY_FRAME_HOLD_MS)
2697
+ };
2698
+ return;
2699
+ }
2700
+ case "webrtc.ice_candidate":
2701
+ this.#addEarlyCandidate(message.participantId, message.payload.candidate);
2702
+ return;
2703
+ default:
2704
+ this.#log.debug("frame ignored", { messageType: message.type, reason: "unknown_participant" });
2705
+ }
2706
+ }
2707
+ #addEarlyCandidate(participantId, candidate) {
2708
+ let early = this.#early.get(participantId);
2709
+ if (!early) {
2710
+ early = {
2711
+ candidates: [],
2712
+ timer: this.#env.setTimeout(() => this.#early.delete(participantId), EARLY_FRAME_HOLD_MS)
2713
+ };
2714
+ this.#early.set(participantId, early);
2715
+ }
2716
+ if (early.candidates.length >= MAX_EARLY_CANDIDATES) {
2717
+ this.#log.debug("late candidate dropped", { participantId, reason: "queue_full" });
2718
+ return;
2719
+ }
2720
+ early.candidates.push(candidate);
2721
+ }
2722
+ #dropEarly(participantId) {
2723
+ const early = this.#early.get(participantId);
2724
+ if (!early) return;
2725
+ this.#env.clearTimeout(early.timer);
2726
+ this.#early.delete(participantId);
2727
+ }
2728
+ #dropHeldOffer() {
2729
+ if (this.#heldOffer) this.#env.clearTimeout(this.#heldOffer.timer);
2730
+ this.#heldOffer = void 0;
2731
+ }
2732
+ #emit(type, event) {
2733
+ if (!this.#closed) this.#context?.emit(type, event);
2734
+ }
2735
+ };
2736
+
2737
+ // src/internal/token.ts
2738
+ var BASE64URL = /^[A-Za-z0-9_-]+$/;
2739
+ function decodeSegment(segment) {
2740
+ if (!BASE64URL.test(segment)) throw new MeetingError("join_token_invalid");
2741
+ const padded = segment.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(segment.length / 4) * 4, "=");
2742
+ const binary = atob(padded);
2743
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
2744
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
2745
+ }
2746
+ function decodeAddressing(token) {
2747
+ const segments = token.split(".");
2748
+ if (segments.length !== 3) throw new MeetingError("join_token_invalid");
2749
+ let claims;
2750
+ try {
2751
+ claims = decodeSegment(segments[1] ?? "");
2752
+ } catch {
2753
+ throw new MeetingError("join_token_invalid");
2754
+ }
2755
+ if (typeof claims !== "object" || claims === null || Array.isArray(claims)) {
2756
+ throw new MeetingError("join_token_invalid");
2757
+ }
2758
+ const { meeting_id: meetingId, participant_id: participantId } = claims;
2759
+ if (!isCanonicalUlid(meetingId) || !isCanonicalUlid(participantId)) {
2760
+ throw new MeetingError("join_token_invalid");
2761
+ }
2762
+ return { meetingId, participantId };
2763
+ }
2764
+
2765
+ // src/createMeetingEngine.ts
2766
+ var DEFAULT_JOIN_TIMEOUT_MS = 15e3;
2767
+ var MIN_JOIN_TIMEOUT_MS = 1e3;
2768
+ var MAX_JOIN_TIMEOUT_MS = 6e4;
2769
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1"]);
2770
+ function normalizeRealtimeUrl(value) {
2771
+ const invalid = new TypeError(
2772
+ "realtimeUrl must be wss://host[:port] (ws:// only for localhost) with no path, query, or fragment."
2773
+ );
2774
+ if (typeof value !== "string" || value.includes("?") || value.includes("#")) throw invalid;
2775
+ let url;
2776
+ try {
2777
+ url = new URL(value);
2778
+ } catch {
2779
+ throw invalid;
2780
+ }
2781
+ const secure = url.protocol === "wss:";
2782
+ const loopback = url.protocol === "ws:" && LOOPBACK_HOSTS.has(url.hostname);
2783
+ if (!secure && !loopback) throw invalid;
2784
+ if (url.username !== "" || url.password !== "" || url.pathname !== "" && url.pathname !== "/") throw invalid;
2785
+ return `${url.protocol}//${url.host}`;
2786
+ }
2787
+ function normalizeJoinTimeout(value) {
2788
+ if (value === void 0) return DEFAULT_JOIN_TIMEOUT_MS;
2789
+ if (typeof value !== "number" || !Number.isInteger(value) || value < MIN_JOIN_TIMEOUT_MS || value > MAX_JOIN_TIMEOUT_MS) {
2790
+ throw new TypeError(`joinTimeoutMs must be an integer from ${MIN_JOIN_TIMEOUT_MS} to ${MAX_JOIN_TIMEOUT_MS}.`);
2791
+ }
2792
+ return value;
2793
+ }
2794
+ function createMeetingEngine(options) {
2795
+ if (typeof options !== "object" || options === null) throw new TypeError("createMeetingEngine requires options.");
2796
+ const realtimeUrl = normalizeRealtimeUrl(options.realtimeUrl);
2797
+ const joinTimeoutMs = normalizeJoinTimeout(options.joinTimeoutMs);
2798
+ const token = options.token;
2799
+ if (typeof token !== "string" || token.length === 0) throw new MeetingError("join_token_invalid");
2800
+ const env = currentEnvironment();
2801
+ if (!env.WebSocket) throw new MeetingError("unsupported_environment");
2802
+ const addressing = decodeAddressing(token);
2803
+ return new MeetingEngineImpl({
2804
+ token,
2805
+ realtimeUrl,
2806
+ joinTimeoutMs,
2807
+ logger: options.logger,
2808
+ addressing,
2809
+ env,
2810
+ createMediaDelivery: (mediaMode, deps) => mediaMode === "p2p" ? new PeerMediaDelivery(deps) : new SignalingOnlyMediaDelivery()
2811
+ });
2812
+ }
2813
+ export {
2814
+ MeetingError,
2815
+ createMeetingEngine
2816
+ };
2817
+ //# sourceMappingURL=index.js.map