@q9labsai/chalk-client 5.2.3 → 5.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/access/grant.d.ts +2 -1
- package/dist/access/manager.d.ts +1 -0
- package/dist/{chunk-IMMT22US.js → chunk-5YPQNMJE.js} +11 -0
- package/dist/{chunk-4ZUN55Z2.js → chunk-BUNSEDXL.js} +840 -185
- package/dist/{chunk-DWKTGBHR.js → chunk-FD25AMLG.js} +322 -206
- package/dist/{chunk-6LNRDKJF.js → chunk-LKNT6XFD.js} +1 -1
- package/dist/{chunk-GA3JDPFI.js → chunk-UMAE5GCV.js} +205 -5
- package/dist/client.d.ts +131 -0
- package/dist/connection/dependencies.d.ts +10 -1
- package/dist/connection/diagnostics.d.ts +4 -0
- package/dist/connection/episode-status.d.ts +7 -0
- package/dist/connection/index.d.ts +2 -0
- package/dist/connection/lifecycle.d.ts +2 -0
- package/dist/connection/report.d.ts +21 -0
- package/dist/effect.js +30 -4
- package/dist/generated/http-api.d.ts +129 -0
- package/dist/generated/schemas.d.ts +219 -0
- package/dist/index.js +5 -5
- package/dist/media/client.d.ts +1 -1
- package/dist/media/failure-diagnostics.d.ts +10 -0
- package/dist/media/index.js +1 -1
- package/dist/media/plane.d.ts +1 -0
- package/dist/media/types.d.ts +5 -1
- package/dist/public-invites/index.js +2 -2
- package/dist/server/access-refresh.d.cts +1 -1
- package/dist/server/access-refresh.d.ts +1 -1
- package/dist/server/index.cjs +15 -5
- package/dist/server/index.js +15 -5
- package/dist/server/types.d.cts +2 -1
- package/dist/server/types.d.ts +2 -1
- package/dist/space-client/media-projection-monitor.d.ts +26 -0
- package/dist/space-client/types.d.ts +4 -0
- package/dist/sync/index.js +1 -1
- package/dist/sync/v1-types.d.ts +3 -0
- package/dist/transport-diagnostics.d.ts +3 -0
- package/package.json +2 -2
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CloudflareSFUClient,
|
|
3
|
+
CloudflareSFUError,
|
|
4
|
+
classifyMediaFailure,
|
|
3
5
|
createCloudflareSFUHTTPTransport,
|
|
4
|
-
describeMediaCaptureError
|
|
5
|
-
|
|
6
|
+
describeMediaCaptureError,
|
|
7
|
+
grantedMediaFromStream,
|
|
8
|
+
markMediaFailure
|
|
9
|
+
} from "./chunk-FD25AMLG.js";
|
|
6
10
|
import {
|
|
7
11
|
AccessGrantError,
|
|
8
12
|
requireParsedAccessGrant
|
|
9
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-UMAE5GCV.js";
|
|
10
14
|
import {
|
|
11
15
|
ChatAttachmentMimeTypeSchema,
|
|
12
16
|
ChatReactionSetFrameSchema,
|
|
@@ -20,22 +24,46 @@ import {
|
|
|
20
24
|
formatSyncConnectDiagnostic,
|
|
21
25
|
scheduleSyncDialDeadline,
|
|
22
26
|
syncConnectFailureCode
|
|
23
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-5YPQNMJE.js";
|
|
24
28
|
|
|
25
29
|
// src/access/manager.ts
|
|
26
|
-
import { Clock, Context, Data, Effect, Layer, SynchronizedRef } from "effect";
|
|
30
|
+
import { Clock, Context, Data, Deferred, Effect, Layer, SynchronizedRef } from "effect";
|
|
27
31
|
var DEFAULT_REFRESH_WINDOW_MS = 6e4;
|
|
28
32
|
var ACCESS_PROVIDER_TIMEOUT_MS = 7e3;
|
|
29
33
|
var ConnectionAccessFailure = class extends Data.TaggedError("ConnectionAccessFailure") {
|
|
30
34
|
};
|
|
35
|
+
var MediaReplacementPending = class extends Data.TaggedError("MediaReplacementPending") {
|
|
36
|
+
};
|
|
31
37
|
var ConnectionAccessService = class extends Context.Service()("@chalk/client/ConnectionAccess") {
|
|
32
38
|
};
|
|
33
39
|
var makeConnectionAccessLayer = (provider, refreshWindowMs = DEFAULT_REFRESH_WINDOW_MS) => Layer.effect(
|
|
34
40
|
ConnectionAccessService,
|
|
35
41
|
Effect.gen(function* () {
|
|
36
42
|
const state = yield* SynchronizedRef.make({ access: null });
|
|
43
|
+
const holds = /* @__PURE__ */ new Map();
|
|
44
|
+
const replacementGate = () => {
|
|
45
|
+
const binding = SynchronizedRef.getUnsafe(state).access?.media.clientPayload.connectionId;
|
|
46
|
+
return binding ? holds.get(binding)?.done : void 0;
|
|
47
|
+
};
|
|
48
|
+
const holdMediaBinding = (binding) => Effect.gen(function* () {
|
|
49
|
+
const hold = holds.get(binding) ?? { count: 0, done: yield* Deferred.make() };
|
|
50
|
+
hold.count++;
|
|
51
|
+
holds.set(binding, hold);
|
|
52
|
+
return Effect.gen(function* () {
|
|
53
|
+
if (--hold.count !== 0) return;
|
|
54
|
+
holds.delete(binding);
|
|
55
|
+
yield* Deferred.succeed(hold.done, void 0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
37
58
|
const automaticMediaReplacementListeners = /* @__PURE__ */ new Set();
|
|
38
|
-
const fetch2 = (reason, replaceMediaConnection, force) => SynchronizedRef.modifyEffect(state, (current) => fetchAccessGrant(provider, { current, reason, replaceMediaConnection, force, refreshWindowMs })).pipe(
|
|
59
|
+
const fetch2 = (reason, replaceMediaConnection, force) => SynchronizedRef.modifyEffect(state, (current) => fetchAccessGrant(provider, { current, reason, replaceMediaConnection, force, refreshWindowMs, replacementGate })).pipe(
|
|
60
|
+
Effect.catchTag(
|
|
61
|
+
"MediaReplacementPending",
|
|
62
|
+
({ done }) => Deferred.await(done).pipe(
|
|
63
|
+
Effect.flatMap(() => fetch2(reason, replaceMediaConnection, force)),
|
|
64
|
+
Effect.map((access) => ({ access, automaticMediaReplacement: false }))
|
|
65
|
+
)
|
|
66
|
+
),
|
|
39
67
|
Effect.tap((result) => Effect.sync(() => notifyAutomaticMediaReplacement(automaticMediaReplacementListeners, result))),
|
|
40
68
|
Effect.map((result) => result.access)
|
|
41
69
|
);
|
|
@@ -59,9 +87,14 @@ var makeConnectionAccessLayer = (provider, refreshWindowMs = DEFAULT_REFRESH_WIN
|
|
|
59
87
|
automaticMediaReplacementListeners.add(listener);
|
|
60
88
|
return () => automaticMediaReplacementListeners.delete(listener);
|
|
61
89
|
}),
|
|
62
|
-
requiresRefresh: millisecondsUntilRefresh.pipe(Effect.map((
|
|
90
|
+
requiresRefresh: millisecondsUntilRefresh.pipe(Effect.map((milliseconds2) => milliseconds2 === null || milliseconds2 === 0)),
|
|
63
91
|
millisecondsUntilRefresh,
|
|
64
|
-
|
|
92
|
+
holdMediaBinding,
|
|
93
|
+
clear: Effect.gen(function* () {
|
|
94
|
+
yield* SynchronizedRef.set(state, { access: null });
|
|
95
|
+
for (const hold of holds.values()) yield* Deferred.fail(hold.done, new ConnectionAccessFailure({ code: "access.unavailable", cause: "Connection closed during media rejection classification" }));
|
|
96
|
+
holds.clear();
|
|
97
|
+
})
|
|
65
98
|
};
|
|
66
99
|
})
|
|
67
100
|
);
|
|
@@ -70,17 +103,21 @@ function fetchAccessGrant(provider, input) {
|
|
|
70
103
|
return Effect.gen(function* () {
|
|
71
104
|
const now = yield* Clock.currentTimeMillis;
|
|
72
105
|
const currentAccess = input.current.access;
|
|
73
|
-
if (
|
|
106
|
+
if (canReuseAccess(input, now) && currentAccess) {
|
|
74
107
|
return [{ access: currentAccess, automaticMediaReplacement: false }, input.current];
|
|
75
108
|
}
|
|
76
|
-
const
|
|
77
|
-
const shouldReplaceMediaConnection = input
|
|
109
|
+
const gate = input.replacementGate();
|
|
110
|
+
const shouldReplaceMediaConnection = mediaReplacementRequested(input, now, Boolean(gate));
|
|
111
|
+
if (shouldReplaceMediaConnection && gate) return yield* Effect.fail(new MediaReplacementPending({ done: gate }));
|
|
78
112
|
const next = yield* provider(connectionAccessRequest(input, shouldReplaceMediaConnection)).pipe(
|
|
79
113
|
Effect.timeout(ACCESS_PROVIDER_TIMEOUT_MS),
|
|
80
114
|
Effect.mapError((cause) => cause instanceof ConnectionAccessFailure ? cause : new ConnectionAccessFailure({ code: "access.unavailable", cause }))
|
|
81
115
|
);
|
|
82
116
|
yield* Effect.try({
|
|
83
|
-
try: () =>
|
|
117
|
+
try: () => {
|
|
118
|
+
validateFetchedAccess(currentAccess, next, now, shouldReplaceMediaConnection);
|
|
119
|
+
validateHeldBinding(input, next);
|
|
120
|
+
},
|
|
84
121
|
catch: (cause) => new ConnectionAccessFailure({ code: "access.invalid", cause })
|
|
85
122
|
});
|
|
86
123
|
return [{ access: next, automaticMediaReplacement: currentAccess !== null && !input.replaceMediaConnection && !hasSameMediaConnection(currentAccess, next) }, { access: next }];
|
|
@@ -92,7 +129,8 @@ function connectionAccessRequest(input, replaceMediaConnection) {
|
|
|
92
129
|
replaceMediaConnection,
|
|
93
130
|
...input.current.access ? {
|
|
94
131
|
currentMediaToken: input.current.access.media.token,
|
|
95
|
-
expectedParticipantGeneration: input.current.access.subject.participantGeneration
|
|
132
|
+
expectedParticipantGeneration: input.current.access.subject.participantGeneration,
|
|
133
|
+
...replaceMediaConnection && input.reason !== "access_retry" ? { expectedMediaConnectionId: input.current.access.media.clientPayload.connectionId } : {}
|
|
96
134
|
} : {}
|
|
97
135
|
});
|
|
98
136
|
}
|
|
@@ -138,6 +176,17 @@ function expiresAt(value) {
|
|
|
138
176
|
if (!Number.isFinite(timestamp)) throw new TypeError("Access grant expiration is invalid");
|
|
139
177
|
return timestamp;
|
|
140
178
|
}
|
|
179
|
+
function canReuseAccess(input, now) {
|
|
180
|
+
return !input.force && input.current.access !== null && millisecondsUntil(input.current.access, now, input.refreshWindowMs) > 0;
|
|
181
|
+
}
|
|
182
|
+
function mediaReplacementRequested(input, now, held) {
|
|
183
|
+
const current = input.current.access;
|
|
184
|
+
return input.replaceMediaConnection || current !== null && expiresAt(current.media.expiresAt) <= now && !held;
|
|
185
|
+
}
|
|
186
|
+
function validateHeldBinding(input, next) {
|
|
187
|
+
const previous = input.current.access;
|
|
188
|
+
if (input.replacementGate() && previous && !hasSameMediaConnection(previous, next)) throw new TypeError("Cannot replace a rejected media binding before classification");
|
|
189
|
+
}
|
|
141
190
|
|
|
142
191
|
// src/connection/dependencies.ts
|
|
143
192
|
import { Context as Context2, Layer as Layer2 } from "effect";
|
|
@@ -2156,7 +2205,7 @@ var ChalkWhiteboardV1Client = class {
|
|
|
2156
2205
|
return this.#options.clock ?? {
|
|
2157
2206
|
now: Date.now,
|
|
2158
2207
|
monotonicNow: () => globalThis.performance.now(),
|
|
2159
|
-
setTimeout: (callback,
|
|
2208
|
+
setTimeout: (callback, milliseconds2) => globalThis.setTimeout(callback, milliseconds2),
|
|
2160
2209
|
clearTimeout: (handle) => globalThis.clearTimeout(handle)
|
|
2161
2210
|
};
|
|
2162
2211
|
}
|
|
@@ -2288,6 +2337,162 @@ function createChalkWhiteboardV1Client(options) {
|
|
|
2288
2337
|
});
|
|
2289
2338
|
}
|
|
2290
2339
|
|
|
2340
|
+
// src/connection/episode-status.ts
|
|
2341
|
+
var EpisodeStatusError = class extends Error {
|
|
2342
|
+
constructor(status, retryAfterMs = 0) {
|
|
2343
|
+
super(`Episode status unavailable: HTTP ${status}`);
|
|
2344
|
+
this.status = status;
|
|
2345
|
+
this.retryAfterMs = retryAfterMs;
|
|
2346
|
+
}
|
|
2347
|
+
status;
|
|
2348
|
+
retryAfterMs;
|
|
2349
|
+
};
|
|
2350
|
+
function episodeStatusRetryDelay(attempt, error2) {
|
|
2351
|
+
const retryAfterMs = error2 instanceof EpisodeStatusError ? error2.retryAfterMs : 0;
|
|
2352
|
+
const ceiling = Math.min(1e3 * 2 ** Math.min(attempt, 5), 1e4);
|
|
2353
|
+
return Math.max(retryAfterMs, ceiling * (0.5 + Math.random() * 0.5));
|
|
2354
|
+
}
|
|
2355
|
+
function retryAfterMilliseconds(value, now) {
|
|
2356
|
+
if (!value) return 0;
|
|
2357
|
+
const seconds = Number(value);
|
|
2358
|
+
const delay = Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(value) - now;
|
|
2359
|
+
return Number.isFinite(delay) ? Math.max(0, delay) : 0;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// src/connection/report.ts
|
|
2363
|
+
var MAX_EVENTS = 64;
|
|
2364
|
+
var MAX_BYTES = 32768;
|
|
2365
|
+
var MAX_MS = 2147483647;
|
|
2366
|
+
var COOLDOWN_MS = 6e4;
|
|
2367
|
+
var participantCooldowns = /* @__PURE__ */ new Map();
|
|
2368
|
+
var milliseconds = (value) => Math.max(0, Math.min(MAX_MS, Math.round(Number.isFinite(value) ? value : 0)));
|
|
2369
|
+
var optionalMilliseconds = (value) => value == null ? void 0 : milliseconds(value);
|
|
2370
|
+
function mediaReportFields(media) {
|
|
2371
|
+
if (!media) return {};
|
|
2372
|
+
return {
|
|
2373
|
+
media_error_kind: media.media_error_kind,
|
|
2374
|
+
media_error_step: media.media_error_step,
|
|
2375
|
+
granted_audio: media.granted_audio,
|
|
2376
|
+
granted_video: media.granted_video,
|
|
2377
|
+
http_status: media.http_status
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
function reserveParticipantReport(access, now) {
|
|
2381
|
+
const key = `${access.subject.tenantId}:${access.subject.participantId}`;
|
|
2382
|
+
for (const [participant, until] of participantCooldowns) if (until <= now) participantCooldowns.delete(participant);
|
|
2383
|
+
if (participantCooldowns.has(key)) return false;
|
|
2384
|
+
if (participantCooldowns.size >= 128) participantCooldowns.delete(participantCooldowns.keys().next().value ?? "");
|
|
2385
|
+
participantCooldowns.set(key, now + COOLDOWN_MS);
|
|
2386
|
+
return true;
|
|
2387
|
+
}
|
|
2388
|
+
var ConnectionReporter = class {
|
|
2389
|
+
#events = [];
|
|
2390
|
+
#startedAt;
|
|
2391
|
+
#options;
|
|
2392
|
+
#pending = null;
|
|
2393
|
+
#unsubscribe;
|
|
2394
|
+
#controller = null;
|
|
2395
|
+
#retryOnReconnect = false;
|
|
2396
|
+
#disposed = false;
|
|
2397
|
+
constructor(options) {
|
|
2398
|
+
this.#options = options;
|
|
2399
|
+
this.#startedAt = options.now();
|
|
2400
|
+
}
|
|
2401
|
+
recordDiagnostic(diagnostic) {
|
|
2402
|
+
this.#record({
|
|
2403
|
+
event: diagnostic.event,
|
|
2404
|
+
elapsed_ms: this.#elapsed(),
|
|
2405
|
+
state: diagnostic.state,
|
|
2406
|
+
step: diagnostic.step,
|
|
2407
|
+
outcome: diagnostic.outcome,
|
|
2408
|
+
code: diagnostic.code,
|
|
2409
|
+
attempt: optionalMilliseconds(diagnostic.attempt),
|
|
2410
|
+
duration_ms: optionalMilliseconds(diagnostic.sync?.durationMs ?? diagnostic.durationMs),
|
|
2411
|
+
close_code: diagnostic.sync?.closeCode ?? void 0,
|
|
2412
|
+
heartbeat_gap_ms: optionalMilliseconds(diagnostic.recovery?.lastHeartbeatReplyAgoMs),
|
|
2413
|
+
...mediaReportFields(diagnostic.media)
|
|
2414
|
+
});
|
|
2415
|
+
if (diagnostic.event === "recovery_exhausted") this.#report("recovery_exhausted");
|
|
2416
|
+
else if (diagnostic.event === "connection_failed") this.#report(diagnostic.state === "joining" ? "join_failed" : "connection_lost");
|
|
2417
|
+
}
|
|
2418
|
+
recordTransport(event) {
|
|
2419
|
+
this.#record({
|
|
2420
|
+
event: event.event,
|
|
2421
|
+
elapsed_ms: this.#elapsed(),
|
|
2422
|
+
...event.phase ? { phase: event.phase } : {},
|
|
2423
|
+
...event.attempt == null ? {} : { attempt: milliseconds(event.attempt) },
|
|
2424
|
+
...event.duration_ms == null ? {} : { duration_ms: milliseconds(event.duration_ms) },
|
|
2425
|
+
...event.heartbeat_gap_ms == null ? {} : { heartbeat_gap_ms: milliseconds(event.heartbeat_gap_ms) },
|
|
2426
|
+
...event.close_code == null ? {} : { close_code: event.close_code },
|
|
2427
|
+
...event.http_status == null ? {} : { http_status: event.http_status },
|
|
2428
|
+
source: event.source,
|
|
2429
|
+
publication_state: event.publication_state,
|
|
2430
|
+
projection_state: event.projection_state,
|
|
2431
|
+
outcome: event.outcome
|
|
2432
|
+
});
|
|
2433
|
+
if (event.event === "media_projection_mismatch") this.#report("media_projection_mismatch");
|
|
2434
|
+
}
|
|
2435
|
+
dispose() {
|
|
2436
|
+
this.#disposed = true;
|
|
2437
|
+
this.#controller?.abort();
|
|
2438
|
+
this.#stopListening();
|
|
2439
|
+
this.#pending = null;
|
|
2440
|
+
this.#events.length = 0;
|
|
2441
|
+
}
|
|
2442
|
+
get traceparent() {
|
|
2443
|
+
return this.#options.traceparent;
|
|
2444
|
+
}
|
|
2445
|
+
#elapsed() {
|
|
2446
|
+
return milliseconds(this.#options.now() - this.#startedAt);
|
|
2447
|
+
}
|
|
2448
|
+
#record(event) {
|
|
2449
|
+
if (this.#disposed) return;
|
|
2450
|
+
this.#events.push(event);
|
|
2451
|
+
if (this.#events.length > MAX_EVENTS) this.#events.shift();
|
|
2452
|
+
}
|
|
2453
|
+
#report(trigger) {
|
|
2454
|
+
if (this.#disposed || this.#pending) return;
|
|
2455
|
+
const access = this.#options.access();
|
|
2456
|
+
if (!access) return;
|
|
2457
|
+
if (!reserveParticipantReport(access, this.#options.now())) return;
|
|
2458
|
+
const events = this.#events.slice();
|
|
2459
|
+
const report = { version: 1, trigger, events };
|
|
2460
|
+
while (events.length && new TextEncoder().encode(JSON.stringify(report)).byteLength > MAX_BYTES) events.shift();
|
|
2461
|
+
if (!events.length) return;
|
|
2462
|
+
this.#pending = { access, report, attempts: 0 };
|
|
2463
|
+
this.#unsubscribe = this.#options.subscribeOnline(() => {
|
|
2464
|
+
this.#retryOnReconnect = true;
|
|
2465
|
+
void this.#flush();
|
|
2466
|
+
});
|
|
2467
|
+
void this.#flush();
|
|
2468
|
+
}
|
|
2469
|
+
async #flush() {
|
|
2470
|
+
const pending = this.#pending;
|
|
2471
|
+
if (!pending || this.#controller || this.#disposed || !this.#options.online()) return;
|
|
2472
|
+
this.#retryOnReconnect = false;
|
|
2473
|
+
const controller = new AbortController();
|
|
2474
|
+
this.#controller = controller;
|
|
2475
|
+
pending.attempts += 1;
|
|
2476
|
+
const timer = globalThis.setTimeout(() => controller.abort(), 5e3);
|
|
2477
|
+
try {
|
|
2478
|
+
await this.#options.send(pending.access, pending.report, controller.signal, this.traceparent);
|
|
2479
|
+
this.#pending = null;
|
|
2480
|
+
} catch {
|
|
2481
|
+
if (pending.attempts >= 2) this.#pending = null;
|
|
2482
|
+
} finally {
|
|
2483
|
+
globalThis.clearTimeout(timer);
|
|
2484
|
+
this.#controller = null;
|
|
2485
|
+
if (!this.#pending) this.#stopListening();
|
|
2486
|
+
if (this.#retryOnReconnect) void this.#flush();
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
#stopListening() {
|
|
2490
|
+
this.#unsubscribe?.();
|
|
2491
|
+
this.#unsubscribe = void 0;
|
|
2492
|
+
this.#retryOnReconnect = false;
|
|
2493
|
+
}
|
|
2494
|
+
};
|
|
2495
|
+
|
|
2291
2496
|
// src/connection/types.ts
|
|
2292
2497
|
var ConnectionError = class extends Error {
|
|
2293
2498
|
code;
|
|
@@ -2309,7 +2514,12 @@ function createBrowserMediaDevices(mediaDevices = globalThis.navigator?.mediaDev
|
|
|
2309
2514
|
return {
|
|
2310
2515
|
async getUserMedia(constraints) {
|
|
2311
2516
|
if (!mediaDevices?.getUserMedia || globalThis.isSecureContext === false) throw new ConnectionError({ code: "unsupported_environment", action: null, recoverable: false, message: "Browser media capture is unavailable. Use a supported browser over HTTPS." });
|
|
2312
|
-
|
|
2517
|
+
try {
|
|
2518
|
+
return await mediaDevices.getUserMedia(constraints);
|
|
2519
|
+
} catch (error2) {
|
|
2520
|
+
markMediaFailure(error2, "local_capture");
|
|
2521
|
+
throw error2;
|
|
2522
|
+
}
|
|
2313
2523
|
},
|
|
2314
2524
|
async getDisplayMedia(constraints) {
|
|
2315
2525
|
if (!mediaDevices?.getDisplayMedia) throw new DOMException("Browser display capture is unavailable", "NotSupportedError");
|
|
@@ -2359,10 +2569,49 @@ function createDefaultConnectionDependencies(options) {
|
|
|
2359
2569
|
return {
|
|
2360
2570
|
clock: {
|
|
2361
2571
|
now: () => Date.now(),
|
|
2362
|
-
setTimeout: (callback,
|
|
2572
|
+
setTimeout: (callback, milliseconds2) => globalThis.setTimeout(callback, milliseconds2),
|
|
2363
2573
|
clearTimeout: (handle) => globalThis.clearTimeout(handle)
|
|
2364
2574
|
},
|
|
2575
|
+
createConnectionReporter: (access) => {
|
|
2576
|
+
const random = globalThis.crypto;
|
|
2577
|
+
if (!random?.randomUUID) return void 0;
|
|
2578
|
+
return new ConnectionReporter({
|
|
2579
|
+
traceparent: `00-${random.randomUUID().replaceAll("-", "")}-${random.randomUUID().replaceAll("-", "").slice(0, 16)}-01`,
|
|
2580
|
+
now: () => performance.now(),
|
|
2581
|
+
access,
|
|
2582
|
+
online: () => globalThis.navigator?.onLine !== false,
|
|
2583
|
+
subscribeOnline: (listener) => {
|
|
2584
|
+
globalThis.window?.addEventListener("online", listener);
|
|
2585
|
+
return () => globalThis.window?.removeEventListener("online", listener);
|
|
2586
|
+
},
|
|
2587
|
+
send: async (grant, report, signal, traceparent) => {
|
|
2588
|
+
const response = await fetch(`${options.apiBaseURL.replace(/\/$/, "")}/v1/tenants/${encodeURIComponent(grant.subject.tenantId)}/connection-reports`, {
|
|
2589
|
+
method: "POST",
|
|
2590
|
+
headers: { Authorization: `Bearer ${grant.media.token}`, "Content-Type": "application/json", traceparent },
|
|
2591
|
+
body: JSON.stringify(report),
|
|
2592
|
+
signal,
|
|
2593
|
+
keepalive: true,
|
|
2594
|
+
credentials: "omit"
|
|
2595
|
+
});
|
|
2596
|
+
await response.body?.cancel();
|
|
2597
|
+
}
|
|
2598
|
+
});
|
|
2599
|
+
},
|
|
2365
2600
|
mediaDevices: createBrowserMediaDevices(),
|
|
2601
|
+
getEpisodeStatus: async (access, signal) => {
|
|
2602
|
+
const { tenantId, spaceId, episodeId, participantId } = access.subject;
|
|
2603
|
+
const path = [tenantId, spaceId, episodeId, participantId].map(encodeURIComponent);
|
|
2604
|
+
const response = await fetch(`${options.apiBaseURL.replace(/\/$/, "")}/v1/tenants/${path[0]}/spaces/${path[1]}/episodes/${path[2]}/participants/${path[3]}/episode-status`, {
|
|
2605
|
+
headers: { Authorization: `Bearer ${access.media.token}` },
|
|
2606
|
+
signal,
|
|
2607
|
+
cache: "no-store",
|
|
2608
|
+
credentials: "omit"
|
|
2609
|
+
});
|
|
2610
|
+
if (!response.ok) throw new EpisodeStatusError(response.status, retryAfterMilliseconds(response.headers.get("Retry-After"), Date.now()));
|
|
2611
|
+
const body = await response.json();
|
|
2612
|
+
if (typeof body === "object" && body !== null && "status" in body && (body.status === "active" || body.status === "ending" || body.status === "ended")) return body.status;
|
|
2613
|
+
throw new TypeError("Invalid Episode status");
|
|
2614
|
+
},
|
|
2366
2615
|
cancelPendingAdmission: async (access, signal) => {
|
|
2367
2616
|
const { tenantId, spaceId, episodeId, participantId } = access.subject;
|
|
2368
2617
|
const path = [tenantId, spaceId, episodeId, participantId].map(encodeURIComponent);
|
|
@@ -2426,6 +2675,8 @@ function createMediaClient(apiBaseURL, input) {
|
|
|
2426
2675
|
transport: createCloudflareSFUHTTPTransport({
|
|
2427
2676
|
apiBaseURL,
|
|
2428
2677
|
credential: input.credential,
|
|
2678
|
+
onDiagnostic: input.onTransportDiagnostic,
|
|
2679
|
+
traceparent: input.traceparent,
|
|
2429
2680
|
tenantId: subject.tenantId,
|
|
2430
2681
|
spaceId: subject.spaceId,
|
|
2431
2682
|
episodeId: subject.episodeId,
|
|
@@ -2467,6 +2718,8 @@ function bindConnectionMediaClient(client, restart) {
|
|
|
2467
2718
|
function createSyncClient(syncURL, input) {
|
|
2468
2719
|
return createV1SyncClient({
|
|
2469
2720
|
url: syncURL,
|
|
2721
|
+
onDiagnostic: input.onTransportDiagnostic,
|
|
2722
|
+
traceparent: input.traceparent,
|
|
2470
2723
|
token: input.token,
|
|
2471
2724
|
mediaPlane: input.media,
|
|
2472
2725
|
collaboration: { name: "collaboration_v2", chatCursor: { afterSequence: null, retainedFloorSequence: null } },
|
|
@@ -2498,7 +2751,7 @@ function whiteboardURL(syncURL) {
|
|
|
2498
2751
|
}
|
|
2499
2752
|
|
|
2500
2753
|
// src/foreground-deadline.ts
|
|
2501
|
-
function scheduleForegroundDeadline(clock,
|
|
2754
|
+
function scheduleForegroundDeadline(clock, milliseconds2, expired) {
|
|
2502
2755
|
let timer;
|
|
2503
2756
|
let cancelled = false;
|
|
2504
2757
|
const arm = () => {
|
|
@@ -2506,9 +2759,9 @@ function scheduleForegroundDeadline(clock, milliseconds, expired) {
|
|
|
2506
2759
|
timer = clock.setTimeout(() => {
|
|
2507
2760
|
if (cancelled) return;
|
|
2508
2761
|
const elapsed = clock.now() - started;
|
|
2509
|
-
if (typeof document !== "undefined" && document.visibilityState === "hidden" || elapsed >
|
|
2762
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden" || elapsed > milliseconds2 + 2e3 || elapsed < 0) arm();
|
|
2510
2763
|
else expired();
|
|
2511
|
-
},
|
|
2764
|
+
}, milliseconds2);
|
|
2512
2765
|
};
|
|
2513
2766
|
arm();
|
|
2514
2767
|
return () => {
|
|
@@ -2518,7 +2771,7 @@ function scheduleForegroundDeadline(clock, milliseconds, expired) {
|
|
|
2518
2771
|
}
|
|
2519
2772
|
|
|
2520
2773
|
// src/connection/lifecycle.ts
|
|
2521
|
-
import { Clock as Clock2, Context as Context3, Data as Data2, Deferred, Duration, Effect as Effect2, Exit, Fiber, Layer as Layer3, Queue, Scope, SubscriptionRef } from "effect";
|
|
2774
|
+
import { Clock as Clock2, Context as Context3, Data as Data2, Deferred as Deferred2, Duration, Effect as Effect2, Exit, Fiber, Layer as Layer3, Queue, Scope, SubscriptionRef } from "effect";
|
|
2522
2775
|
|
|
2523
2776
|
// src/connection/diagnostics.ts
|
|
2524
2777
|
var ConnectionDiagnostics = class {
|
|
@@ -2543,11 +2796,17 @@ var ConnectionDiagnostics = class {
|
|
|
2543
2796
|
}
|
|
2544
2797
|
recordRecoveryExhausted(input) {
|
|
2545
2798
|
const { state, epoch, code, attempts, transport } = input;
|
|
2799
|
+
const media = input.media ?? (code === "media_recovery_exhausted" ? {
|
|
2800
|
+
media_error_kind: "timeout",
|
|
2801
|
+
media_error_step: "peer_connection",
|
|
2802
|
+
...input.grantedMedia
|
|
2803
|
+
} : void 0);
|
|
2546
2804
|
this.record({
|
|
2547
2805
|
event: "recovery_exhausted",
|
|
2548
2806
|
state,
|
|
2549
2807
|
epoch,
|
|
2550
2808
|
code,
|
|
2809
|
+
...media ? { media } : {},
|
|
2551
2810
|
recovery: {
|
|
2552
2811
|
reference: `connection.${code}`,
|
|
2553
2812
|
attempts,
|
|
@@ -2579,7 +2838,8 @@ var ConnectionDiagnostics = class {
|
|
|
2579
2838
|
...input.parentSpanId ? { parentSpanId: input.parentSpanId } : {},
|
|
2580
2839
|
outcome: end.outcome,
|
|
2581
2840
|
durationMs: Math.max(0, this.#now() - startedAt),
|
|
2582
|
-
...end.code ? { code: end.code } : {}
|
|
2841
|
+
...end.code ? { code: end.code } : {},
|
|
2842
|
+
...end.media ? { media: end.media } : {}
|
|
2583
2843
|
});
|
|
2584
2844
|
}
|
|
2585
2845
|
};
|
|
@@ -2593,7 +2853,7 @@ var ConnectionDiagnostics = class {
|
|
|
2593
2853
|
var MEDIA_RECOVERY_TIMEOUT_MS = 6e4;
|
|
2594
2854
|
var START_TIMEOUT_MS = 3e4;
|
|
2595
2855
|
var LEAVE_TIMEOUT_MS = 5e3;
|
|
2596
|
-
var RECOVERY_BUDGET_MS =
|
|
2856
|
+
var RECOVERY_BUDGET_MS = MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2597
2857
|
var MAX_RECOVERY_ATTEMPTS = 3;
|
|
2598
2858
|
var REFRESH_RETRY_MS = 5e3;
|
|
2599
2859
|
var RECONNECT_GRACE_MS = 250;
|
|
@@ -2634,7 +2894,18 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2634
2894
|
const clock = yield* Clock2.Clock;
|
|
2635
2895
|
const context = yield* Effect2.context();
|
|
2636
2896
|
const serviceScope = yield* Effect2.scope;
|
|
2637
|
-
const
|
|
2897
|
+
const reporter = platform.createConnectionReporter?.(access.currentUnsafe);
|
|
2898
|
+
let grantedMedia = { granted_audio: "unknown", granted_video: "unknown" };
|
|
2899
|
+
let mediaFailure;
|
|
2900
|
+
const diagnostics = new ConnectionDiagnostics({
|
|
2901
|
+
now: () => clock.currentTimeMillisUnsafe(),
|
|
2902
|
+
...options.diagnostics,
|
|
2903
|
+
onEvent: (event) => {
|
|
2904
|
+
reporter?.recordDiagnostic(event);
|
|
2905
|
+
options.diagnostics?.onEvent?.(event);
|
|
2906
|
+
}
|
|
2907
|
+
});
|
|
2908
|
+
const onTransportDiagnostic = (event) => reporter?.recordTransport(event);
|
|
2638
2909
|
const snapshotRef = yield* SubscriptionRef.make(idleSnapshot());
|
|
2639
2910
|
const queue = yield* Queue.unbounded();
|
|
2640
2911
|
const listeners = /* @__PURE__ */ new Set();
|
|
@@ -2645,6 +2916,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2645
2916
|
let fallbackIdentifier = 0;
|
|
2646
2917
|
let emittedPorts = null;
|
|
2647
2918
|
let foregroundRecoveryDeadline = 0;
|
|
2919
|
+
let pendingEpisodeEnd = null;
|
|
2920
|
+
let rejectionCheck = null;
|
|
2648
2921
|
const model = {
|
|
2649
2922
|
state: "idle",
|
|
2650
2923
|
sync: null,
|
|
@@ -2676,11 +2949,11 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2676
2949
|
epoch: 0
|
|
2677
2950
|
};
|
|
2678
2951
|
const mediaAttempt = { expiresAt: null };
|
|
2679
|
-
const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : lifecycleFailure(code, true, message, cause) });
|
|
2680
|
-
const withinForegroundBudget = (effect,
|
|
2952
|
+
const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : sfuAccessRejected(cause) ? failureFromCause(cause, code, message) : lifecycleFailure(code, true, message, cause) });
|
|
2953
|
+
const withinForegroundBudget = (effect, milliseconds2, failure2) => Effect2.raceFirst(
|
|
2681
2954
|
effect,
|
|
2682
2955
|
Effect2.callback((resume) => {
|
|
2683
|
-
const cancel = scheduleForegroundDeadline(platform.clock,
|
|
2956
|
+
const cancel = scheduleForegroundDeadline(platform.clock, milliseconds2, () => resume(Effect2.fail(typeof failure2 === "function" ? failure2() : failure2)));
|
|
2684
2957
|
return Effect2.sync(cancel);
|
|
2685
2958
|
})
|
|
2686
2959
|
);
|
|
@@ -2726,9 +2999,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2726
2999
|
});
|
|
2727
3000
|
const enqueue = (effect, join = false) => Effect2.gen(function* () {
|
|
2728
3001
|
if (model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed"));
|
|
2729
|
-
const deferred = yield*
|
|
3002
|
+
const deferred = yield* Deferred2.make();
|
|
2730
3003
|
yield* Queue.offer(queue, { effect, deferred, join });
|
|
2731
|
-
return yield*
|
|
3004
|
+
return yield* Deferred2.await(deferred);
|
|
2732
3005
|
});
|
|
2733
3006
|
const enqueueBackground = (effect) => {
|
|
2734
3007
|
void Effect2.runForkWith(context)(enqueue(effect).pipe(Effect2.ignore));
|
|
@@ -2741,10 +3014,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2741
3014
|
const scheduleRefresh = (delay) => Effect2.gen(function* () {
|
|
2742
3015
|
yield* stopRefresh();
|
|
2743
3016
|
if (!active(model)) return;
|
|
2744
|
-
const
|
|
3017
|
+
const milliseconds2 = delay ?? (yield* access.millisecondsUntilRefresh) ?? 0;
|
|
2745
3018
|
const scope = model.activeScope;
|
|
2746
3019
|
if (!scope) return;
|
|
2747
|
-
model.refreshFiber = yield* Effect2.forkIn(Effect2.sleep(Math.max(0,
|
|
3020
|
+
model.refreshFiber = yield* Effect2.forkIn(Effect2.sleep(Math.max(0, milliseconds2)).pipe(Effect2.andThen(enqueue(refreshAccess())), Effect2.ignore), scope);
|
|
2748
3021
|
});
|
|
2749
3022
|
const refreshAccess = () => Effect2.gen(function* () {
|
|
2750
3023
|
const scope = model.activeScope;
|
|
@@ -2884,7 +3157,13 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2884
3157
|
const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
|
|
2885
3158
|
return effect.pipe(
|
|
2886
3159
|
Effect2.tap(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "succeeded" }))),
|
|
2887
|
-
Effect2.tapError(
|
|
3160
|
+
Effect2.tapError(
|
|
3161
|
+
(cause) => Effect2.sync(() => {
|
|
3162
|
+
const media = step === "acquire_initial_media" || step === "create_media_client" || step === "start_media" ? { ...classifyMediaFailure(cause, step === "acquire_initial_media" ? "local_capture" : "peer_connection"), ...grantedMedia } : void 0;
|
|
3163
|
+
if (media) mediaFailure = media;
|
|
3164
|
+
span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failureFromCause(cause, "internal_error", "Connection step failed").code, ...media ? { media } : {} });
|
|
3165
|
+
})
|
|
3166
|
+
)
|
|
2888
3167
|
);
|
|
2889
3168
|
};
|
|
2890
3169
|
const confirmSyncEntry = (sync) => {
|
|
@@ -2895,6 +3174,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2895
3174
|
if (model.state === "live") return;
|
|
2896
3175
|
if (model.state === "leaving" || model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Cannot join an inactive Connection"));
|
|
2897
3176
|
model.epoch += 1;
|
|
3177
|
+
pendingEpisodeEnd = null;
|
|
3178
|
+
rejectionCheck = null;
|
|
3179
|
+
grantedMedia = { granted_audio: "unknown", granted_video: "unknown" };
|
|
3180
|
+
mediaFailure = void 0;
|
|
2898
3181
|
model.failure = null;
|
|
2899
3182
|
model.episodeEndConfirmed = false;
|
|
2900
3183
|
model.episodeEndReason = void 0;
|
|
@@ -2909,6 +3192,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2909
3192
|
if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
|
|
2910
3193
|
const captured = yield* trace("acquire_initial_media", acquireInitialMedia(model.initialMedia, model.initialMediaIntent?.() ?? model.intent));
|
|
2911
3194
|
stream = captured.stream;
|
|
3195
|
+
grantedMedia = grantedMediaFromStream(stream);
|
|
2912
3196
|
captureWarning = captured.failure;
|
|
2913
3197
|
if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
|
|
2914
3198
|
const grant = yield* trace("access_initialize", reason === "access_retry" ? access.refreshAfterRejection() : access.initialize(reason));
|
|
@@ -2923,7 +3207,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2923
3207
|
if (media === model.media) mediaAttempt.expiresAt = platform.clock.now() + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2924
3208
|
return replacement.media;
|
|
2925
3209
|
}),
|
|
2926
|
-
|
|
3210
|
+
onTransportDiagnostic,
|
|
3211
|
+
traceparent: reporter?.traceparent,
|
|
3212
|
+
onFailure: (cause, binding = grant.media.clientPayload.connectionId) => enqueueBackground(handleMediaFailure(media, cause, binding)),
|
|
2927
3213
|
onScreenEnded: () => enqueueBackground(notifyScreenEnded())
|
|
2928
3214
|
}),
|
|
2929
3215
|
catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : lifecycleFailure("media_start_failed", true, "The media layer could not start", cause)
|
|
@@ -2933,7 +3219,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2933
3219
|
const sync = yield* trace(
|
|
2934
3220
|
"create_sync_client",
|
|
2935
3221
|
Effect2.try({
|
|
2936
|
-
try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media }),
|
|
3222
|
+
try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, onTransportDiagnostic, traceparent: reporter?.traceparent }),
|
|
2937
3223
|
catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
|
|
2938
3224
|
})
|
|
2939
3225
|
);
|
|
@@ -2943,7 +3229,11 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2943
3229
|
[
|
|
2944
3230
|
trace(
|
|
2945
3231
|
"start_media",
|
|
2946
|
-
model.startInitialMedia ? model.startInitialMedia(media, stream)
|
|
3232
|
+
(model.startInitialMedia ? model.startInitialMedia(media, stream) : Effect2.tryPromise({ try: () => media.start(stream), catch: (cause) => cause })).pipe(
|
|
3233
|
+
Effect2.mapError(
|
|
3234
|
+
(cause) => sfuAccessRejected(cause) ? lifecycleFailure("invalid_access", false, "Media access was rejected. Another tab or device may be using it. Rejoin explicitly to request access again.", cause) : model.startInitialMedia ? lifecycleFailure("media_start_failed", true, "The media layer could not start", cause) : failureFromCause(cause, "media_start_failed", "The media layer could not start")
|
|
3235
|
+
)
|
|
3236
|
+
)
|
|
2947
3237
|
),
|
|
2948
3238
|
trace(
|
|
2949
3239
|
"start_sync",
|
|
@@ -2961,14 +3251,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2961
3251
|
Effect2.matchEffect({
|
|
2962
3252
|
onFailure: (cause) => Effect2.gen(function* () {
|
|
2963
3253
|
stopStream(stream);
|
|
2964
|
-
const
|
|
3254
|
+
const originalFailure = failureFromCause(cause, "internal_error", "The Connection could not start");
|
|
3255
|
+
const failure2 = yield* resolveMediaRejection(originalFailure);
|
|
3256
|
+
if (failure2.code === "episode_ended" && needsEpisodeCheck(originalFailure)) {
|
|
3257
|
+
span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "episode_ended" });
|
|
3258
|
+
yield* finishEpisodeEnded();
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
2965
3261
|
model.failure = toFailure(failure2);
|
|
2966
3262
|
if (model.joinCancelled) {
|
|
2967
3263
|
span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "invalid_state" });
|
|
2968
3264
|
return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
|
|
2969
3265
|
}
|
|
2970
3266
|
span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure2.code });
|
|
2971
|
-
const retry = !retried && failure2.code === "invalid_access";
|
|
3267
|
+
const retry = !retried && failure2.code === "invalid_access" && !sfuAccessRejected(failure2.cause);
|
|
2972
3268
|
if (!retry) yield* recordFailure(failure2);
|
|
2973
3269
|
model.admissionCleanupConfirmed = yield* cancelPendingAdmission();
|
|
2974
3270
|
yield* stopPorts();
|
|
@@ -3113,7 +3409,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3113
3409
|
}
|
|
3114
3410
|
const code = plan.kind === "media" ? "media_recovery_exhausted" : "sync_recovery_exhausted";
|
|
3115
3411
|
const transport = model.sync?.getSnapshot().connection.recoveryDiagnostic ?? null;
|
|
3116
|
-
diagnostics.recordRecoveryExhausted({ state: model.state, epoch, code, attempts, transport });
|
|
3412
|
+
diagnostics.recordRecoveryExhausted({ state: model.state, epoch, code, attempts, transport, media: mediaFailure, grantedMedia });
|
|
3117
3413
|
if (plan.kind === "sync") {
|
|
3118
3414
|
yield* failForSnapshot(lifecycleFailure(code, true, "Connection lost. Rejoin to continue."));
|
|
3119
3415
|
return;
|
|
@@ -3135,12 +3431,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3135
3431
|
Effect2.map((now) => ({
|
|
3136
3432
|
kind,
|
|
3137
3433
|
deadline: now + boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4),
|
|
3138
|
-
attempts: boundedInteger(options.recovery?.maxAttempts, MAX_RECOVERY_ATTEMPTS, 1, 10),
|
|
3139
|
-
delays: options.recovery?.backoffMs?.length ? options.recovery.backoffMs : [100, 250, 500]
|
|
3434
|
+
attempts: kind === "sync" ? boundedInteger(options.recovery?.maxAttempts, MAX_RECOVERY_ATTEMPTS, 1, 10) : null,
|
|
3435
|
+
delays: options.recovery?.backoffMs?.length ? options.recovery.backoffMs : kind === "media" ? [1e3, 2e3, 4e3, 6e3] : [100, 250, 500]
|
|
3140
3436
|
}))
|
|
3141
3437
|
);
|
|
3142
3438
|
const attemptRecovery = (plan, mediaAttempt2, syncAttempt, onAttempt) => Effect2.gen(function* () {
|
|
3143
|
-
for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
|
|
3439
|
+
for (let attempt = 1; plan.attempts === null || attempt <= plan.attempts; attempt += 1) {
|
|
3144
3440
|
const remaining = plan.deadline - (yield* Clock2.currentTimeMillis);
|
|
3145
3441
|
if (remaining <= 0) return null;
|
|
3146
3442
|
onAttempt();
|
|
@@ -3151,7 +3447,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3151
3447
|
);
|
|
3152
3448
|
if (failure2 === null) return attempt;
|
|
3153
3449
|
if (!failure2.recoverable) return yield* Effect2.fail(failure2);
|
|
3154
|
-
if (attempt < plan.attempts) {
|
|
3450
|
+
if (plan.attempts === null || attempt < plan.attempts) {
|
|
3155
3451
|
const remaining2 = Math.max(0, plan.deadline - (yield* Clock2.currentTimeMillis));
|
|
3156
3452
|
yield* Effect2.sleep(Math.min(recoveryDelay(plan, attempt), remaining2));
|
|
3157
3453
|
}
|
|
@@ -3168,10 +3464,13 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3168
3464
|
});
|
|
3169
3465
|
const recoveryDelay = (plan, attempt) => {
|
|
3170
3466
|
const delay = plan.delays[Math.min(attempt - 1, plan.delays.length - 1)] ?? 0;
|
|
3171
|
-
|
|
3467
|
+
const finiteDelay = Number.isFinite(delay) ? Math.max(0, delay) : 0;
|
|
3468
|
+
const mediaMinimum = [1e3, 2e3, 4e3][attempt - 1] ?? 6e3;
|
|
3469
|
+
return plan.kind === "media" ? Math.min(6e3, Math.max(mediaMinimum, finiteDelay)) : finiteDelay;
|
|
3172
3470
|
};
|
|
3173
3471
|
const finishRecovery = (attempt, epoch) => Effect2.gen(function* () {
|
|
3174
3472
|
if (model.epoch !== epoch || !active(model)) return;
|
|
3473
|
+
mediaFailure = void 0;
|
|
3175
3474
|
model.recoveryQueued = null;
|
|
3176
3475
|
model.syncSnapshot = model.sync?.getSnapshot() ?? null;
|
|
3177
3476
|
model.mediaSnapshot = model.media?.getSnapshot() ?? null;
|
|
@@ -3231,7 +3530,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3231
3530
|
attempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
3232
3531
|
yield* foreign7(() => media.restart(restartInput), "media_start_failed", "Media transport could not restart");
|
|
3233
3532
|
yield* waitForMediaLive(media);
|
|
3234
|
-
})
|
|
3533
|
+
}).pipe(
|
|
3534
|
+
Effect2.tapError(
|
|
3535
|
+
(cause) => Effect2.sync(() => {
|
|
3536
|
+
const step = cause.code === "access_unavailable" || cause.code === "invalid_access" ? "access_refresh" : "peer_connection";
|
|
3537
|
+
const snapshotDiagnostic = cause.cause === void 0 && step === "peer_connection" ? model.media?.getSnapshot().failure?.diagnostic : void 0;
|
|
3538
|
+
mediaFailure = { ...snapshotDiagnostic ?? classifyMediaFailure(cause, step), ...grantedMedia };
|
|
3539
|
+
})
|
|
3540
|
+
),
|
|
3541
|
+
Effect2.tap(
|
|
3542
|
+
() => Effect2.sync(() => {
|
|
3543
|
+
mediaFailure = void 0;
|
|
3544
|
+
})
|
|
3545
|
+
)
|
|
3546
|
+
);
|
|
3235
3547
|
const syncAttemptExpired = (attempt, sync, now) => {
|
|
3236
3548
|
if (attempt.client !== sync) {
|
|
3237
3549
|
attempt.client = sync;
|
|
@@ -3273,7 +3585,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3273
3585
|
model.syncBindingCleanup?.();
|
|
3274
3586
|
model.sync = null;
|
|
3275
3587
|
const sync2 = yield* Effect2.try({
|
|
3276
|
-
try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media }),
|
|
3588
|
+
try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, onTransportDiagnostic, traceparent: reporter?.traceparent }),
|
|
3277
3589
|
catch: (cause) => lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
|
|
3278
3590
|
});
|
|
3279
3591
|
model.sync = sync2;
|
|
@@ -3341,23 +3653,128 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3341
3653
|
yield* transition("left");
|
|
3342
3654
|
});
|
|
3343
3655
|
const syncNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "connecting" || snapshot.connection.phase === "recovering" || snapshot.connection.phase === "terminal");
|
|
3656
|
+
const mediaNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "recovering" || snapshot.connection.phase === "failed" && snapshot.failure?.recoverable === true);
|
|
3344
3657
|
const recordFailure = (failure2) => Effect2.sync(() => {
|
|
3345
|
-
diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure2.code, ...failure2.sync ? { sync: failure2.sync } : {} });
|
|
3658
|
+
diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure2.code, ...failure2.sync ? { sync: failure2.sync } : {}, ...mediaFailure ? { media: mediaFailure } : {} });
|
|
3346
3659
|
});
|
|
3347
|
-
const
|
|
3660
|
+
const finishEpisodeEnded = () => Effect2.gen(function* () {
|
|
3661
|
+
model.episodeEndConfirmed = true;
|
|
3662
|
+
model.syncSnapshot = model.sync?.getSnapshot() ?? model.syncSnapshot;
|
|
3663
|
+
model.episodeEndReason = model.syncSnapshot?.control?.endReason;
|
|
3664
|
+
yield* finishTerminalSnapshot();
|
|
3665
|
+
});
|
|
3666
|
+
const needsEpisodeCheck = (failure2) => failure2.code === "invalid_access" || failure2.cause instanceof CloudflareSFUError && failure2.cause.options.status === 410;
|
|
3667
|
+
const episodeHasEnded = () => {
|
|
3668
|
+
const snapshot = model.sync?.getSnapshot();
|
|
3669
|
+
return model.episodeEndConfirmed || snapshot?.control?.status === "ended" || snapshot?.connection.terminalReason === "episode_ended";
|
|
3670
|
+
};
|
|
3671
|
+
const stopRejectedMedia = () => {
|
|
3672
|
+
model.mediaBindingCleanup?.();
|
|
3673
|
+
model.mediaSnapshot = model.media?.getSnapshot() ?? null;
|
|
3674
|
+
};
|
|
3675
|
+
const waitForPendingEpisodeEnd = Effect2.suspend(() => pendingEpisodeEnd ? Deferred2.await(pendingEpisodeEnd) : Effect2.void);
|
|
3676
|
+
const resolveMediaRejection = (failure2) => {
|
|
3677
|
+
if (!needsEpisodeCheck(failure2)) return Effect2.succeed(failure2);
|
|
3678
|
+
if (failure2.code === "invalid_access") stopRejectedMedia();
|
|
3679
|
+
const epoch = model.epoch;
|
|
3680
|
+
const original = access.currentUnsafe();
|
|
3681
|
+
if (!original) return Effect2.succeed(failure2);
|
|
3682
|
+
return checkMediaRejection(failure2, original, epoch);
|
|
3683
|
+
};
|
|
3684
|
+
const checkMediaRejection = (failure2, original, epoch) => Effect2.gen(function* () {
|
|
3685
|
+
let attempt = 0;
|
|
3686
|
+
let refreshedAfterAuthRejection = false;
|
|
3687
|
+
let rejectedToken = null;
|
|
3688
|
+
while (model.epoch === epoch) {
|
|
3689
|
+
yield* waitForPendingEpisodeEnd;
|
|
3690
|
+
if (episodeHasEnded()) return lifecycleFailure("episode_ended", false, "The Episode has ended");
|
|
3691
|
+
const current = access.currentUnsafe();
|
|
3692
|
+
const grant = sameBindingCredential(current, original);
|
|
3693
|
+
const result = yield* readEpisodeStatus(platform.getEpisodeStatus, grant, rejectedToken);
|
|
3694
|
+
yield* waitForPendingEpisodeEnd;
|
|
3695
|
+
if (episodeHasEnded() || statusIsEnded(result.status)) return lifecycleFailure("episode_ended", false, "The Episode has ended");
|
|
3696
|
+
if (result.status === "active") return failure2;
|
|
3697
|
+
const error2 = result.cause;
|
|
3698
|
+
if (statusCredentialsRejected(error2)) {
|
|
3699
|
+
rejectedToken = grant.media.token;
|
|
3700
|
+
if (!refreshedAfterAuthRejection) {
|
|
3701
|
+
refreshedAfterAuthRejection = true;
|
|
3702
|
+
yield* access.refresh("scheduled_refresh", false).pipe(Effect2.catch(() => Effect2.succeed(null)));
|
|
3703
|
+
continue;
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
yield* Effect2.sleep(episodeStatusRetryDelay(attempt++, error2));
|
|
3707
|
+
}
|
|
3708
|
+
return lifecycleFailure("invalid_state", false, "The connection changed");
|
|
3709
|
+
}).pipe(
|
|
3710
|
+
Effect2.timeout(6e4),
|
|
3711
|
+
Effect2.catch(() => Effect2.succeed(episodeHasEnded() ? lifecycleFailure("episode_ended", false, "The Episode has ended") : lifecycleFailure("access_unavailable", true, "Could not confirm Episode status. Rejoin to try again.")))
|
|
3712
|
+
);
|
|
3713
|
+
const commitSnapshotFailure = (failure2) => Effect2.gen(function* () {
|
|
3714
|
+
if (failure2.code === "episode_ended") return yield* finishEpisodeEnded();
|
|
3348
3715
|
model.failure = toFailure(failure2);
|
|
3349
3716
|
yield* recordFailure(failure2);
|
|
3350
3717
|
yield* stopPorts();
|
|
3351
3718
|
yield* transition("failed");
|
|
3352
3719
|
});
|
|
3720
|
+
const failForSnapshot = (failure2) => {
|
|
3721
|
+
if (!needsEpisodeCheck(failure2)) return commitSnapshotFailure(failure2);
|
|
3722
|
+
if (failure2.code === "invalid_access") stopRejectedMedia();
|
|
3723
|
+
const scope = model.activeScope;
|
|
3724
|
+
const epoch = model.epoch;
|
|
3725
|
+
const binding = access.currentUnsafe()?.media.clientPayload.connectionId;
|
|
3726
|
+
if (!scope) return Effect2.void;
|
|
3727
|
+
if (rejectionCheck?.epoch === epoch && rejectionCheck.binding === binding) {
|
|
3728
|
+
if (failure2.code === "invalid_access") rejectionCheck.failure = failure2;
|
|
3729
|
+
return publish();
|
|
3730
|
+
}
|
|
3731
|
+
const check = { epoch, binding, failure: failure2 };
|
|
3732
|
+
rejectionCheck = check;
|
|
3733
|
+
const releaseBinding = binding ? Effect2.runSync(access.holdMediaBinding(binding)) : Effect2.void;
|
|
3734
|
+
return Effect2.gen(function* () {
|
|
3735
|
+
if (model.epoch !== epoch || model.activeScope !== scope) return;
|
|
3736
|
+
yield* publish();
|
|
3737
|
+
yield* Effect2.forkIn(
|
|
3738
|
+
resolveMediaRejection(failure2).pipe(
|
|
3739
|
+
Effect2.flatMap(
|
|
3740
|
+
(resolved) => enqueue(
|
|
3741
|
+
Effect2.suspend(() => {
|
|
3742
|
+
if (model.epoch !== epoch || !active(model)) return Effect2.void;
|
|
3743
|
+
const finalFailure = resolved === failure2 ? check.failure : resolved;
|
|
3744
|
+
if (finalFailure === failure2 && failure2.cause instanceof CloudflareSFUError && failure2.cause.options.status === 410) {
|
|
3745
|
+
rejectionCheck = null;
|
|
3746
|
+
return recover("media");
|
|
3747
|
+
}
|
|
3748
|
+
return commitSnapshotFailure(finalFailure);
|
|
3749
|
+
})
|
|
3750
|
+
)
|
|
3751
|
+
),
|
|
3752
|
+
Effect2.ensuring(
|
|
3753
|
+
Effect2.gen(function* () {
|
|
3754
|
+
if (rejectionCheck === check) rejectionCheck = null;
|
|
3755
|
+
if (model.activeScope === scope) yield* releaseBinding;
|
|
3756
|
+
})
|
|
3757
|
+
)
|
|
3758
|
+
),
|
|
3759
|
+
scope
|
|
3760
|
+
);
|
|
3761
|
+
});
|
|
3762
|
+
};
|
|
3353
3763
|
const handleMediaSnapshot = (media, snapshot) => Effect2.gen(function* () {
|
|
3354
3764
|
if (media !== model.media || media.getSnapshot() !== snapshot) return;
|
|
3355
3765
|
if (snapshot === model.mediaSnapshot) return;
|
|
3766
|
+
const phase = mediaDiagnosticPhase(snapshot);
|
|
3767
|
+
if (phase !== mediaDiagnosticPhase(model.mediaSnapshot)) onTransportDiagnostic({ event: "media_state", phase });
|
|
3356
3768
|
model.mediaSnapshot = snapshot;
|
|
3357
|
-
if (
|
|
3769
|
+
if (mediaNeedsRecovery(snapshot)) yield* recover("media");
|
|
3358
3770
|
yield* publish();
|
|
3359
3771
|
});
|
|
3360
|
-
const handleMediaFailure = (
|
|
3772
|
+
const handleMediaFailure = (media, cause, binding) => {
|
|
3773
|
+
if (media !== model.media || binding !== access.currentUnsafe()?.media.clientPayload.connectionId) return Effect2.void;
|
|
3774
|
+
if (sfuAccessRejected(cause)) stopRejectedMedia();
|
|
3775
|
+
if (sfuAccessRejected(cause) || cause instanceof CloudflareSFUError && cause.options.status === 410 || active(model) && connectionSuperseded(cause)) return failForSnapshot(failureFromCause(cause, "media_start_failed", "Media transport needs recovery"));
|
|
3776
|
+
return handleMediaSnapshot(media, media.getSnapshot());
|
|
3777
|
+
};
|
|
3361
3778
|
const applyAutomaticMediaReplacement = (grant) => Effect2.gen(function* () {
|
|
3362
3779
|
const media = model.media;
|
|
3363
3780
|
const scope = model.activeScope;
|
|
@@ -3373,7 +3790,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3373
3790
|
"media_start_failed",
|
|
3374
3791
|
"The media client could not apply a replacement access grant"
|
|
3375
3792
|
).pipe(
|
|
3376
|
-
(effect) => withinForegroundBudget(effect, MEDIA_RECOVERY_TIMEOUT_MS, lifecycleFailure("media_start_failed", true, "Automatic media replacement did not finish")),
|
|
3793
|
+
(effect) => withinForegroundBudget(effect, MEDIA_RECOVERY_TIMEOUT_MS, lifecycleFailure("media_start_failed", true, "Automatic media replacement did not finish", new CloudflareSFUError("Automatic media replacement deadline expired", "negotiation_timeout"))),
|
|
3794
|
+
Effect2.tapError(
|
|
3795
|
+
(cause) => Effect2.sync(() => {
|
|
3796
|
+
mediaFailure = { ...classifyMediaFailure(cause, "peer_connection"), ...grantedMedia };
|
|
3797
|
+
})
|
|
3798
|
+
),
|
|
3377
3799
|
Effect2.match({ onSuccess: () => true, onFailure: () => false }),
|
|
3378
3800
|
Effect2.flatMap(
|
|
3379
3801
|
(restarted) => enqueue(
|
|
@@ -3407,8 +3829,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3407
3829
|
const exit = yield* Effect2.exit(Fiber.join(fiber));
|
|
3408
3830
|
activeJoin = null;
|
|
3409
3831
|
joinDeferred = null;
|
|
3410
|
-
yield*
|
|
3411
|
-
}) :
|
|
3832
|
+
yield* Deferred2.done(work.deferred, exit);
|
|
3833
|
+
}) : Deferred2.complete(work.deferred, work.effect).pipe(Effect2.asVoid);
|
|
3412
3834
|
yield* Effect2.forkScoped(Queue.take(queue).pipe(Effect2.flatMap(process), Effect2.forever));
|
|
3413
3835
|
const accessReplacementUnsubscribe = yield* access.subscribeAutomaticMediaReplacement((grant) => enqueueBackground(applyAutomaticMediaReplacement(grant)));
|
|
3414
3836
|
const foregroundUnsubscribe = platform.subscribeForeground?.(() => {
|
|
@@ -3418,6 +3840,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3418
3840
|
yield* Effect2.addFinalizer(
|
|
3419
3841
|
() => Effect2.gen(function* () {
|
|
3420
3842
|
model.closed = true;
|
|
3843
|
+
reporter?.dispose();
|
|
3421
3844
|
accessReplacementUnsubscribe();
|
|
3422
3845
|
foregroundUnsubscribe?.();
|
|
3423
3846
|
if (activeJoin) yield* Fiber.interrupt(activeJoin);
|
|
@@ -3457,15 +3880,33 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3457
3880
|
confirmEpisodeEnded: Effect2.sync(() => {
|
|
3458
3881
|
model.episodeEndConfirmed = true;
|
|
3459
3882
|
}),
|
|
3883
|
+
runEpisodeEnd: (operation) => Effect2.gen(function* () {
|
|
3884
|
+
const completed = yield* Deferred2.make();
|
|
3885
|
+
const epoch = model.epoch;
|
|
3886
|
+
pendingEpisodeEnd = completed;
|
|
3887
|
+
return yield* operation.pipe(
|
|
3888
|
+
Effect2.tap(
|
|
3889
|
+
() => Effect2.sync(() => {
|
|
3890
|
+
if (model.epoch === epoch) model.episodeEndConfirmed = true;
|
|
3891
|
+
})
|
|
3892
|
+
),
|
|
3893
|
+
Effect2.ensuring(
|
|
3894
|
+
Effect2.gen(function* () {
|
|
3895
|
+
if (pendingEpisodeEnd === completed) pendingEpisodeEnd = null;
|
|
3896
|
+
yield* Deferred2.succeed(completed, void 0);
|
|
3897
|
+
})
|
|
3898
|
+
)
|
|
3899
|
+
);
|
|
3900
|
+
}),
|
|
3460
3901
|
join: () => Effect2.suspend(() => {
|
|
3461
3902
|
if (model.state === "live") return Effect2.void;
|
|
3462
|
-
if (joinDeferred) return
|
|
3903
|
+
if (joinDeferred) return Deferred2.await(joinDeferred);
|
|
3463
3904
|
return Effect2.gen(function* () {
|
|
3464
|
-
const deferred = yield*
|
|
3905
|
+
const deferred = yield* Deferred2.make();
|
|
3465
3906
|
model.joinCancelled = false;
|
|
3466
3907
|
joinDeferred = deferred;
|
|
3467
3908
|
yield* Queue.offer(queue, { effect: performJoin(), deferred, join: true });
|
|
3468
|
-
return yield*
|
|
3909
|
+
return yield* Deferred2.await(deferred);
|
|
3469
3910
|
});
|
|
3470
3911
|
}),
|
|
3471
3912
|
leave: () => Effect2.gen(function* () {
|
|
@@ -3491,9 +3932,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
3491
3932
|
),
|
|
3492
3933
|
runPortCommand: (operation) => withFreshAccess(operation),
|
|
3493
3934
|
nowUnsafe: () => clock.currentTimeMillisUnsafe(),
|
|
3494
|
-
scheduleUnsafe: (callback,
|
|
3935
|
+
scheduleUnsafe: (callback, milliseconds2) => platform.clock.setTimeout(callback, milliseconds2),
|
|
3495
3936
|
cancelScheduleUnsafe: (handle) => platform.clock.clearTimeout(handle),
|
|
3496
|
-
createId: () => platform.createId?.() ?? `local-${++fallbackIdentifier}
|
|
3937
|
+
createId: () => platform.createId?.() ?? `local-${++fallbackIdentifier}`,
|
|
3938
|
+
recordTransportDiagnostic: onTransportDiagnostic
|
|
3497
3939
|
};
|
|
3498
3940
|
})
|
|
3499
3941
|
);
|
|
@@ -3538,7 +3980,12 @@ function toFailure(value) {
|
|
|
3538
3980
|
return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message, ...value.sync ? { sync: value.sync } : {} });
|
|
3539
3981
|
}
|
|
3540
3982
|
function accessFailure(value) {
|
|
3541
|
-
|
|
3983
|
+
const rejectedMessage = connectionSuperseded(value) ? "Access was rejected. Another tab or device may be using it. Rejoin explicitly to continue." : "Access was rejected";
|
|
3984
|
+
return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", value.code === "access.invalid" ? rejectedMessage : "Could not reach the Space. Check your network and try again.", value.cause);
|
|
3985
|
+
}
|
|
3986
|
+
function connectionSuperseded(failure2) {
|
|
3987
|
+
const cause = failure2 instanceof ConnectionAccessFailure ? failure2.cause : failure2;
|
|
3988
|
+
return cause instanceof AccessGrantError ? cause.reason === "connection_superseded" : typeof cause === "object" && cause !== null && "code" in cause && cause.code === "participant.connection_superseded";
|
|
3542
3989
|
}
|
|
3543
3990
|
function captureFailure(cause) {
|
|
3544
3991
|
if (cause instanceof ConnectionError && cause.cause === void 0) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
|
|
@@ -3594,15 +4041,19 @@ function initialCaptureWarning(failures) {
|
|
|
3594
4041
|
return lifecycleFailure(first.failure.code, true, message, first.failure.cause);
|
|
3595
4042
|
}
|
|
3596
4043
|
function failureFromCause(cause, code, message) {
|
|
4044
|
+
if (sfuAccessRejected(cause)) return lifecycleFailure("invalid_access", false, "Media access was rejected. Rejoin explicitly to continue.", cause);
|
|
3597
4045
|
if (cause instanceof ConnectionLifecycleFailure) return cause;
|
|
3598
4046
|
if (cause instanceof ConnectionAccessFailure) return accessFailure(cause);
|
|
3599
|
-
if (cause instanceof AccessGrantError) return
|
|
4047
|
+
if (cause instanceof AccessGrantError || connectionSuperseded(cause)) return accessFailure(new ConnectionAccessFailure({ code: "access.invalid", cause }));
|
|
3600
4048
|
if (cause instanceof ConnectionError) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
|
|
3601
4049
|
if (cause instanceof DOMException && cause.name === "NotAllowedError") return lifecycleFailure("permission_denied", true, "Media permission was denied", cause);
|
|
3602
4050
|
return lifecycleFailure(code, true, message, cause);
|
|
3603
4051
|
}
|
|
4052
|
+
function sfuAccessRejected(cause) {
|
|
4053
|
+
return cause instanceof CloudflareSFUError && (cause.options.status === 401 || cause.options.status === 403);
|
|
4054
|
+
}
|
|
3604
4055
|
function accessRejected(cause) {
|
|
3605
|
-
return cause instanceof ConnectionError ? cause.code === "invalid_access" : typeof cause === "object" && cause !== null && "code" in cause && cause.code === "access.invalid";
|
|
4056
|
+
return cause instanceof ConnectionError ? cause.code === "invalid_access" : typeof cause === "object" && cause !== null && "code" in cause && (cause.code === "access.invalid" || cause.code === "participant.connection_superseded");
|
|
3606
4057
|
}
|
|
3607
4058
|
function boundedInteger(value, fallback, minimum, maximum) {
|
|
3608
4059
|
const result = value ?? fallback;
|
|
@@ -3617,6 +4068,9 @@ function syncPhase(phase) {
|
|
|
3617
4068
|
if (phase === "connecting") return "connecting";
|
|
3618
4069
|
return "idle";
|
|
3619
4070
|
}
|
|
4071
|
+
function mediaDiagnosticPhase(snapshot) {
|
|
4072
|
+
return snapshot?.connection.peerConnectionState ?? snapshot?.connection.phase;
|
|
4073
|
+
}
|
|
3620
4074
|
function mediaPhase(phase) {
|
|
3621
4075
|
if (phase === "live") return "healthy";
|
|
3622
4076
|
if (phase === "recovering") return "recovering";
|
|
@@ -3629,6 +4083,7 @@ function syncTerminalFailure(snapshot) {
|
|
|
3629
4083
|
const reason = snapshot.connection.terminalReason;
|
|
3630
4084
|
if (reason === "state_digest_recovery_failed") return lifecycleFailure("state_digest_recovery_failed", false, "The Episode state could not be recovered from an authoritative snapshot");
|
|
3631
4085
|
if (reason === "episode_ended") return lifecycleFailure("episode_ended", false, "The Episode has ended");
|
|
4086
|
+
if (reason === "stale_participant_generation") return lifecycleFailure("invalid_access", false, "Call access was replaced. Another tab or device may be using it. Rejoin explicitly to request access again.");
|
|
3632
4087
|
if (reason === "participant_inactive" || reason === "participant_removed" || reason === "stale_participant_generation" || reason === "access_rejected") return lifecycleFailure("invalid_access", false, "Participant access is no longer valid");
|
|
3633
4088
|
if (reason === "entry_timeout") return lifecycleFailure("sync_start_failed", true, "Call entry was not confirmed in time");
|
|
3634
4089
|
return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
|
|
@@ -3643,6 +4098,22 @@ function syncStartupFailure(snapshot) {
|
|
|
3643
4098
|
function syncAutoRecoverable(snapshot, failure2) {
|
|
3644
4099
|
return failure2.recoverable && snapshot.connection.terminalReason !== "entry_timeout";
|
|
3645
4100
|
}
|
|
4101
|
+
function statusIsEnded(status) {
|
|
4102
|
+
return status === "ending" || status === "ended";
|
|
4103
|
+
}
|
|
4104
|
+
function statusCredentialsRejected(error2) {
|
|
4105
|
+
return error2 instanceof EpisodeStatusError && (error2.status === 401 || error2.status === 403);
|
|
4106
|
+
}
|
|
4107
|
+
function readEpisodeStatus(read, grant, rejectedToken) {
|
|
4108
|
+
if (grant.media.token === rejectedToken) return Effect2.succeed({ status: null, cause: null });
|
|
4109
|
+
return Effect2.tryPromise({
|
|
4110
|
+
try: (signal) => read ? read(grant, signal) : Promise.reject(new Error("Episode status lookup unavailable")),
|
|
4111
|
+
catch: (cause) => cause
|
|
4112
|
+
}).pipe(Effect2.timeout(3e3), Effect2.match({ onSuccess: (status) => ({ status, cause: null }), onFailure: (cause) => ({ status: null, cause }) }));
|
|
4113
|
+
}
|
|
4114
|
+
function sameBindingCredential(current, original) {
|
|
4115
|
+
return current?.media.clientPayload.connectionId === original.media.clientPayload.connectionId ? current : original;
|
|
4116
|
+
}
|
|
3646
4117
|
|
|
3647
4118
|
// src/space-client/errors.ts
|
|
3648
4119
|
import { Data as Data3 } from "effect";
|
|
@@ -3743,7 +4214,7 @@ function connectionErrorCode(code, action) {
|
|
|
3743
4214
|
|
|
3744
4215
|
// src/space-client/store.ts
|
|
3745
4216
|
import { Context as Context4, Effect as Effect3, Layer as Layer4, SubscriptionRef as SubscriptionRef2 } from "effect";
|
|
3746
|
-
var INTERRUPTED_CONNECTION_PHASES = /* @__PURE__ */ new Set(["unresponsive", "connecting", "recovering"]);
|
|
4217
|
+
var INTERRUPTED_CONNECTION_PHASES = /* @__PURE__ */ new Set(["unresponsive", "connecting", "recovering", "stopped"]);
|
|
3747
4218
|
var empty = (value) => Object.freeze(value);
|
|
3748
4219
|
var EMPTY = empty({
|
|
3749
4220
|
connection: empty({ status: "idle", episode: null, lastError: null, episodeEnded: false, removedByHost: false }),
|
|
@@ -4406,11 +4877,79 @@ function chatError(message) {
|
|
|
4406
4877
|
}
|
|
4407
4878
|
|
|
4408
4879
|
// src/space-client/media-controller.ts
|
|
4409
|
-
import { Clock as Clock3, Context as Context6, Deferred as
|
|
4880
|
+
import { Clock as Clock3, Context as Context6, Deferred as Deferred3, Effect as Effect5, Exit as Exit2, Layer as Layer6, Semaphore } from "effect";
|
|
4881
|
+
|
|
4882
|
+
// src/space-client/media-projection-monitor.ts
|
|
4883
|
+
var GRACE_MS = 1e4;
|
|
4884
|
+
function projectionMatches(observation) {
|
|
4885
|
+
return observation.enabled === observation.projectedEnabled && (!observation.enabled || observation.publicationId === observation.projectedPublicationId);
|
|
4886
|
+
}
|
|
4887
|
+
var MediaProjectionMonitor = class {
|
|
4888
|
+
#options;
|
|
4889
|
+
#actions = /* @__PURE__ */ new Map();
|
|
4890
|
+
constructor(options) {
|
|
4891
|
+
this.#options = options;
|
|
4892
|
+
}
|
|
4893
|
+
observe(source, observation) {
|
|
4894
|
+
let action = this.#actions.get(source);
|
|
4895
|
+
if (action?.revision !== observation.revision) {
|
|
4896
|
+
this.#clear(source);
|
|
4897
|
+
action = { revision: observation.revision, mismatch: null, reported: false, failed: false };
|
|
4898
|
+
this.#actions.set(source, action);
|
|
4899
|
+
}
|
|
4900
|
+
if (action.failed) return;
|
|
4901
|
+
if (projectionMatches(observation)) {
|
|
4902
|
+
if (action.mismatch) this.#options.cancel(action.mismatch.timer);
|
|
4903
|
+
action.mismatch = null;
|
|
4904
|
+
return;
|
|
4905
|
+
}
|
|
4906
|
+
if (action.mismatch) {
|
|
4907
|
+
action.mismatch.observation = observation;
|
|
4908
|
+
if (observation.enabled && this.#options.now() - action.mismatch.startedAt >= GRACE_MS) this.#expire(source, action);
|
|
4909
|
+
return;
|
|
4910
|
+
}
|
|
4911
|
+
const timer = this.#options.schedule(() => this.#expire(source, action), GRACE_MS);
|
|
4912
|
+
action.mismatch = { observation, startedAt: this.#options.now(), timer };
|
|
4913
|
+
}
|
|
4914
|
+
state(source) {
|
|
4915
|
+
const action = this.#actions.get(source);
|
|
4916
|
+
if (action?.failed) return "failed";
|
|
4917
|
+
return action?.mismatch?.observation.enabled ? "requesting" : null;
|
|
4918
|
+
}
|
|
4919
|
+
reset() {
|
|
4920
|
+
for (const source of this.#actions.keys()) this.#clear(source);
|
|
4921
|
+
}
|
|
4922
|
+
#clear(source) {
|
|
4923
|
+
const action = this.#actions.get(source);
|
|
4924
|
+
if (action?.mismatch) this.#options.cancel(action.mismatch.timer);
|
|
4925
|
+
this.#actions.delete(source);
|
|
4926
|
+
}
|
|
4927
|
+
#expire(source, action) {
|
|
4928
|
+
if (this.#actions.get(source) !== action || !action.mismatch) return;
|
|
4929
|
+
const { observation, startedAt } = action.mismatch;
|
|
4930
|
+
action.failed = observation.enabled;
|
|
4931
|
+
if (observation.enabled) this.#options.pause(source, observation.revision);
|
|
4932
|
+
if (!action.reported) {
|
|
4933
|
+
action.reported = true;
|
|
4934
|
+
this.#options.report({
|
|
4935
|
+
event: "media_projection_mismatch",
|
|
4936
|
+
source,
|
|
4937
|
+
publication_state: observation.enabled ? "on" : "off",
|
|
4938
|
+
projection_state: observation.projectedEnabled ? "on" : "off",
|
|
4939
|
+
duration_ms: this.#options.now() - startedAt,
|
|
4940
|
+
attempt: 0,
|
|
4941
|
+
outcome: observation.enabled ? "failed" : "started"
|
|
4942
|
+
});
|
|
4943
|
+
}
|
|
4944
|
+
this.#options.changed();
|
|
4945
|
+
}
|
|
4946
|
+
};
|
|
4947
|
+
|
|
4948
|
+
// src/space-client/media-controller.ts
|
|
4410
4949
|
var MEDIA_SOURCES = ["microphone", "camera", "screen"];
|
|
4411
4950
|
var MEDIA_READY_TIMEOUT_MS = 1e4;
|
|
4412
4951
|
function sourceWorker(enabled) {
|
|
4413
|
-
return { desired: { enabled, revision: 0, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" },
|
|
4952
|
+
return { desired: { enabled, revision: 0, controlRevision: -1, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" }, initialRevision: 0, localEnabled: enabled, pause: null, serverSequence: -1, forcedRevision: null, pausedPublicationId: null };
|
|
4414
4953
|
}
|
|
4415
4954
|
var MediaControllerService = class extends Context6.Service()("@chalk/client/MediaController") {
|
|
4416
4955
|
};
|
|
@@ -4453,6 +4992,7 @@ var MediaControllerRuntime = class {
|
|
|
4453
4992
|
#tracks = /* @__PURE__ */ new Map();
|
|
4454
4993
|
#sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
|
|
4455
4994
|
#syncLive = false;
|
|
4995
|
+
#recovering = null;
|
|
4456
4996
|
#pendingSources = /* @__PURE__ */ new Map();
|
|
4457
4997
|
#pendingCaptures = /* @__PURE__ */ new Set();
|
|
4458
4998
|
#readinessChecks = /* @__PURE__ */ new Set();
|
|
@@ -4468,6 +5008,8 @@ var MediaControllerRuntime = class {
|
|
|
4468
5008
|
#unsubscribeConnection = null;
|
|
4469
5009
|
#unsubscribeScreenEnded = null;
|
|
4470
5010
|
#unsubscribePorts = null;
|
|
5011
|
+
#microphoneWatch = null;
|
|
5012
|
+
#projection;
|
|
4471
5013
|
constructor(connection, store, selection, gates, now, fork) {
|
|
4472
5014
|
this.#connection = connection;
|
|
4473
5015
|
this.#store = store;
|
|
@@ -4475,6 +5017,16 @@ var MediaControllerRuntime = class {
|
|
|
4475
5017
|
this.#gates = gates;
|
|
4476
5018
|
this.#now = now;
|
|
4477
5019
|
this.#fork = fork;
|
|
5020
|
+
this.#projection = new MediaProjectionMonitor({
|
|
5021
|
+
now,
|
|
5022
|
+
schedule: connection.scheduleUnsafe,
|
|
5023
|
+
cancel: connection.cancelScheduleUnsafe,
|
|
5024
|
+
changed: () => this.#publish(),
|
|
5025
|
+
report: (event) => connection.recordTransportDiagnostic(event),
|
|
5026
|
+
pause: (source, revision) => {
|
|
5027
|
+
if (this.#ports) this.#pausePublication(this.#ports, source, revision);
|
|
5028
|
+
}
|
|
5029
|
+
});
|
|
4478
5030
|
this.#unsubscribeConnection = connection.subscribe(() => this.#handleConnectionChange());
|
|
4479
5031
|
this.#unsubscribeScreenEnded = connection.subscribeScreenEnded(() => this.#handleScreenEnded());
|
|
4480
5032
|
this.#unsubscribePorts = connection.subscribePorts((ports) => this.#bind(ports));
|
|
@@ -4522,7 +5074,7 @@ var MediaControllerRuntime = class {
|
|
|
4522
5074
|
}).pipe(Effect5.mapError(normalizeMediaError));
|
|
4523
5075
|
};
|
|
4524
5076
|
initialIntent() {
|
|
4525
|
-
return { microphone: this.#
|
|
5077
|
+
return { microphone: this.#hasConsent("microphone"), camera: this.#hasConsent("camera") };
|
|
4526
5078
|
}
|
|
4527
5079
|
captureInitial(intent) {
|
|
4528
5080
|
for (const source of ["microphone", "camera"]) this.#sources[source].initialRevision = this.#sources[source].desired.revision;
|
|
@@ -4578,6 +5130,7 @@ var MediaControllerRuntime = class {
|
|
|
4578
5130
|
this.#fork(this.#refreshDevices().pipe(Effect5.ignore));
|
|
4579
5131
|
}
|
|
4580
5132
|
dispose() {
|
|
5133
|
+
this.#projection.reset();
|
|
4581
5134
|
for (const check of this.#readinessChecks) check(normalizeMediaError(cancelledCaptureError()));
|
|
4582
5135
|
this.#cancelCaptures();
|
|
4583
5136
|
for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
|
|
@@ -4593,9 +5146,6 @@ var MediaControllerRuntime = class {
|
|
|
4593
5146
|
this.#unsubscribePorts = null;
|
|
4594
5147
|
this.#ports = null;
|
|
4595
5148
|
this.#resetObservations();
|
|
4596
|
-
this.#screenAudioTrack?.stop();
|
|
4597
|
-
this.#screenAudioTrack = null;
|
|
4598
|
-
for (const track of this.#tracks.values()) track.stop();
|
|
4599
5149
|
this.#clearTracks();
|
|
4600
5150
|
this.#clearRequests();
|
|
4601
5151
|
this.#publish();
|
|
@@ -4611,10 +5161,11 @@ var MediaControllerRuntime = class {
|
|
|
4611
5161
|
const check = (failure2) => {
|
|
4612
5162
|
if (failure2) return resume(Effect5.fail(failure2));
|
|
4613
5163
|
if (this.#sources[source].desired.revision !== revision) return resume(Effect5.void);
|
|
4614
|
-
if (ports &&
|
|
5164
|
+
if (ports && !this.#canUsePorts(ports)) return resume(Effect5.fail(normalizeMediaError(cancelledCaptureError())));
|
|
4615
5165
|
const state = this.#connection.getSnapshot().state;
|
|
4616
|
-
|
|
4617
|
-
if (
|
|
5166
|
+
const phases = [state, this.#ports?.sync.getSnapshot().connection.phase, this.#store.getSnapshot().connection.status];
|
|
5167
|
+
if (phases.every((phase) => phase === "live")) return resume(Effect5.void);
|
|
5168
|
+
if (!["joining", "reconnecting", "live"].includes(state)) resume(Effect5.fail(normalizeMediaError(new V1SyncError("Media enable requires a live call", "not_live"))));
|
|
4618
5169
|
};
|
|
4619
5170
|
this.#readinessChecks.add(check);
|
|
4620
5171
|
const unsubscribe = this.#connection.subscribe(() => check());
|
|
@@ -4644,11 +5195,20 @@ var MediaControllerRuntime = class {
|
|
|
4644
5195
|
#pauseTracks(source) {
|
|
4645
5196
|
const track = this.#track(source);
|
|
4646
5197
|
if (track) track.enabled = false;
|
|
5198
|
+
for (const publication of this.#ports?.media.getSnapshot().localTracks ?? []) if (publication.source === source) publication.track.enabled = false;
|
|
4647
5199
|
if (source === "screen" && this.#screenAudioTrack) this.#screenAudioTrack.enabled = false;
|
|
4648
5200
|
}
|
|
5201
|
+
#retainPause(state, next) {
|
|
5202
|
+
const previous = state.pause;
|
|
5203
|
+
state.pause = previous ? Promise.allSettled([previous, next]).then((results) => {
|
|
5204
|
+
for (const result of results) if (result.status === "rejected") throw result.reason;
|
|
5205
|
+
}) : next;
|
|
5206
|
+
void state.pause.catch(() => void 0);
|
|
5207
|
+
}
|
|
4649
5208
|
#recordIntent(source, enabled, options) {
|
|
4650
5209
|
const state = this.#sources[source];
|
|
4651
|
-
const
|
|
5210
|
+
const controlRevision = Math.max(state.serverSequence, this.#ports?.sync.getSnapshot().control?.revision ?? -1);
|
|
5211
|
+
const desired = { enabled, revision: state.desired.revision + 1, controlRevision, options, previousEnabled: state.localEnabled };
|
|
4652
5212
|
state.desired = desired;
|
|
4653
5213
|
if (!enabled) {
|
|
4654
5214
|
state.localEnabled = false;
|
|
@@ -4660,12 +5220,7 @@ var MediaControllerRuntime = class {
|
|
|
4660
5220
|
const ports = this.#ports;
|
|
4661
5221
|
if (ports) {
|
|
4662
5222
|
ports.media.setLocalSourceIntent?.(source, false);
|
|
4663
|
-
|
|
4664
|
-
const next = this.#beginLocalPause(ports, source);
|
|
4665
|
-
state.pause = previous ? Promise.allSettled([previous, next]).then((results) => {
|
|
4666
|
-
for (const result of results) if (result.status === "rejected") throw result.reason;
|
|
4667
|
-
}) : next;
|
|
4668
|
-
void state.pause.catch(() => void 0);
|
|
5223
|
+
this.#retainPause(state, this.#beginLocalPause(ports, source));
|
|
4669
5224
|
}
|
|
4670
5225
|
}
|
|
4671
5226
|
for (const check of this.#readinessChecks) check();
|
|
@@ -4673,6 +5228,7 @@ var MediaControllerRuntime = class {
|
|
|
4673
5228
|
}
|
|
4674
5229
|
#act(source, enabled, options) {
|
|
4675
5230
|
return Effect5.suspend(() => {
|
|
5231
|
+
if (this.#ports?.media.getSnapshot().connection.phase === "stopped") return Effect5.void;
|
|
4676
5232
|
const desired = this.#recordIntent(source, enabled, options);
|
|
4677
5233
|
const ready = enabled && source !== "screen" && this.#store.getSnapshot().connection.status !== "live" ? this.#waitForLive(source, desired.revision).pipe(
|
|
4678
5234
|
Effect5.tapError(
|
|
@@ -4757,7 +5313,16 @@ var MediaControllerRuntime = class {
|
|
|
4757
5313
|
const state = this.#sources[source];
|
|
4758
5314
|
if (!intent.enabled && ports.sync.getSnapshot().connection.phase !== "live") return;
|
|
4759
5315
|
const previous = intent.previousEnabled;
|
|
4760
|
-
if (state.desired.revision
|
|
5316
|
+
if (state.desired.revision !== intent.revision) return;
|
|
5317
|
+
state.result = { revision: intent.revision, status: "failed", cause, cleanup: !intent.enabled || !previous };
|
|
5318
|
+
if (state.result.cleanup) {
|
|
5319
|
+
if (intent.enabled && source !== "screen") this.#pausePublication(ports, source, intent.revision);
|
|
5320
|
+
else {
|
|
5321
|
+
state.localEnabled = false;
|
|
5322
|
+
this.#pauseTracks(source);
|
|
5323
|
+
ports.media.setLocalSourceIntent?.(source, false);
|
|
5324
|
+
}
|
|
5325
|
+
}
|
|
4761
5326
|
});
|
|
4762
5327
|
}
|
|
4763
5328
|
#prepareDevice(ports, source, intent) {
|
|
@@ -4867,21 +5432,26 @@ var MediaControllerRuntime = class {
|
|
|
4867
5432
|
})
|
|
4868
5433
|
);
|
|
4869
5434
|
}
|
|
4870
|
-
#assertCurrentIntent(source, revision) {
|
|
4871
|
-
|
|
5435
|
+
#assertCurrentIntent(source, revision, enabling = true) {
|
|
5436
|
+
const state = this.#sources[source];
|
|
5437
|
+
if (state.desired.revision !== revision || enabling && state.forcedRevision === revision) throw cancelledCaptureError();
|
|
5438
|
+
}
|
|
5439
|
+
#hasConsent(source) {
|
|
5440
|
+
const state = this.#sources[source];
|
|
5441
|
+
return state.desired.enabled && state.forcedRevision !== state.desired.revision;
|
|
4872
5442
|
}
|
|
4873
5443
|
#resetObservations() {
|
|
5444
|
+
if (this.#connection.getSnapshot().state !== "reconnecting") this.#recovering = null;
|
|
4874
5445
|
for (const source of MEDIA_SOURCES) {
|
|
4875
5446
|
const state = this.#sources[source];
|
|
4876
5447
|
state.result = { revision: state.desired.revision, status: "confirmed" };
|
|
4877
5448
|
state.pause = null;
|
|
4878
5449
|
state.serverSequence = -1;
|
|
4879
|
-
state.forcedRevision = null;
|
|
4880
5450
|
}
|
|
4881
5451
|
}
|
|
4882
5452
|
#pauseInitialTracks() {
|
|
4883
5453
|
for (const source of ["microphone", "camera"]) {
|
|
4884
|
-
const enabled = this.#
|
|
5454
|
+
const enabled = this.#hasConsent(source);
|
|
4885
5455
|
const track = this.#track(source);
|
|
4886
5456
|
if (track && !enabled) track.enabled = false;
|
|
4887
5457
|
this.#sources[source].localEnabled = !!track && enabled;
|
|
@@ -4892,15 +5462,14 @@ var MediaControllerRuntime = class {
|
|
|
4892
5462
|
const revisions = { microphone: this.#sources.microphone.initialRevision, camera: this.#sources.camera.initialRevision };
|
|
4893
5463
|
const tracks = stream.getTracks().filter((track) => {
|
|
4894
5464
|
const source = track.kind === "audio" ? "microphone" : "camera";
|
|
4895
|
-
const
|
|
4896
|
-
track.enabled =
|
|
4897
|
-
if (!
|
|
5465
|
+
const enabled = this.#hasConsent(source);
|
|
5466
|
+
track.enabled = enabled;
|
|
5467
|
+
if (!enabled) {
|
|
4898
5468
|
this.#deleteTrack(source);
|
|
4899
5469
|
if (source === "microphone") track.stop();
|
|
4900
5470
|
}
|
|
4901
|
-
return
|
|
5471
|
+
return enabled;
|
|
4902
5472
|
});
|
|
4903
|
-
for (const source of ["microphone", "camera"]) this.#sources[source].sentRevision = revisions[source];
|
|
4904
5473
|
await media.start(streamFromTracks(tracks));
|
|
4905
5474
|
for (const source of ["microphone", "camera"]) {
|
|
4906
5475
|
const state = this.#sources[source];
|
|
@@ -4913,11 +5482,10 @@ var MediaControllerRuntime = class {
|
|
|
4913
5482
|
const command = this.#mediaAction(
|
|
4914
5483
|
enabled,
|
|
4915
5484
|
foreign2(async () => {
|
|
4916
|
-
this.#assertCurrentIntent(source, intent.revision);
|
|
5485
|
+
this.#assertCurrentIntent(source, intent.revision, enabled);
|
|
4917
5486
|
this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled");
|
|
4918
|
-
const
|
|
4919
|
-
|
|
4920
|
-
await (source === "microphone" ? ports.sync.setMicrophoneEnabled(enabled) : source === "camera" ? ports.sync.setCameraEnabled(enabled) : ports.sync.setScreenShareEnabled(enabled));
|
|
5487
|
+
const send = source === "microphone" ? ports.sync.setMicrophoneEnabled : source === "camera" ? ports.sync.setCameraEnabled : ports.sync.setScreenShareEnabled;
|
|
5488
|
+
await send.call(ports.sync, enabled);
|
|
4921
5489
|
}).pipe(
|
|
4922
5490
|
Effect5.andThen(enabled && source !== "screen" ? this.#waitForLive(source, intent.revision, ports) : Effect5.void),
|
|
4923
5491
|
Effect5.tap(() => Effect5.try({ try: () => this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled"), catch: (cause) => cause }))
|
|
@@ -4927,10 +5495,19 @@ var MediaControllerRuntime = class {
|
|
|
4927
5495
|
}
|
|
4928
5496
|
#reconcileIntents() {
|
|
4929
5497
|
const live = this.#connection.getSnapshot().state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live";
|
|
5498
|
+
if (this.#syncLive && !live) this.#rememberRecoveryIntents();
|
|
4930
5499
|
const resumed = live && !this.#syncLive;
|
|
4931
5500
|
this.#syncLive = live;
|
|
4932
5501
|
if (!resumed) return;
|
|
4933
|
-
|
|
5502
|
+
if (this.#ports) this.#pauseRecoveredIntents(this.#ports, this.#ports.sync.getSnapshot());
|
|
5503
|
+
this.#resumePendingActions();
|
|
5504
|
+
}
|
|
5505
|
+
#resumePendingActions() {
|
|
5506
|
+
for (const source of MEDIA_SOURCES) {
|
|
5507
|
+
const state = this.#sources[source];
|
|
5508
|
+
if (source !== "screen" && (state.desired.enabled || state.result.revision === state.desired.revision)) continue;
|
|
5509
|
+
this.#fork(this.#serialize(source, this.#work(source)).pipe(Effect5.ignore));
|
|
5510
|
+
}
|
|
4934
5511
|
}
|
|
4935
5512
|
#clearScreenAudio(media) {
|
|
4936
5513
|
return foreign2(() => media.clearPreparedLocalTrack("screen_audio")).pipe(
|
|
@@ -4943,7 +5520,8 @@ var MediaControllerRuntime = class {
|
|
|
4943
5520
|
);
|
|
4944
5521
|
}
|
|
4945
5522
|
#beginLocalPause(ports, source) {
|
|
4946
|
-
|
|
5523
|
+
this.#sources[source].pausedPublicationId = ports.media.getSnapshot().localTracks.find((item) => item.source === source)?.publicationId ?? null;
|
|
5524
|
+
const pause = ports.media.closeForcedLocalPublication ? Promise.resolve().then(() => ports.media.closeForcedLocalPublication(source)) : Effect5.runPromise(this.#localTarget(ports, source, false));
|
|
4947
5525
|
void pause.catch(() => void 0);
|
|
4948
5526
|
return pause;
|
|
4949
5527
|
}
|
|
@@ -5012,18 +5590,53 @@ var MediaControllerRuntime = class {
|
|
|
5012
5590
|
#setTrack(source, track) {
|
|
5013
5591
|
if (source !== "camera") {
|
|
5014
5592
|
this.#tracks.set(source, track);
|
|
5593
|
+
if (source === "microphone") this.#watchMicrophone(track);
|
|
5015
5594
|
return;
|
|
5016
5595
|
}
|
|
5017
5596
|
const state = this.#camera;
|
|
5018
5597
|
if (!("owner" in state)) throw cancelledCaptureError();
|
|
5019
5598
|
if (state.status === "live" && state.track !== track) throw cancelledCaptureError();
|
|
5020
|
-
const onEnded = () =>
|
|
5021
|
-
if (this.#camera.status === "live" && this.#camera.track === track) this.#transitionCamera({ status: "ended" });
|
|
5022
|
-
};
|
|
5599
|
+
const onEnded = () => this.#handleCameraEnded(track);
|
|
5023
5600
|
this.#transitionCamera({ status: "live", owner: state.owner, track, publication: this.#ports?.media ?? "initial", onEnded });
|
|
5024
5601
|
}
|
|
5602
|
+
#handleCameraEnded(track) {
|
|
5603
|
+
const state = this.#camera;
|
|
5604
|
+
if (state.status !== "live" || state.track !== track) return;
|
|
5605
|
+
if (this.#ports && state.publication === this.#ports.media) this.#pausePublication(this.#ports, "camera", this.#sources.camera.desired.revision);
|
|
5606
|
+
this.#transitionCamera({ status: "ended" });
|
|
5607
|
+
}
|
|
5608
|
+
#watchMicrophone(track) {
|
|
5609
|
+
if (this.#microphoneWatch?.track === track) return;
|
|
5610
|
+
this.#microphoneWatch?.unsubscribe();
|
|
5611
|
+
this.#microphoneWatch = null;
|
|
5612
|
+
if (!track) return;
|
|
5613
|
+
const changed = () => {
|
|
5614
|
+
if (this.#microphoneWatch?.track !== track) return;
|
|
5615
|
+
if (track.muted || track.readyState === "ended") {
|
|
5616
|
+
track.enabled = false;
|
|
5617
|
+
const state = this.#sources.microphone;
|
|
5618
|
+
if (this.#ports) this.#pausePublication(this.#ports, "microphone", state.desired.revision);
|
|
5619
|
+
else {
|
|
5620
|
+
state.forcedRevision = state.desired.revision;
|
|
5621
|
+
state.localEnabled = false;
|
|
5622
|
+
}
|
|
5623
|
+
}
|
|
5624
|
+
this.#publish();
|
|
5625
|
+
};
|
|
5626
|
+
for (const event of ["mute", "unmute", "ended"]) track.addEventListener(event, changed);
|
|
5627
|
+
this.#microphoneWatch = {
|
|
5628
|
+
track,
|
|
5629
|
+
unsubscribe: () => {
|
|
5630
|
+
for (const event of ["mute", "unmute", "ended"]) track.removeEventListener(event, changed);
|
|
5631
|
+
}
|
|
5632
|
+
};
|
|
5633
|
+
if (track.muted || track.readyState === "ended") changed();
|
|
5634
|
+
}
|
|
5025
5635
|
#deleteTrack(source) {
|
|
5026
5636
|
if (source !== "camera") {
|
|
5637
|
+
if (source === "microphone") {
|
|
5638
|
+
this.#watchMicrophone(null);
|
|
5639
|
+
}
|
|
5027
5640
|
this.#tracks.delete(source);
|
|
5028
5641
|
return;
|
|
5029
5642
|
}
|
|
@@ -5034,6 +5647,7 @@ var MediaControllerRuntime = class {
|
|
|
5034
5647
|
} else this.#transitionCamera({ status: "ended" });
|
|
5035
5648
|
}
|
|
5036
5649
|
#clearTracks() {
|
|
5650
|
+
this.#watchMicrophone(null);
|
|
5037
5651
|
this.#tracks.clear();
|
|
5038
5652
|
this.#transitionCamera({ status: "idle" });
|
|
5039
5653
|
}
|
|
@@ -5076,11 +5690,11 @@ var MediaControllerRuntime = class {
|
|
|
5076
5690
|
#beginCameraCapture(acquire) {
|
|
5077
5691
|
const abort = new AbortController();
|
|
5078
5692
|
const owner = {
|
|
5079
|
-
result:
|
|
5693
|
+
result: Deferred3.makeUnsafe(),
|
|
5080
5694
|
references: 0,
|
|
5081
5695
|
cancel: () => {
|
|
5082
5696
|
abort.abort();
|
|
5083
|
-
|
|
5697
|
+
Deferred3.doneUnsafe(owner.result, Effect5.fail(cancelledCaptureError()));
|
|
5084
5698
|
}
|
|
5085
5699
|
};
|
|
5086
5700
|
this.#transitionCamera({ status: "capturing", owner });
|
|
@@ -5094,9 +5708,7 @@ var MediaControllerRuntime = class {
|
|
|
5094
5708
|
track.stop();
|
|
5095
5709
|
throw cancelledCaptureError();
|
|
5096
5710
|
}
|
|
5097
|
-
const onEnded = () =>
|
|
5098
|
-
if (this.#camera.status === "live" && this.#camera.owner === owner) this.#transitionCamera({ status: "ended" });
|
|
5099
|
-
};
|
|
5711
|
+
const onEnded = () => this.#handleCameraEnded(track);
|
|
5100
5712
|
this.#transitionCamera({ status: "live", owner, track, publication: state.status === "live" ? state.publication : null, onEnded });
|
|
5101
5713
|
if (track.readyState !== "live") {
|
|
5102
5714
|
this.#transitionCamera({ status: "ended" });
|
|
@@ -5106,7 +5718,7 @@ var MediaControllerRuntime = class {
|
|
|
5106
5718
|
catch: (cause) => cause
|
|
5107
5719
|
})
|
|
5108
5720
|
),
|
|
5109
|
-
Effect5.onExit((exit) =>
|
|
5721
|
+
Effect5.onExit((exit) => Deferred3.done(owner.result, exit)),
|
|
5110
5722
|
Effect5.ignore
|
|
5111
5723
|
)
|
|
5112
5724
|
);
|
|
@@ -5122,7 +5734,7 @@ var MediaControllerRuntime = class {
|
|
|
5122
5734
|
const current = this.#camera;
|
|
5123
5735
|
if (owner.references === 0 && "owner" in current && current.owner === owner && (current.status !== "live" || current.publication === null)) this.#transitionCamera({ status: "ended" });
|
|
5124
5736
|
};
|
|
5125
|
-
return
|
|
5737
|
+
return Deferred3.await(owner.result).pipe(
|
|
5126
5738
|
Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(cancelledCaptureError())),
|
|
5127
5739
|
Effect5.onExit(
|
|
5128
5740
|
(exit) => Effect5.sync(() => {
|
|
@@ -5152,9 +5764,11 @@ var MediaControllerRuntime = class {
|
|
|
5152
5764
|
return Effect5.suspend(() => {
|
|
5153
5765
|
let cancelled = false;
|
|
5154
5766
|
let captured = null;
|
|
5767
|
+
const cancellation = Deferred3.makeUnsafe();
|
|
5155
5768
|
const cancel = () => {
|
|
5156
5769
|
cancelled = true;
|
|
5157
5770
|
stopStream(captured);
|
|
5771
|
+
Deferred3.doneUnsafe(cancellation, Effect5.fail(cancelledCaptureError()));
|
|
5158
5772
|
};
|
|
5159
5773
|
this.#pendingCaptures.add(cancel);
|
|
5160
5774
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
@@ -5163,7 +5777,7 @@ var MediaControllerRuntime = class {
|
|
|
5163
5777
|
if (cancelled) throw cancelledCaptureError();
|
|
5164
5778
|
const stream = await operation();
|
|
5165
5779
|
captured = stream;
|
|
5166
|
-
if (cancelled) {
|
|
5780
|
+
if (cancelled || this.#ports?.media.getSnapshot().connection.phase === "stopped") {
|
|
5167
5781
|
stopStream(stream);
|
|
5168
5782
|
throw cancelledCaptureError();
|
|
5169
5783
|
}
|
|
@@ -5171,6 +5785,7 @@ var MediaControllerRuntime = class {
|
|
|
5171
5785
|
}).pipe(
|
|
5172
5786
|
Effect5.mapError(onCaptureError),
|
|
5173
5787
|
Effect5.flatMap(use),
|
|
5788
|
+
Effect5.raceFirst(Deferred3.await(cancellation)),
|
|
5174
5789
|
Effect5.tap(() => cancelled ? Effect5.fail(cancelledCaptureError()) : Effect5.void),
|
|
5175
5790
|
Effect5.onExit(
|
|
5176
5791
|
(exit) => Effect5.sync(() => {
|
|
@@ -5186,6 +5801,9 @@ var MediaControllerRuntime = class {
|
|
|
5186
5801
|
this.#transitionCamera({ status: "ended" });
|
|
5187
5802
|
for (const cancel of this.#pendingCaptures) cancel();
|
|
5188
5803
|
this.#pendingCaptures.clear();
|
|
5804
|
+
this.#screenAudioTrack?.stop();
|
|
5805
|
+
this.#screenAudioTrack = null;
|
|
5806
|
+
for (const track of this.#tracks.values()) track.stop();
|
|
5189
5807
|
}
|
|
5190
5808
|
#captureSource(source, signal) {
|
|
5191
5809
|
return this.#captureStream(
|
|
@@ -5242,42 +5860,46 @@ var MediaControllerRuntime = class {
|
|
|
5242
5860
|
const failure2 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
|
|
5243
5861
|
return rollback.pipe(Effect5.andThen(Effect5.sync(() => this.#publish())), Effect5.andThen(Effect5.fail(failure2)));
|
|
5244
5862
|
}
|
|
5863
|
+
#canUsePorts(ports) {
|
|
5864
|
+
return this.#ports?.sync === ports.sync && this.#ports.media === ports.media && ports.media.getSnapshot().connection.phase !== "stopped";
|
|
5865
|
+
}
|
|
5245
5866
|
#assertActivePorts(ports, action) {
|
|
5246
|
-
if (this.#connection.getSnapshot().state === "live" && this.#ports
|
|
5867
|
+
if (this.#connection.getSnapshot().state === "live" && this.#canUsePorts(ports)) return;
|
|
5247
5868
|
throw new ConnectionError({ code: "invalid_state", action, recoverable: false, message: `${action} belongs to an inactive connection` });
|
|
5248
5869
|
}
|
|
5249
5870
|
#pausePublication(ports, source, revision) {
|
|
5250
5871
|
const state = this.#sources[source];
|
|
5251
|
-
if (state.desired.revision !== revision || ports.sync.getSnapshot().control?.status === "ended") return;
|
|
5872
|
+
if (state.desired.revision !== revision || state.forcedRevision === revision || ports.sync.getSnapshot().control?.status === "ended") return;
|
|
5252
5873
|
state.forcedRevision = revision;
|
|
5253
5874
|
state.localEnabled = false;
|
|
5254
5875
|
this.#pauseTracks(source);
|
|
5255
5876
|
ports.media.setLocalSourceIntent?.(source, false);
|
|
5256
|
-
|
|
5877
|
+
this.#retainPause(state, this.#beginLocalPause(ports, source));
|
|
5257
5878
|
this.#publish();
|
|
5258
5879
|
}
|
|
5259
5880
|
#bind(ports) {
|
|
5881
|
+
if (ports?.media.getSnapshot().connection.phase === "stopped") this.#cancelCaptures();
|
|
5260
5882
|
if (ports && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
|
|
5883
|
+
this.#rememberRecoveryIntents();
|
|
5261
5884
|
for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
|
|
5262
5885
|
this.#unsubscribeMedia = null;
|
|
5263
5886
|
this.#unsubscribeRequests = null;
|
|
5264
5887
|
this.#unsubscribePause = null;
|
|
5265
5888
|
this.#unsubscribeSync = null;
|
|
5266
5889
|
this.#ports = ports;
|
|
5890
|
+
this.#projection.reset();
|
|
5267
5891
|
this.#syncLive = false;
|
|
5268
5892
|
this.#bindCamera(ports);
|
|
5269
5893
|
if (!ports) {
|
|
5270
5894
|
this.#resetObservations();
|
|
5271
5895
|
this.#cancelCaptures();
|
|
5272
|
-
this.#screenAudioTrack?.stop();
|
|
5273
|
-
this.#screenAudioTrack = null;
|
|
5274
|
-
for (const track of this.#tracks.values()) track.stop();
|
|
5275
5896
|
this.#clearTracks();
|
|
5276
5897
|
this.#clearRequests();
|
|
5277
5898
|
this.#publish();
|
|
5278
5899
|
return;
|
|
5279
5900
|
}
|
|
5280
5901
|
this.#unsubscribeMedia = ports.media.subscribe(() => {
|
|
5902
|
+
if (ports.media.getSnapshot().connection.phase === "stopped") this.#cancelCaptures();
|
|
5281
5903
|
this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
|
|
5282
5904
|
this.#publish();
|
|
5283
5905
|
});
|
|
@@ -5292,41 +5914,27 @@ var MediaControllerRuntime = class {
|
|
|
5292
5914
|
const state = this.#sources[pause.source];
|
|
5293
5915
|
if (pause.revision <= state.serverSequence) return;
|
|
5294
5916
|
state.serverSequence = pause.revision;
|
|
5295
|
-
this.#pausePublication(ports, pause.source, state.
|
|
5917
|
+
if (pause.revision > state.desired.controlRevision) this.#pausePublication(ports, pause.source, state.desired.revision);
|
|
5296
5918
|
}) ?? null;
|
|
5297
5919
|
}
|
|
5298
|
-
#
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
for (const item of own) {
|
|
5302
|
-
if (item.source === "screen_audio" || item.publicationId === null) continue;
|
|
5303
|
-
const state = this.#sources[item.source];
|
|
5304
|
-
if (state.localEnabled && state.sentRevision === state.desired.revision) publications.set(item.source, { revision: state.sentRevision, publicationId: item.publicationId });
|
|
5305
|
-
}
|
|
5306
|
-
return publications;
|
|
5920
|
+
#rememberRecoveryIntents() {
|
|
5921
|
+
if (!this.#ports) return;
|
|
5922
|
+
this.#recovering ??= new Map(MEDIA_SOURCES.filter((source) => this.#sources[source].desired.enabled).map((source) => [source, this.#sources[source].desired.revision]));
|
|
5307
5923
|
}
|
|
5308
|
-
#
|
|
5309
|
-
|
|
5924
|
+
#pauseRecoveredIntents(ports, snapshot) {
|
|
5925
|
+
const recovering = this.#recovering;
|
|
5926
|
+
this.#recovering = null;
|
|
5927
|
+
for (const [source, revision] of recovering ?? []) {
|
|
5310
5928
|
const current = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
|
|
5311
|
-
if (!current
|
|
5929
|
+
if (!current?.enabled) this.#pausePublication(ports, source, revision);
|
|
5312
5930
|
}
|
|
5313
5931
|
}
|
|
5314
5932
|
#subscribeSync(ports) {
|
|
5315
|
-
let recovering = /* @__PURE__ */ new Map();
|
|
5316
|
-
let lastLive = ports.sync.getSnapshot();
|
|
5317
|
-
let wasLive = lastLive.connection.phase === "live";
|
|
5318
5933
|
return ports.sync.subscribe((snapshot) => {
|
|
5319
|
-
const live = snapshot.connection.phase === "live";
|
|
5320
|
-
if (wasLive && !live) recovering = this.#rememberPublications(lastLive);
|
|
5321
|
-
if (live && !wasLive) {
|
|
5322
|
-
this.#pauseLostPublications(ports, snapshot, recovering);
|
|
5323
|
-
recovering.clear();
|
|
5324
|
-
}
|
|
5325
|
-
if (live) lastLive = snapshot;
|
|
5326
|
-
wasLive = live;
|
|
5327
5934
|
this.#removeRequestsFromMissingParticipants(snapshot);
|
|
5328
5935
|
this.#applyRemoteMediaState(ports, snapshot);
|
|
5329
5936
|
this.#reconcileIntents();
|
|
5937
|
+
this.#publish();
|
|
5330
5938
|
});
|
|
5331
5939
|
}
|
|
5332
5940
|
#bindCamera(ports) {
|
|
@@ -5400,14 +6008,64 @@ var MediaControllerRuntime = class {
|
|
|
5400
6008
|
if (!track || this.#track("screen") !== track) return;
|
|
5401
6009
|
this.#fork(this.#stopScreen().pipe(Effect5.ignore));
|
|
5402
6010
|
}
|
|
6011
|
+
#observeProjections() {
|
|
6012
|
+
const ports = this.#ports;
|
|
6013
|
+
this.#watchMicrophone(ports?.media.getSnapshot().localTracks.find((item) => item.source === "microphone")?.track ?? this.#track("microphone") ?? null);
|
|
6014
|
+
if (!ports) {
|
|
6015
|
+
this.#projection.reset();
|
|
6016
|
+
return;
|
|
6017
|
+
}
|
|
6018
|
+
this.#enforcePause(ports, "microphone");
|
|
6019
|
+
this.#enforcePause(ports, "camera");
|
|
6020
|
+
if (!this.#syncLive) {
|
|
6021
|
+
this.#projection.reset();
|
|
6022
|
+
return;
|
|
6023
|
+
}
|
|
6024
|
+
this.#observeProjection(ports, "microphone");
|
|
6025
|
+
this.#observeProjection(ports, "camera");
|
|
6026
|
+
}
|
|
6027
|
+
#enforcePause(ports, source) {
|
|
6028
|
+
const publication = ports.media.getSnapshot().localTracks.find((item) => item.source === source);
|
|
6029
|
+
const state = this.#sources[source];
|
|
6030
|
+
if (!this.#hasConsent(source) && publication?.enabled && state.pausedPublicationId !== publication.publicationId) {
|
|
6031
|
+
this.#pauseTracks(source);
|
|
6032
|
+
ports.media.setLocalSourceIntent?.(source, false);
|
|
6033
|
+
this.#retainPause(state, this.#beginLocalPause(ports, source));
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
#observeProjection(ports, source) {
|
|
6037
|
+
const sync = ports.sync.getSnapshot();
|
|
6038
|
+
const state = this.#sources[source];
|
|
6039
|
+
const publication = ports.media.getSnapshot().localTracks.find((item) => item.source === source);
|
|
6040
|
+
const projection = sync.media?.items.find((item) => item.participantId === sync.participantId && item.source === source) ?? { enabled: false, publicationId: null };
|
|
6041
|
+
this.#projection.observe(source, {
|
|
6042
|
+
revision: state.desired.revision,
|
|
6043
|
+
publicationId: publication?.publicationId ?? null,
|
|
6044
|
+
enabled: livePublication(publication),
|
|
6045
|
+
projectedEnabled: projection.enabled,
|
|
6046
|
+
projectedPublicationId: projection.publicationId
|
|
6047
|
+
});
|
|
6048
|
+
}
|
|
6049
|
+
#confirmedLocal(source) {
|
|
6050
|
+
const snapshot = this.#ports?.media.getSnapshot();
|
|
6051
|
+
if (snapshot?.connection.phase === "stopped") return { source, state: "unavailable", track: null };
|
|
6052
|
+
const publication = snapshot?.localTracks.find((item) => item.source === source);
|
|
6053
|
+
const sync = this.#ports?.sync.getSnapshot();
|
|
6054
|
+
const projected = sync?.media?.items.some((item) => item.participantId === sync.participantId && item.source === source && item.enabled && item.publicationId === publication?.publicationId) ?? false;
|
|
6055
|
+
const confirmed = this.#syncLive && livePublication(publication) && projected;
|
|
6056
|
+
const pending = this.#pendingSources.has(source) || this.#connection.getSnapshot().state === "joining";
|
|
6057
|
+
const track = publication?.track ?? this.#track(source) ?? null;
|
|
6058
|
+
return { source, track, state: confirmedLocalMediaState(track, this.#hasConsent(source), pending, confirmed, this.#syncLive, this.#projection.state(source)) };
|
|
6059
|
+
}
|
|
5403
6060
|
#publish() {
|
|
5404
6061
|
const current = this.#store.getSnapshot();
|
|
5405
6062
|
const snapshot = this.#ports?.media.getSnapshot();
|
|
5406
6063
|
const state = this.#connection.getSnapshot().state;
|
|
6064
|
+
this.#observeProjections();
|
|
5407
6065
|
const local = Object.freeze({
|
|
5408
|
-
microphone:
|
|
5409
|
-
camera:
|
|
5410
|
-
screen:
|
|
6066
|
+
microphone: this.#confirmedLocal("microphone"),
|
|
6067
|
+
camera: this.#confirmedLocal("camera"),
|
|
6068
|
+
screen: screenLocalMedia(this.#track("screen") ?? null, snapshot, state, this.#pendingSources.has("screen") && this.#sources.screen.desired.enabled)
|
|
5411
6069
|
});
|
|
5412
6070
|
const remote = Object.freeze((snapshot?.remoteTracks ?? []).map((track) => Object.freeze({ participantId: track.participantId, source: track.source, publicationId: track.publicationId, track: track.track })));
|
|
5413
6071
|
const media = Object.freeze({
|
|
@@ -5424,6 +6082,18 @@ var MediaControllerRuntime = class {
|
|
|
5424
6082
|
if (!sameMediaSlice(current.media, media)) this.#store.updateMedia(media);
|
|
5425
6083
|
}
|
|
5426
6084
|
};
|
|
6085
|
+
function liveTrack(track) {
|
|
6086
|
+
return track.enabled && track.readyState === "live" && !track.muted;
|
|
6087
|
+
}
|
|
6088
|
+
function livePublication(publication) {
|
|
6089
|
+
return !!publication?.enabled && liveTrack(publication.track);
|
|
6090
|
+
}
|
|
6091
|
+
function confirmedLocalMediaState(track, desired, pending, confirmed, live, convergence) {
|
|
6092
|
+
if (!desired) return convergence === "failed" ? "failed" : "disabled";
|
|
6093
|
+
if (convergence) return convergence;
|
|
6094
|
+
if (!live && track && liveTrack(track)) return "requesting";
|
|
6095
|
+
return confirmed ? "enabled" : pending ? "requesting" : "disabled";
|
|
6096
|
+
}
|
|
5427
6097
|
function cancelledCaptureError() {
|
|
5428
6098
|
return new ConnectionError({ code: "invalid_state", action: null, recoverable: false, message: "Media capture was cancelled before its result could be used" });
|
|
5429
6099
|
}
|
|
@@ -5432,57 +6102,35 @@ function foreign2(operation) {
|
|
|
5432
6102
|
}
|
|
5433
6103
|
function selectInitialTracks(stream, intent) {
|
|
5434
6104
|
const tracks = stream.getTracks();
|
|
5435
|
-
const
|
|
5436
|
-
const
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
6105
|
+
const selected = /* @__PURE__ */ new Map();
|
|
6106
|
+
for (const [source, kind] of [
|
|
6107
|
+
["microphone", "audio"],
|
|
6108
|
+
["camera", "video"]
|
|
6109
|
+
]) {
|
|
6110
|
+
if (!intent[source]) continue;
|
|
6111
|
+
const track = tracks.find((track2) => track2.kind === kind);
|
|
6112
|
+
if (track) selected.set(source, track);
|
|
6113
|
+
}
|
|
6114
|
+
const retained = new Set(selected.values());
|
|
6115
|
+
for (const track of tracks) if (!retained.has(track)) track.stop();
|
|
6116
|
+
if (selected.size !== Number(intent.microphone) + Number(intent.camera)) {
|
|
6117
|
+
for (const track of retained) track.stop();
|
|
5441
6118
|
throw new TypeError("Media capture did not return every requested track");
|
|
5442
6119
|
}
|
|
5443
|
-
return
|
|
5444
|
-
}
|
|
5445
|
-
function initialTrackFor(tracks, enabled, kind) {
|
|
5446
|
-
if (!enabled) return void 0;
|
|
5447
|
-
return tracks.find((track) => track.kind === kind);
|
|
5448
|
-
}
|
|
5449
|
-
function initialTracks(microphone, camera) {
|
|
5450
|
-
return [microphone, camera].filter((track) => track !== void 0);
|
|
5451
|
-
}
|
|
5452
|
-
function missingInitialTrack(intent, microphone, camera) {
|
|
5453
|
-
return intent.microphone && !microphone || intent.camera && !camera;
|
|
5454
|
-
}
|
|
5455
|
-
function initialTrackEntries(microphone, camera) {
|
|
5456
|
-
const entries = [];
|
|
5457
|
-
if (microphone) entries.push(["microphone", microphone]);
|
|
5458
|
-
if (camera) entries.push(["camera", camera]);
|
|
5459
|
-
return entries;
|
|
6120
|
+
return selected;
|
|
5460
6121
|
}
|
|
5461
|
-
function
|
|
5462
|
-
|
|
5463
|
-
const publication = snapshot?.localTracks.find((candidate) => candidate.source ===
|
|
5464
|
-
|
|
5465
|
-
if (locallyMuted(intended, track, publication)) return Object.freeze({ source, state: "disabled", track });
|
|
5466
|
-
if (publication?.enabled) return Object.freeze({ source, state: "enabled", track });
|
|
5467
|
-
if (publication && connectionState === "live") return Object.freeze({ source, state: "disabled", track });
|
|
5468
|
-
return Object.freeze({ source, state: localMediaState(source, track, intended, connectionState), track });
|
|
6122
|
+
function screenLocalMedia(track, snapshot, connectionState, pending) {
|
|
6123
|
+
if (snapshot?.connection.phase === "stopped") return Object.freeze({ source: "screen", state: "unavailable", track: null });
|
|
6124
|
+
const publication = snapshot?.localTracks.find((candidate) => candidate.source === "screen");
|
|
6125
|
+
return Object.freeze({ source: "screen", state: pending ? "requesting" : screenLocalMediaState(track, publication?.enabled, connectionState), track });
|
|
5469
6126
|
}
|
|
5470
|
-
function
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
if (
|
|
5475
|
-
if (
|
|
5476
|
-
return
|
|
5477
|
-
}
|
|
5478
|
-
function mediaIsDesired(source, track, intended) {
|
|
5479
|
-
return source === "screen" ? track !== null : intended;
|
|
5480
|
-
}
|
|
5481
|
-
function inactiveLocalMediaState(connectionState, track) {
|
|
5482
|
-
return connectionState === "live" || connectionState === "reconnecting" || track ? "disabled" : "unavailable";
|
|
5483
|
-
}
|
|
5484
|
-
function activeLocalMediaState(connectionState) {
|
|
5485
|
-
return connectionState === "failed" || connectionState === "live" || connectionState === "reconnecting" ? "failed" : "unavailable";
|
|
6127
|
+
function screenLocalMediaState(track, enabled, connectionState) {
|
|
6128
|
+
if (enabled === false) {
|
|
6129
|
+
if (track?.enabled === false || connectionState === "live") return "disabled";
|
|
6130
|
+
}
|
|
6131
|
+
if (enabled) return "enabled";
|
|
6132
|
+
if (track) return "requesting";
|
|
6133
|
+
return connectionState === "live" || connectionState === "reconnecting" ? "disabled" : "unavailable";
|
|
5486
6134
|
}
|
|
5487
6135
|
function sameMediaSlice(left, right) {
|
|
5488
6136
|
if (left.selection !== right.selection || !sameDevices(left.devices, right.devices)) return false;
|
|
@@ -6489,7 +7137,7 @@ var SpaceClientCore = class {
|
|
|
6489
7137
|
return this.#connection.leave().pipe(Effect12.mapError(normalizeClientError));
|
|
6490
7138
|
}
|
|
6491
7139
|
endEpisode() {
|
|
6492
|
-
return this.#connection.runCommand(({ sync }) => foreign6(() => sync.endEpisode())
|
|
7140
|
+
return this.#connection.runCommand(({ sync }) => this.#connection.runEpisodeEnd(foreign6(() => sync.endEpisode()))).pipe(Effect12.tap(() => this.#connection.leave())).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
|
|
6493
7141
|
}
|
|
6494
7142
|
extendEpisode(minutes) {
|
|
6495
7143
|
return this.#connection.runCommand(({ sync }) => foreign6(() => sync.extendEpisode(minutes))).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
|
|
@@ -6565,7 +7213,14 @@ function foreign6(operation) {
|
|
|
6565
7213
|
}
|
|
6566
7214
|
async function fetchAccessGrant2(input) {
|
|
6567
7215
|
if (input.platform.connectionAccess) return input.platform.connectionAccess(input.request);
|
|
6568
|
-
return input.options.getAccess({
|
|
7216
|
+
return input.options.getAccess({
|
|
7217
|
+
space: input.options.space.trim(),
|
|
7218
|
+
reason: accessReason(input.request?.reason),
|
|
7219
|
+
replaceMediaConnection: input.request?.replaceMediaConnection ?? false,
|
|
7220
|
+
expectedMediaConnectionId: input.request?.expectedMediaConnectionId,
|
|
7221
|
+
currentMediaToken: input.request?.replaceMediaConnection ? input.request.currentMediaToken : void 0,
|
|
7222
|
+
signal: input.request?.signal
|
|
7223
|
+
});
|
|
6569
7224
|
}
|
|
6570
7225
|
function accessReason(reason) {
|
|
6571
7226
|
if (reason === void 0 || reason === "join") return "join";
|