@q9labsai/chalk-client 4.1.15 → 4.1.17
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 +100 -5
- package/dist/access/grant.d.ts.map +1 -1
- package/dist/access/manager.d.ts.map +1 -1
- package/dist/{chunk-PQNSA7ZN.js → chunk-2DZUHV6I.js} +782 -122
- package/dist/{chunk-6ONSFCIY.js → chunk-A7BVZEFM.js} +7 -3
- package/dist/{chunk-LQSXEWLL.js → chunk-AUQTQOLR.js} +2685 -511
- package/dist/{chunk-PWUU6T3R.js → chunk-CZUJNAUI.js} +1196 -874
- package/dist/{chunk-NKOKY2AE.js → chunk-EWTITNJ3.js} +15 -101
- package/dist/chunk-HW5TTZL2.js +99 -0
- package/dist/chunk-RCDXIDCN.js +33 -0
- package/dist/{chunk-LSDDADCN.js → chunk-ZRMGKMX4.js} +1728 -517
- package/dist/client.d.ts +1452 -465
- package/dist/client.d.ts.map +1 -1
- package/dist/connection/dependencies.d.ts +11 -0
- package/dist/connection/dependencies.d.ts.map +1 -1
- package/dist/connection/diagnostics.d.ts +1 -1
- package/dist/connection/diagnostics.d.ts.map +1 -1
- package/dist/connection/index.d.ts +3 -0
- package/dist/connection/index.d.ts.map +1 -1
- package/dist/connection/lifecycle.d.ts +4 -1
- package/dist/connection/lifecycle.d.ts.map +1 -1
- package/dist/connection/media-devices.d.ts.map +1 -1
- package/dist/connection/production.d.ts +4 -1
- package/dist/connection/production.d.ts.map +1 -1
- package/dist/connection/types.d.ts +2 -2
- package/dist/connection/types.d.ts.map +1 -1
- package/dist/effect.js +355 -130
- package/dist/feedback/collector.d.ts +14 -0
- package/dist/feedback/collector.d.ts.map +1 -0
- package/dist/feedback/context.d.ts +38 -0
- package/dist/feedback/context.d.ts.map +1 -0
- package/dist/feedback/controller.d.ts +4 -0
- package/dist/feedback/controller.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +7 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/transport.d.ts +29 -0
- package/dist/feedback/transport.d.ts.map +1 -0
- package/dist/feedback/types.d.ts +202 -0
- package/dist/feedback/types.d.ts.map +1 -0
- package/dist/feedback/validation.d.ts +30 -0
- package/dist/feedback/validation.d.ts.map +1 -0
- package/dist/foreground-deadline.d.ts +9 -0
- package/dist/foreground-deadline.d.ts.map +1 -0
- package/dist/generated/http-api.d.ts +1438 -461
- package/dist/generated/http-api.d.ts.map +1 -1
- package/dist/generated/schemas.d.ts +2667 -842
- package/dist/generated/schemas.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +136 -21
- package/dist/media/camera-uplink.d.ts +13 -0
- package/dist/media/camera-uplink.d.ts.map +1 -0
- package/dist/media/capture-error.d.ts +27 -0
- package/dist/media/capture-error.d.ts.map +1 -0
- package/dist/media/client.d.ts +4 -0
- package/dist/media/client.d.ts.map +1 -1
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +5 -1
- package/dist/media/plane.d.ts +4 -0
- package/dist/media/plane.d.ts.map +1 -1
- package/dist/media/remote-track-response.d.ts +6 -0
- package/dist/media/remote-track-response.d.ts.map +1 -0
- package/dist/media/rtk.d.ts.map +1 -1
- package/dist/media/transport.d.ts.map +1 -1
- package/dist/media/types.d.ts +20 -2
- package/dist/media/types.d.ts.map +1 -1
- package/dist/public-invites/client.d.ts.map +1 -1
- package/dist/public-invites/index.js +4 -3
- package/dist/public-invites/types.d.ts +4 -2
- package/dist/public-invites/types.d.ts.map +1 -1
- package/dist/server/access-refresh.d.cts +13 -0
- package/dist/server/access-refresh.d.ts +14 -0
- package/dist/server/access-refresh.d.ts.map +1 -0
- package/dist/server/client.d.ts.map +1 -1
- package/dist/server/index.cjs +189 -23
- package/dist/server/index.d.cts +2 -1
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +187 -22
- package/dist/server/types.d.cts +76 -1
- package/dist/server/types.d.ts +76 -1
- package/dist/server/types.d.ts.map +1 -1
- package/dist/space-client/chat-controller.d.ts +3 -0
- package/dist/space-client/chat-controller.d.ts.map +1 -1
- package/dist/space-client/controller-effects.d.ts +6 -0
- package/dist/space-client/controller-effects.d.ts.map +1 -1
- package/dist/space-client/core.d.ts +12 -1
- package/dist/space-client/core.d.ts.map +1 -1
- package/dist/space-client/core.test.helpers.d.ts +2 -1
- package/dist/space-client/core.test.helpers.d.ts.map +1 -1
- package/dist/space-client/effect.d.ts +5 -0
- package/dist/space-client/effect.d.ts.map +1 -1
- package/dist/space-client/episode-diagnostic-runtime.d.ts +3 -0
- package/dist/space-client/episode-diagnostic-runtime.d.ts.map +1 -1
- package/dist/space-client/errors.d.ts.map +1 -1
- package/dist/space-client/index.d.ts +4 -1
- package/dist/space-client/index.d.ts.map +1 -1
- package/dist/space-client/media-controller.d.ts.map +1 -1
- package/dist/space-client/offline-actions-storage.d.ts +66 -0
- package/dist/space-client/offline-actions-storage.d.ts.map +1 -0
- package/dist/space-client/offline-actions.d.ts +36 -0
- package/dist/space-client/offline-actions.d.ts.map +1 -0
- package/dist/space-client/participants-controller.d.ts +2 -1
- package/dist/space-client/participants-controller.d.ts.map +1 -1
- package/dist/space-client/recording-controller.d.ts +20 -0
- package/dist/space-client/recording-controller.d.ts.map +1 -0
- package/dist/space-client/reload-rejoin.d.ts +24 -0
- package/dist/space-client/reload-rejoin.d.ts.map +1 -0
- package/dist/space-client/space-client.d.ts.map +1 -1
- package/dist/space-client/store.d.ts +3 -1
- package/dist/space-client/store.d.ts.map +1 -1
- package/dist/space-client/terminal.test.helpers.d.ts +115 -0
- package/dist/space-client/terminal.test.helpers.d.ts.map +1 -0
- package/dist/space-client/types.d.ts +42 -4
- package/dist/space-client/types.d.ts.map +1 -1
- package/dist/sync/index.js +2 -1
- package/dist/sync/v1-client.d.ts +2 -5
- package/dist/sync/v1-client.d.ts.map +1 -1
- package/dist/sync/v1-collaboration-state.d.ts +65 -0
- package/dist/sync/v1-collaboration-state.d.ts.map +1 -0
- package/dist/sync/v1-command-scheduler.d.ts +54 -0
- package/dist/sync/v1-command-scheduler.d.ts.map +1 -0
- package/dist/sync/v1-deferred.d.ts +8 -0
- package/dist/sync/v1-deferred.d.ts.map +1 -0
- package/dist/sync/v1-error.d.ts +5 -0
- package/dist/sync/v1-error.d.ts.map +1 -0
- package/dist/sync/v1-frame-signature.d.ts +2 -0
- package/dist/sync/v1-frame-signature.d.ts.map +1 -0
- package/dist/sync/v1-live-target-coordinator.d.ts +32 -0
- package/dist/sync/v1-live-target-coordinator.d.ts.map +1 -0
- package/dist/sync/v1-types.d.ts +3 -0
- package/dist/sync/v1-types.d.ts.map +1 -1
- package/dist/telemetry/index.js +9 -7
- package/dist/telemetry/reconnect.d.ts +7 -0
- package/dist/telemetry/reconnect.d.ts.map +1 -0
- package/dist/telemetry/trace.d.ts +1 -1
- package/dist/telemetry/trace.d.ts.map +1 -1
- package/dist/webhooks/generated/event-v1.d.cts +10 -2
- package/dist/webhooks/generated/event-v1.d.ts +10 -2
- package/dist/webhooks/generated/event-v1.d.ts.map +1 -1
- package/dist/webhooks/generated/fixtures-v1.d.cts +2 -2
- package/dist/webhooks/generated/fixtures-v1.d.ts +2 -2
- package/dist/webhooks/index.cjs +10 -2
- package/dist/webhooks/index.js +10 -2
- package/dist/webhooks/test.cjs +2 -2
- package/dist/webhooks/test.js +2 -2
- package/dist/whiteboard/v1-client.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,26 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AccessGrantError,
|
|
3
|
+
requireParsedAccessGrant,
|
|
4
|
+
validEpisodeDiagnosticCredential
|
|
5
|
+
} from "./chunk-ZRMGKMX4.js";
|
|
1
6
|
import {
|
|
2
7
|
CloudflareRTKClient,
|
|
3
8
|
CloudflareSFUClient,
|
|
4
|
-
createCloudflareSFUHTTPTransport
|
|
5
|
-
|
|
9
|
+
createCloudflareSFUHTTPTransport,
|
|
10
|
+
describeMediaCaptureError
|
|
11
|
+
} from "./chunk-2DZUHV6I.js";
|
|
6
12
|
import {
|
|
13
|
+
ChatAttachmentMimeTypeSchema,
|
|
14
|
+
V1SyncError,
|
|
7
15
|
createBrowserSyncLifecycle,
|
|
8
16
|
createBrowserWebSocketFactory,
|
|
9
17
|
createV1SyncClient
|
|
10
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-CZUJNAUI.js";
|
|
11
19
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
recordInitialConnection,
|
|
21
|
+
recordReconnect
|
|
22
|
+
} from "./chunk-RCDXIDCN.js";
|
|
23
|
+
import {
|
|
24
|
+
normalizeTelemetryAttributes
|
|
25
|
+
} from "./chunk-EWTITNJ3.js";
|
|
16
26
|
import {
|
|
17
27
|
createTraceContext,
|
|
28
|
+
journeyHeaders,
|
|
18
29
|
parseTraceparent
|
|
19
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-HW5TTZL2.js";
|
|
20
31
|
|
|
21
32
|
// src/access/manager.ts
|
|
22
33
|
import { Clock, Context, Data, Effect, Layer, SynchronizedRef } from "effect";
|
|
23
34
|
var DEFAULT_REFRESH_WINDOW_MS = 6e4;
|
|
35
|
+
var ACCESS_PROVIDER_TIMEOUT_MS = 7e3;
|
|
24
36
|
var ConnectionAccessFailure = class extends Data.TaggedError("ConnectionAccessFailure") {
|
|
25
37
|
};
|
|
26
38
|
var ConnectionAccessService = class extends Context.Service()("@chalk/client/ConnectionAccess") {
|
|
@@ -34,7 +46,7 @@ var makeConnectionAccessLayer = (provider, refreshWindowMs = DEFAULT_REFRESH_WIN
|
|
|
34
46
|
Effect.tap((result) => Effect.sync(() => notifyAutomaticMediaReplacement(automaticMediaReplacementListeners, result))),
|
|
35
47
|
Effect.map((result) => result.access)
|
|
36
48
|
);
|
|
37
|
-
const initialize = (reason = "join") => fetch(reason, false, false).pipe(Effect.catchTag("ConnectionAccessFailure", (
|
|
49
|
+
const initialize = (reason = "join") => fetch(reason, false, false).pipe(Effect.catchTag("ConnectionAccessFailure", (failure3) => failure3.code === "access.invalid" && SynchronizedRef.getUnsafe(state).access === null ? fetch(reason, false, true) : Effect.fail(failure3)));
|
|
38
50
|
const millisecondsUntilRefresh = Effect.gen(function* () {
|
|
39
51
|
const access = yield* SynchronizedRef.get(state);
|
|
40
52
|
if (!access.access) return null;
|
|
@@ -70,7 +82,10 @@ function fetchAccessGrant(provider, input) {
|
|
|
70
82
|
}
|
|
71
83
|
const mediaExpired = currentAccess !== null && expiresAt(currentAccess.media.expiresAt) <= now;
|
|
72
84
|
const shouldReplaceMediaConnection = input.replaceMediaConnection || mediaExpired;
|
|
73
|
-
const next = yield* provider(connectionAccessRequest(input, shouldReplaceMediaConnection))
|
|
85
|
+
const next = yield* provider(connectionAccessRequest(input, shouldReplaceMediaConnection)).pipe(
|
|
86
|
+
Effect.timeout(ACCESS_PROVIDER_TIMEOUT_MS),
|
|
87
|
+
Effect.mapError((cause) => cause instanceof ConnectionAccessFailure ? cause : new ConnectionAccessFailure({ code: "access.unavailable", cause }))
|
|
88
|
+
);
|
|
74
89
|
yield* Effect.try({
|
|
75
90
|
try: () => validateFetchedAccess(currentAccess, next, now, shouldReplaceMediaConnection),
|
|
76
91
|
catch: (cause) => new ConnectionAccessFailure({ code: "access.invalid", cause })
|
|
@@ -139,6 +154,20 @@ var ConnectionPlatformService = class extends Context2.Service()("@chalk/client/
|
|
|
139
154
|
var makeConnectionPlatformLayer = (dependencies) => Layer2.succeed(ConnectionPlatformService, dependencies);
|
|
140
155
|
var makeFakeConnectionPlatformLayer = makeConnectionPlatformLayer;
|
|
141
156
|
|
|
157
|
+
// src/whiteboard/types.ts
|
|
158
|
+
var ChalkWhiteboardV1Error = class extends Error {
|
|
159
|
+
operation;
|
|
160
|
+
code;
|
|
161
|
+
recoverable;
|
|
162
|
+
constructor(failure3, options) {
|
|
163
|
+
super(failure3.message, options);
|
|
164
|
+
this.name = "ChalkWhiteboardV1Error";
|
|
165
|
+
this.operation = failure3.operation;
|
|
166
|
+
this.code = failure3.code;
|
|
167
|
+
this.recoverable = failure3.recoverable;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
142
171
|
// src/collaboration/types.ts
|
|
143
172
|
var CHALK_CHAT_ATTACHMENT_LIMITS = {
|
|
144
173
|
maximumPerMessage: 5,
|
|
@@ -168,12 +197,12 @@ var ChalkChatFileError = class extends Error {
|
|
|
168
197
|
operation;
|
|
169
198
|
code;
|
|
170
199
|
recoverable;
|
|
171
|
-
constructor(
|
|
172
|
-
super(
|
|
200
|
+
constructor(failure3, options) {
|
|
201
|
+
super(failure3.message, options);
|
|
173
202
|
this.name = "ChalkChatFileError";
|
|
174
|
-
this.operation =
|
|
175
|
-
this.code =
|
|
176
|
-
this.recoverable =
|
|
203
|
+
this.operation = failure3.operation;
|
|
204
|
+
this.code = failure3.code;
|
|
205
|
+
this.recoverable = failure3.recoverable;
|
|
177
206
|
}
|
|
178
207
|
};
|
|
179
208
|
|
|
@@ -286,20 +315,6 @@ function isAllowedMimeType(value) {
|
|
|
286
315
|
return typeof value === "string" && CHALK_CHAT_ATTACHMENT_MIME_TYPES.includes(value);
|
|
287
316
|
}
|
|
288
317
|
|
|
289
|
-
// src/whiteboard/types.ts
|
|
290
|
-
var ChalkWhiteboardV1Error = class extends Error {
|
|
291
|
-
operation;
|
|
292
|
-
code;
|
|
293
|
-
recoverable;
|
|
294
|
-
constructor(failure2, options) {
|
|
295
|
-
super(failure2.message, options);
|
|
296
|
-
this.name = "ChalkWhiteboardV1Error";
|
|
297
|
-
this.operation = failure2.operation;
|
|
298
|
-
this.code = failure2.code;
|
|
299
|
-
this.recoverable = failure2.recoverable;
|
|
300
|
-
}
|
|
301
|
-
};
|
|
302
|
-
|
|
303
318
|
// src/whiteboard/file-http-transport.ts
|
|
304
319
|
function createChalkWhiteboardV1FileHttpTransport(options) {
|
|
305
320
|
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
@@ -1028,7 +1043,7 @@ var ChalkWhiteboardV1Client = class {
|
|
|
1028
1043
|
return () => this.#summaryListeners.delete(listener);
|
|
1029
1044
|
}
|
|
1030
1045
|
submitUpdate(input) {
|
|
1031
|
-
this.#assertLive("submit_update");
|
|
1046
|
+
if (!this.#started || !this.#sceneId) this.#assertLive("submit_update");
|
|
1032
1047
|
if (!this.#canDraw || !this.#capabilities.includes("drawWhiteboard")) {
|
|
1033
1048
|
throw error(failure("submit_update", "permission_denied", false, "Whiteboard draw permission is required."));
|
|
1034
1049
|
}
|
|
@@ -1042,7 +1057,7 @@ var ChalkWhiteboardV1Client = class {
|
|
|
1042
1057
|
});
|
|
1043
1058
|
}
|
|
1044
1059
|
sendCursor(input) {
|
|
1045
|
-
if (this.#phase !== "live" || !this.#canDraw) return;
|
|
1060
|
+
if (this.#phase !== "live" || this.#summaryStatus !== "ready" || this.#snapshots.size > 0 || !this.#canDraw) return;
|
|
1046
1061
|
const now = this.#now();
|
|
1047
1062
|
if (now - this.#lastCursorAt < 1e3 / WhiteboardV1ProtocolLimits.cursorRatePerSecond) return;
|
|
1048
1063
|
const frame = { type: "cursor", x: input.x, y: input.y };
|
|
@@ -1289,6 +1304,8 @@ var ChalkWhiteboardV1Client = class {
|
|
|
1289
1304
|
void this.#requestSnapshot().catch(() => void 0);
|
|
1290
1305
|
}
|
|
1291
1306
|
#requestSnapshot() {
|
|
1307
|
+
const active2 = this.#snapshots.values().next().value;
|
|
1308
|
+
if (active2) return active2.promise;
|
|
1292
1309
|
const requestId = this.#nextId();
|
|
1293
1310
|
const frame = { type: "request_snapshot", request_id: requestId };
|
|
1294
1311
|
encodeWhiteboardV1ClientFrame(frame);
|
|
@@ -1296,7 +1313,7 @@ var ChalkWhiteboardV1Client = class {
|
|
|
1296
1313
|
const promise = new Promise((resolve, reject) => {
|
|
1297
1314
|
deferred = { resolve, reject, settled: false };
|
|
1298
1315
|
});
|
|
1299
|
-
this.#snapshots.set(requestId, { requestId, deferred, pages: /* @__PURE__ */ new Map() });
|
|
1316
|
+
this.#snapshots.set(requestId, { requestId, deferred, promise, pages: /* @__PURE__ */ new Map() });
|
|
1300
1317
|
this.#send(frame);
|
|
1301
1318
|
return promise;
|
|
1302
1319
|
}
|
|
@@ -1328,6 +1345,7 @@ var ChalkWhiteboardV1Client = class {
|
|
|
1328
1345
|
this.#sceneId = frame.scene_id;
|
|
1329
1346
|
this.#revision = frame.revision;
|
|
1330
1347
|
this.#publishSummary("ready", null);
|
|
1348
|
+
this.#flushQueuedOperations();
|
|
1331
1349
|
this.#emit({
|
|
1332
1350
|
type: "snapshot",
|
|
1333
1351
|
sceneId: frame.scene_id,
|
|
@@ -1375,7 +1393,10 @@ var ChalkWhiteboardV1Client = class {
|
|
|
1375
1393
|
deferred = { resolve, reject, settled: false };
|
|
1376
1394
|
});
|
|
1377
1395
|
this.#operations.set(frame.operation_id, { pending, deferred, retries: 0 });
|
|
1378
|
-
this.#
|
|
1396
|
+
if (this.#phase === "live" && this.#snapshots.size === 0) {
|
|
1397
|
+
if (this.#waitingForOperations) this.#awaitingOperationIds.add(frame.operation_id);
|
|
1398
|
+
this.#sendOperation(frame);
|
|
1399
|
+
}
|
|
1379
1400
|
return promise;
|
|
1380
1401
|
}
|
|
1381
1402
|
#commit(frame) {
|
|
@@ -1423,6 +1444,13 @@ var ChalkWhiteboardV1Client = class {
|
|
|
1423
1444
|
rejectDeferred(this.#initialSnapshot, snapshotError);
|
|
1424
1445
|
this.#initialSnapshot = null;
|
|
1425
1446
|
}
|
|
1447
|
+
if (frame.recoverable) this.#flushQueuedOperations();
|
|
1448
|
+
}
|
|
1449
|
+
#flushQueuedOperations() {
|
|
1450
|
+
if (this.#phase !== "live" || this.#snapshots.size > 0) return;
|
|
1451
|
+
for (const entry of this.#operations.values()) {
|
|
1452
|
+
if (!this.#awaitingOperationIds.has(entry.pending.operationId)) this.#sendOperation(entry.pending.frame);
|
|
1453
|
+
}
|
|
1426
1454
|
}
|
|
1427
1455
|
#scheduleOperationRetry(operationId, entry) {
|
|
1428
1456
|
if (this.#operationRetryTimers.has(operationId)) return;
|
|
@@ -1462,8 +1490,6 @@ var ChalkWhiteboardV1Client = class {
|
|
|
1462
1490
|
this.#phase = "connecting";
|
|
1463
1491
|
this.#waitingForOperations = false;
|
|
1464
1492
|
this.#participantId = null;
|
|
1465
|
-
this.#capabilities = [];
|
|
1466
|
-
this.#canDraw = false;
|
|
1467
1493
|
this.#rejectSnapshots(failure("request_snapshot", "unavailable", true, "Whiteboard connection interrupted."));
|
|
1468
1494
|
this.#publishSummary("recovering", failure("start_scene_subscription", "unavailable", true, "Whiteboard connection interrupted."));
|
|
1469
1495
|
}
|
|
@@ -1701,15 +1727,29 @@ function createChalkWhiteboardV1Client(options) {
|
|
|
1701
1727
|
});
|
|
1702
1728
|
}
|
|
1703
1729
|
|
|
1730
|
+
// src/connection/types.ts
|
|
1731
|
+
var ConnectionError = class extends Error {
|
|
1732
|
+
code;
|
|
1733
|
+
action;
|
|
1734
|
+
recoverable;
|
|
1735
|
+
constructor(failure3, options) {
|
|
1736
|
+
super(failure3.message, options);
|
|
1737
|
+
this.name = "ConnectionError";
|
|
1738
|
+
this.code = failure3.code;
|
|
1739
|
+
this.action = failure3.action;
|
|
1740
|
+
this.recoverable = failure3.recoverable;
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1743
|
+
|
|
1704
1744
|
// src/connection/media-devices.ts
|
|
1705
1745
|
function createBrowserMediaDevices(mediaDevices = globalThis.navigator?.mediaDevices) {
|
|
1706
1746
|
return {
|
|
1707
1747
|
async getUserMedia(constraints) {
|
|
1708
|
-
if (!mediaDevices?.getUserMedia) throw new
|
|
1748
|
+
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." });
|
|
1709
1749
|
return mediaDevices.getUserMedia(constraints);
|
|
1710
1750
|
},
|
|
1711
1751
|
async getDisplayMedia(constraints) {
|
|
1712
|
-
if (!mediaDevices?.getDisplayMedia) throw new
|
|
1752
|
+
if (!mediaDevices?.getDisplayMedia) throw new DOMException("Browser display capture is unavailable", "NotSupportedError");
|
|
1713
1753
|
return mediaDevices.getDisplayMedia(constraints);
|
|
1714
1754
|
},
|
|
1715
1755
|
async enumerateDevices() {
|
|
@@ -1802,12 +1842,14 @@ function createMediaClient(apiBaseURL, input) {
|
|
|
1802
1842
|
} : void 0,
|
|
1803
1843
|
transport: createCloudflareSFUHTTPTransport({
|
|
1804
1844
|
apiBaseURL,
|
|
1845
|
+
telemetry: input.telemetry,
|
|
1805
1846
|
credential: input.credential,
|
|
1806
1847
|
tenantId: subject.tenantId,
|
|
1807
1848
|
spaceId: subject.spaceId,
|
|
1808
1849
|
episodeId: subject.episodeId,
|
|
1809
1850
|
participantId: subject.participantId
|
|
1810
1851
|
}),
|
|
1852
|
+
recordReconnect: input.recordReconnect,
|
|
1811
1853
|
onError: input.onFailure,
|
|
1812
1854
|
...input.recordRtcSummary ? { onRtcSummary: input.recordRtcSummary } : {},
|
|
1813
1855
|
onScreenEnded: input.onScreenEnded
|
|
@@ -1832,8 +1874,12 @@ function createSFUConnectionMediaClient(client) {
|
|
|
1832
1874
|
function bindConnectionMediaClient(client, restart) {
|
|
1833
1875
|
return {
|
|
1834
1876
|
setLocalPublicationTarget: client.setLocalPublicationTarget.bind(client),
|
|
1877
|
+
...client.setLocalSourceIntent ? { setLocalSourceIntent: client.setLocalSourceIntent.bind(client) } : {},
|
|
1878
|
+
...client.closeForcedLocalPublication ? { closeForcedLocalPublication: client.closeForcedLocalPublication.bind(client) } : {},
|
|
1835
1879
|
observeLocalPublications: client.observeLocalPublications.bind(client),
|
|
1836
1880
|
observeRemotePublications: client.observeRemotePublications.bind(client),
|
|
1881
|
+
...client.remotePublicationsChanged ? { remotePublicationsChanged: client.remotePublicationsChanged.bind(client) } : {},
|
|
1882
|
+
...client.remotePublicationResumed ? { remotePublicationResumed: client.remotePublicationResumed.bind(client) } : {},
|
|
1837
1883
|
start: client.start.bind(client),
|
|
1838
1884
|
stop: client.stop.bind(client),
|
|
1839
1885
|
restart,
|
|
@@ -1849,6 +1895,7 @@ function createSyncClient(syncURL, input) {
|
|
|
1849
1895
|
token: input.token,
|
|
1850
1896
|
mediaPlane: input.media,
|
|
1851
1897
|
telemetry: input.telemetry,
|
|
1898
|
+
recordReconnect: input.recordReconnect,
|
|
1852
1899
|
persistenceScope: `${input.access.subject.tenantId}:${input.access.subject.episodeId}:${input.access.subject.participantId}`
|
|
1853
1900
|
});
|
|
1854
1901
|
}
|
|
@@ -1880,6 +1927,26 @@ function whiteboardURL(syncURL) {
|
|
|
1880
1927
|
return url.toString();
|
|
1881
1928
|
}
|
|
1882
1929
|
|
|
1930
|
+
// src/foreground-deadline.ts
|
|
1931
|
+
function scheduleForegroundDeadline(clock, milliseconds, expired) {
|
|
1932
|
+
let timer;
|
|
1933
|
+
let cancelled = false;
|
|
1934
|
+
const arm = () => {
|
|
1935
|
+
const started = clock.now();
|
|
1936
|
+
timer = clock.setTimeout(() => {
|
|
1937
|
+
if (cancelled) return;
|
|
1938
|
+
const elapsed = clock.now() - started;
|
|
1939
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden" || elapsed > milliseconds + 2e3 || elapsed < 0) arm();
|
|
1940
|
+
else expired();
|
|
1941
|
+
}, milliseconds);
|
|
1942
|
+
};
|
|
1943
|
+
arm();
|
|
1944
|
+
return () => {
|
|
1945
|
+
cancelled = true;
|
|
1946
|
+
clock.clearTimeout(timer);
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1883
1950
|
// src/connection/lifecycle.ts
|
|
1884
1951
|
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";
|
|
1885
1952
|
|
|
@@ -1936,26 +2003,15 @@ var ConnectionDiagnostics = class {
|
|
|
1936
2003
|
}
|
|
1937
2004
|
};
|
|
1938
2005
|
|
|
1939
|
-
// src/connection/types.ts
|
|
1940
|
-
var ConnectionError = class extends Error {
|
|
1941
|
-
code;
|
|
1942
|
-
action;
|
|
1943
|
-
recoverable;
|
|
1944
|
-
constructor(failure2, options) {
|
|
1945
|
-
super(failure2.message, options);
|
|
1946
|
-
this.name = "ConnectionError";
|
|
1947
|
-
this.code = failure2.code;
|
|
1948
|
-
this.action = failure2.action;
|
|
1949
|
-
this.recoverable = failure2.recoverable;
|
|
1950
|
-
}
|
|
1951
|
-
};
|
|
1952
|
-
|
|
1953
2006
|
// src/connection/lifecycle.ts
|
|
2007
|
+
var MEDIA_RECOVERY_TIMEOUT_MS = 6e4;
|
|
1954
2008
|
var START_TIMEOUT_MS = 1e4;
|
|
1955
2009
|
var LEAVE_TIMEOUT_MS = 5e3;
|
|
1956
2010
|
var RECOVERY_BUDGET_MS = 1e4;
|
|
1957
2011
|
var MAX_RECOVERY_ATTEMPTS = 3;
|
|
1958
2012
|
var REFRESH_RETRY_MS = 5e3;
|
|
2013
|
+
var RECONNECT_GRACE_MS = 250;
|
|
2014
|
+
var BACKGROUND_RETRY_MAX_MS = 6e4;
|
|
1959
2015
|
var ConnectionLifecycleFailure = class extends Data2.TaggedError("ConnectionLifecycleFailure") {
|
|
1960
2016
|
};
|
|
1961
2017
|
var ConnectionLifecycleService = class extends Context3.Service()("@chalk/client/ConnectionLifecycle") {
|
|
@@ -1965,8 +2021,8 @@ var makeConnectionLifecycleLayer = (options) => {
|
|
|
1965
2021
|
const platform = { ...defaults, ...options.dependencies };
|
|
1966
2022
|
const access = makeConnectionAccessLayer(
|
|
1967
2023
|
(request) => Effect2.tryPromise({
|
|
1968
|
-
try: () => Promise.resolve(options.access(request)),
|
|
1969
|
-
catch: (cause) => new ConnectionAccessFailure({ code: "access.unavailable", cause })
|
|
2024
|
+
try: (signal) => Promise.resolve(options.access(request ? { ...request, signal } : void 0)),
|
|
2025
|
+
catch: (cause) => cause instanceof ConnectionAccessFailure ? cause : new ConnectionAccessFailure({ code: accessRejected(cause) ? "access.invalid" : "access.unavailable", cause })
|
|
1970
2026
|
}),
|
|
1971
2027
|
options.accessRefreshWindowMs
|
|
1972
2028
|
);
|
|
@@ -2001,32 +2057,65 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2001
2057
|
let joinDeferred = null;
|
|
2002
2058
|
let activeJoin = null;
|
|
2003
2059
|
let fallbackIdentifier = 0;
|
|
2060
|
+
let emittedPorts = null;
|
|
2061
|
+
let foregroundRecoveryDeadline = 0;
|
|
2004
2062
|
const model = {
|
|
2005
2063
|
state: "idle",
|
|
2006
2064
|
sync: null,
|
|
2007
2065
|
media: null,
|
|
2008
2066
|
syncSnapshot: null,
|
|
2067
|
+
syncNoticeUnresponsive: false,
|
|
2009
2068
|
mediaSnapshot: null,
|
|
2010
2069
|
failure: null,
|
|
2011
2070
|
initialMedia: (intent) => Effect2.tryPromise({
|
|
2012
2071
|
try: () => intent.microphone || intent.camera ? platform.mediaDevices.getUserMedia({ audio: intent.microphone, video: intent.camera }) : Promise.resolve(streamFromTracks([])),
|
|
2013
2072
|
catch: (cause) => cause
|
|
2014
2073
|
}),
|
|
2074
|
+
initialMediaIntent: null,
|
|
2015
2075
|
intent: { microphone: options.initialMicrophoneEnabled ?? true, camera: options.initialCameraEnabled ?? true },
|
|
2016
2076
|
episodeEndConfirmed: false,
|
|
2017
2077
|
activeScope: null,
|
|
2018
2078
|
syncBindingCleanup: null,
|
|
2019
2079
|
mediaBindingCleanup: null,
|
|
2020
2080
|
refreshFiber: null,
|
|
2081
|
+
refreshFailures: 0,
|
|
2082
|
+
refreshInFlight: false,
|
|
2021
2083
|
recoveryQueued: null,
|
|
2022
2084
|
joinCancelled: false,
|
|
2023
2085
|
closed: false,
|
|
2024
2086
|
epoch: 0
|
|
2025
2087
|
};
|
|
2026
|
-
const
|
|
2088
|
+
const mediaAttempt = { expiresAt: null };
|
|
2089
|
+
const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => lifecycleFailure(code, true, message, cause) });
|
|
2090
|
+
const withinForegroundBudget = (effect, milliseconds, failure3) => Effect2.raceFirst(
|
|
2091
|
+
effect,
|
|
2092
|
+
Effect2.callback((resume) => {
|
|
2093
|
+
const cancel = scheduleForegroundDeadline(platform.clock, milliseconds, () => resume(Effect2.fail(failure3)));
|
|
2094
|
+
return Effect2.sync(cancel);
|
|
2095
|
+
})
|
|
2096
|
+
);
|
|
2027
2097
|
const toPromise = (effect) => Effect2.runPromiseWith(context)(effect);
|
|
2028
|
-
const publish = () => Effect2.sync(() => snapshotFor(model, access.currentUnsafe())).pipe(
|
|
2029
|
-
Effect2.tap((snapshot) => SubscriptionRef.set(snapshotRef, snapshot)),
|
|
2098
|
+
const publish = () => Effect2.sync(() => ({ previous: SubscriptionRef.getUnsafe(snapshotRef), snapshot: snapshotFor(model, access.currentUnsafe()) })).pipe(
|
|
2099
|
+
Effect2.tap(({ snapshot }) => SubscriptionRef.set(snapshotRef, snapshot)),
|
|
2100
|
+
Effect2.tap(
|
|
2101
|
+
({ previous, snapshot }) => Effect2.sync(() => {
|
|
2102
|
+
const ports = portsFor(model);
|
|
2103
|
+
if (![
|
|
2104
|
+
ports?.sync === emittedPorts?.sync,
|
|
2105
|
+
ports?.media === emittedPorts?.media,
|
|
2106
|
+
["healthy", "unresponsive"].includes(previous.connection.sync) === ["healthy", "unresponsive"].includes(snapshot.connection.sync),
|
|
2107
|
+
previous.connection.media === "healthy" === (snapshot.connection.media === "healthy")
|
|
2108
|
+
].every(Boolean)) {
|
|
2109
|
+
emittedPorts = ports;
|
|
2110
|
+
for (const listener of portListeners) {
|
|
2111
|
+
try {
|
|
2112
|
+
listener(ports);
|
|
2113
|
+
} catch {
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
})
|
|
2118
|
+
),
|
|
2030
2119
|
Effect2.asVoid,
|
|
2031
2120
|
Effect2.tap(
|
|
2032
2121
|
() => Effect2.sync(() => {
|
|
@@ -2039,15 +2128,6 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2039
2128
|
})
|
|
2040
2129
|
)
|
|
2041
2130
|
);
|
|
2042
|
-
const emitPorts = () => Effect2.sync(() => {
|
|
2043
|
-
const ports = portsFor(model);
|
|
2044
|
-
for (const listener of portListeners) {
|
|
2045
|
-
try {
|
|
2046
|
-
listener(ports);
|
|
2047
|
-
} catch {
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
});
|
|
2051
2131
|
const transition = (state) => Effect2.gen(function* () {
|
|
2052
2132
|
if (model.state === state) return;
|
|
2053
2133
|
model.state = state;
|
|
@@ -2077,36 +2157,68 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2077
2157
|
model.refreshFiber = yield* Effect2.forkIn(Effect2.sleep(Math.max(0, milliseconds)).pipe(Effect2.andThen(enqueue(refreshAccess())), Effect2.ignore), scope);
|
|
2078
2158
|
});
|
|
2079
2159
|
const refreshAccess = () => Effect2.gen(function* () {
|
|
2080
|
-
|
|
2160
|
+
const scope = model.activeScope;
|
|
2161
|
+
if (!active(model) || !scope || model.refreshInFlight) return;
|
|
2081
2162
|
const epoch = model.epoch;
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2163
|
+
model.refreshInFlight = true;
|
|
2164
|
+
yield* Effect2.forkIn(
|
|
2165
|
+
access.ensureFresh("scheduled_refresh").pipe(
|
|
2166
|
+
Effect2.match({ onSuccess: () => null, onFailure: (cause) => cause }),
|
|
2167
|
+
Effect2.flatMap(
|
|
2168
|
+
(failure3) => enqueue(
|
|
2169
|
+
Effect2.gen(function* () {
|
|
2170
|
+
if (model.epoch !== epoch || !active(model)) return;
|
|
2171
|
+
model.refreshInFlight = false;
|
|
2172
|
+
if (failure3) {
|
|
2173
|
+
diagnostics.record({ event: "access_refresh_failed", state: model.state, epoch, code: failure3.code === "access.invalid" ? "invalid_access" : "access_unavailable" });
|
|
2174
|
+
if (failure3.code === "access.invalid") return yield* failForSnapshot(accessFailure(failure3));
|
|
2175
|
+
model.refreshFailures = Math.min(model.refreshFailures + 1, 5);
|
|
2176
|
+
yield* scheduleRefresh(Math.min(BACKGROUND_RETRY_MAX_MS, REFRESH_RETRY_MS * 2 ** (model.refreshFailures - 1)));
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
model.refreshFailures = 0;
|
|
2180
|
+
diagnostics.record({ event: "access_refreshed", state: model.state, epoch });
|
|
2181
|
+
yield* publish();
|
|
2182
|
+
yield* scheduleRefresh();
|
|
2183
|
+
})
|
|
2184
|
+
)
|
|
2185
|
+
),
|
|
2186
|
+
Effect2.ignore
|
|
2187
|
+
),
|
|
2188
|
+
scope
|
|
2189
|
+
);
|
|
2091
2190
|
});
|
|
2092
2191
|
const stopPorts = () => Effect2.gen(function* () {
|
|
2093
2192
|
yield* stopRefresh();
|
|
2094
2193
|
const scope = model.activeScope;
|
|
2095
2194
|
model.activeScope = null;
|
|
2195
|
+
model.refreshFailures = 0;
|
|
2196
|
+
model.refreshInFlight = false;
|
|
2197
|
+
mediaAttempt.expiresAt = null;
|
|
2096
2198
|
model.sync = null;
|
|
2097
2199
|
model.media = null;
|
|
2098
2200
|
model.syncSnapshot = null;
|
|
2201
|
+
model.syncNoticeUnresponsive = false;
|
|
2099
2202
|
model.mediaSnapshot = null;
|
|
2100
|
-
|
|
2203
|
+
model.recoveryQueued = null;
|
|
2204
|
+
yield* publish();
|
|
2101
2205
|
if (scope) yield* Scope.close(scope, Exit.void);
|
|
2102
2206
|
yield* access.clear;
|
|
2103
2207
|
yield* publish();
|
|
2104
2208
|
});
|
|
2105
2209
|
const bindSync = (scope, sync) => Effect2.gen(function* () {
|
|
2106
2210
|
model.syncBindingCleanup?.();
|
|
2211
|
+
model.syncNoticeUnresponsive = false;
|
|
2107
2212
|
let latestSnapshot = null;
|
|
2108
2213
|
let snapshotQueued = false;
|
|
2109
2214
|
const unsubscribe = sync.subscribe((snapshot) => {
|
|
2215
|
+
if (sync === model.sync && snapshot.connection.phase === "live") {
|
|
2216
|
+
const notice = snapshot.connection.noticeUnresponsive === true;
|
|
2217
|
+
if (notice !== model.syncNoticeUnresponsive) {
|
|
2218
|
+
model.syncNoticeUnresponsive = notice;
|
|
2219
|
+
void Effect2.runForkWith(context)(publish());
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2110
2222
|
latestSnapshot = snapshot;
|
|
2111
2223
|
if (snapshotQueued) return;
|
|
2112
2224
|
snapshotQueued = true;
|
|
@@ -2152,22 +2264,19 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2152
2264
|
yield* handleMediaSnapshot(media, media.getSnapshot());
|
|
2153
2265
|
});
|
|
2154
2266
|
const waitForSyncLive = (sync, timeoutMs) => Effect2.callback((resume) => {
|
|
2155
|
-
|
|
2156
|
-
resume(Effect2.
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2267
|
+
const observe = (snapshot) => {
|
|
2268
|
+
if (episodeEnded(snapshot)) resume(Effect2.fail(lifecycleFailure("episode_ended", false, "The Episode has ended")));
|
|
2269
|
+
else if (snapshot.connection.phase === "live") resume(Effect2.void);
|
|
2270
|
+
else if (snapshot.connection.phase === "terminal") resume(Effect2.fail(syncTerminalFailure(snapshot)));
|
|
2271
|
+
else if (snapshot.connection.phase === "stopped") resume(Effect2.fail(lifecycleFailure("sync_start_failed", true, "Sync stopped before becoming live")));
|
|
2272
|
+
};
|
|
2273
|
+
const unsubscribe = sync.subscribe(observe);
|
|
2274
|
+
observe(sync.getSnapshot());
|
|
2163
2275
|
return Effect2.sync(unsubscribe);
|
|
2164
|
-
}).pipe(
|
|
2165
|
-
Effect2.timeout(timeoutMs),
|
|
2166
|
-
Effect2.catchTag("TimeoutError", () => Effect2.fail(lifecycleFailure("sync_start_failed", true, "Sync did not become live before the startup deadline")))
|
|
2167
|
-
);
|
|
2276
|
+
}).pipe((effect) => withinForegroundBudget(effect, timeoutMs, lifecycleFailure("sync_start_failed", true, "Sync did not become live before the startup deadline")));
|
|
2168
2277
|
const trace = (step, effect) => {
|
|
2169
2278
|
const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
|
|
2170
|
-
return effect.pipe(
|
|
2279
|
+
return traceRecovery(step, effect, recordInitialConnection).pipe(
|
|
2171
2280
|
Effect2.tap(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "succeeded" }))),
|
|
2172
2281
|
Effect2.tapError(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "failed" })))
|
|
2173
2282
|
);
|
|
@@ -2183,9 +2292,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2183
2292
|
const scope = yield* Scope.make("sequential");
|
|
2184
2293
|
model.activeScope = scope;
|
|
2185
2294
|
let stream = null;
|
|
2295
|
+
let captureWarning = null;
|
|
2186
2296
|
yield* Effect2.gen(function* () {
|
|
2187
2297
|
if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
|
|
2188
|
-
|
|
2298
|
+
const captured = yield* trace("acquire_initial_media", acquireInitialMedia(model.initialMedia, model.initialMediaIntent?.() ?? model.intent));
|
|
2299
|
+
stream = captured.stream;
|
|
2300
|
+
captureWarning = captured.failure;
|
|
2189
2301
|
if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
|
|
2190
2302
|
const grant = yield* trace("access_initialize", access.initialize(reason));
|
|
2191
2303
|
const media = yield* trace(
|
|
@@ -2194,7 +2306,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2194
2306
|
try: () => platform.createMediaClient({
|
|
2195
2307
|
access: grant,
|
|
2196
2308
|
credential: () => toPromise(access.getMediaToken()),
|
|
2197
|
-
replaceMediaConnection: () => toPromise(access.refresh("media_recovery", true)).then((replacement) =>
|
|
2309
|
+
replaceMediaConnection: () => toPromise(access.refresh("media_recovery", true)).then((replacement) => {
|
|
2310
|
+
if (media === model.media) mediaAttempt.expiresAt = platform.clock.now() + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2311
|
+
return replacement.media;
|
|
2312
|
+
}),
|
|
2313
|
+
recordReconnect: options.recordReconnect,
|
|
2314
|
+
telemetry: options.telemetry,
|
|
2198
2315
|
...options.recordRtcSummary ? { recordRtcSummary: options.recordRtcSummary } : {},
|
|
2199
2316
|
onFailure: () => enqueueBackground(handleMediaFailure()),
|
|
2200
2317
|
onScreenEnded: () => enqueueBackground(notifyScreenEnded())
|
|
@@ -2206,7 +2323,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2206
2323
|
const sync = yield* trace(
|
|
2207
2324
|
"create_sync_client",
|
|
2208
2325
|
Effect2.try({
|
|
2209
|
-
try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, telemetry: options.telemetry }),
|
|
2326
|
+
try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, telemetry: options.telemetry, recordReconnect: options.recordReconnect }),
|
|
2210
2327
|
catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
|
|
2211
2328
|
})
|
|
2212
2329
|
);
|
|
@@ -2216,9 +2333,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2216
2333
|
[
|
|
2217
2334
|
trace(
|
|
2218
2335
|
"start_media",
|
|
2219
|
-
|
|
2336
|
+
foreign7(() => media.start(stream), "media_start_failed", "The media layer could not start")
|
|
2220
2337
|
),
|
|
2221
|
-
trace("start_sync",
|
|
2338
|
+
trace("start_sync", foreign7(() => sync.start(), "sync_start_failed", "The sync layer could not start").pipe(Effect2.andThen(trace("wait_for_sync_live", waitForSyncLive(sync, boundedInteger(options.syncStartupTimeoutMs, START_TIMEOUT_MS, 1, 6e4))))))
|
|
2222
2339
|
],
|
|
2223
2340
|
{ concurrency: "unbounded", discard: true }
|
|
2224
2341
|
);
|
|
@@ -2226,23 +2343,26 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2226
2343
|
Effect2.matchEffect({
|
|
2227
2344
|
onFailure: (cause) => Effect2.gen(function* () {
|
|
2228
2345
|
stopStream(stream);
|
|
2229
|
-
const
|
|
2230
|
-
model.failure = toFailure(
|
|
2346
|
+
const failure3 = failureFromCause(cause, "sync_start_failed", "The Connection could not start");
|
|
2347
|
+
model.failure = toFailure(failure3);
|
|
2231
2348
|
if (model.joinCancelled) {
|
|
2232
2349
|
span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "invalid_state" });
|
|
2233
2350
|
return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
|
|
2234
2351
|
}
|
|
2352
|
+
span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure3.code });
|
|
2353
|
+
const retry = !retried && failure3.code === "invalid_access";
|
|
2354
|
+
if (!retry) yield* recordFailure(failure3);
|
|
2235
2355
|
yield* stopPorts();
|
|
2236
|
-
if (
|
|
2356
|
+
if (retry) return yield* performJoin("access_retry", true);
|
|
2237
2357
|
yield* transition("failed");
|
|
2238
|
-
|
|
2239
|
-
return yield* Effect2.fail(failure2);
|
|
2358
|
+
return yield* Effect2.fail(failure3);
|
|
2240
2359
|
}),
|
|
2241
2360
|
onSuccess: () => Effect2.gen(function* () {
|
|
2242
2361
|
model.syncSnapshot = model.sync?.getSnapshot() ?? null;
|
|
2243
2362
|
model.mediaSnapshot = model.media?.getSnapshot() ?? null;
|
|
2363
|
+
model.failure = captureWarning ? toFailure(captureWarning) : null;
|
|
2244
2364
|
yield* transition("live");
|
|
2245
|
-
yield*
|
|
2365
|
+
if (model.mediaSnapshot?.connection.phase !== "live") yield* recover("media");
|
|
2246
2366
|
yield* scheduleRefresh();
|
|
2247
2367
|
span.end({ state: model.state, epoch: model.epoch, outcome: "succeeded" });
|
|
2248
2368
|
})
|
|
@@ -2262,43 +2382,118 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2262
2382
|
const leaveIsConfirmed = () => Effect2.gen(function* leaveIsConfirmedEffect() {
|
|
2263
2383
|
const sync = model.sync;
|
|
2264
2384
|
if (!sync || model.episodeEndConfirmed || access.currentUnsafe() === null) return true;
|
|
2265
|
-
const leave =
|
|
2385
|
+
const leave = foreign7(() => sync.leave()).pipe(Effect2.timeout(LEAVE_TIMEOUT_MS));
|
|
2266
2386
|
return (yield* Effect2.exit(leave))._tag === "Success";
|
|
2267
2387
|
});
|
|
2388
|
+
const publishFreshAccess = () => enqueue(
|
|
2389
|
+
Effect2.gen(function* () {
|
|
2390
|
+
if (!active(model)) return;
|
|
2391
|
+
diagnostics.record({ event: "access_refreshed", state: model.state, epoch: model.epoch });
|
|
2392
|
+
yield* publish();
|
|
2393
|
+
yield* scheduleRefresh();
|
|
2394
|
+
})
|
|
2395
|
+
);
|
|
2396
|
+
const requireOpen = Effect2.suspend(() => model.closed ? Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed")) : Effect2.void);
|
|
2268
2397
|
const withFreshAccess = (operation) => Effect2.gen(function* () {
|
|
2398
|
+
yield* requireOpen;
|
|
2269
2399
|
if (yield* access.requiresRefresh) {
|
|
2400
|
+
yield* requireOpen;
|
|
2270
2401
|
yield* access.ensureFresh("scheduled_refresh").pipe(Effect2.mapError(accessFailure));
|
|
2271
|
-
|
|
2272
|
-
yield* scheduleRefresh();
|
|
2402
|
+
yield* publishFreshAccess();
|
|
2273
2403
|
}
|
|
2274
|
-
|
|
2404
|
+
const run = requireOpen.pipe(
|
|
2405
|
+
Effect2.andThen(Effect2.suspend(operation)),
|
|
2406
|
+
Effect2.flatMap((value) => requireOpen.pipe(Effect2.as(value)))
|
|
2407
|
+
);
|
|
2408
|
+
return yield* run.pipe(
|
|
2275
2409
|
Effect2.matchEffect({
|
|
2276
2410
|
onSuccess: Effect2.succeed,
|
|
2277
2411
|
onFailure: (cause) => {
|
|
2278
2412
|
if (!accessRejected(cause)) return Effect2.fail(cause);
|
|
2279
|
-
return
|
|
2280
|
-
Effect2.
|
|
2281
|
-
Effect2.
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
})
|
|
2285
|
-
),
|
|
2286
|
-
Effect2.andThen(scheduleRefresh()),
|
|
2287
|
-
Effect2.andThen(operation())
|
|
2413
|
+
return requireOpen.pipe(
|
|
2414
|
+
Effect2.andThen(access.refreshAfterRejection()),
|
|
2415
|
+
Effect2.mapError((failure3) => failure3 instanceof ConnectionAccessFailure ? accessFailure(failure3) : failure3),
|
|
2416
|
+
Effect2.andThen(publishFreshAccess()),
|
|
2417
|
+
Effect2.andThen(run)
|
|
2288
2418
|
);
|
|
2289
2419
|
}
|
|
2290
2420
|
})
|
|
2291
2421
|
);
|
|
2292
2422
|
});
|
|
2293
|
-
const
|
|
2294
|
-
|
|
2295
|
-
|
|
2423
|
+
const traceRecovery = (step, operation, recordStep = recordReconnect) => Effect2.gen(function* () {
|
|
2424
|
+
const started = yield* Clock2.currentTimeMillis;
|
|
2425
|
+
recordStep(options.recordReconnect, step, { boundary: "start" });
|
|
2426
|
+
return yield* operation.pipe(
|
|
2427
|
+
Effect2.onExit(
|
|
2428
|
+
(exit) => Clock2.currentTimeMillis.pipe(
|
|
2429
|
+
Effect2.map((now) => {
|
|
2430
|
+
recordStep(options.recordReconnect, step, { boundary: "end", duration_ms: Math.max(0, now - started) }, exit._tag === "Success" ? "succeeded" : "failed");
|
|
2431
|
+
})
|
|
2432
|
+
)
|
|
2433
|
+
)
|
|
2434
|
+
);
|
|
2435
|
+
});
|
|
2436
|
+
const recover = (kind) => Effect2.gen(function* () {
|
|
2437
|
+
const scope = model.activeScope;
|
|
2438
|
+
if (!active(model) || !scope) return;
|
|
2439
|
+
if (model.recoveryQueued !== null) {
|
|
2440
|
+
model.recoveryQueued = kind;
|
|
2441
|
+
yield* publish();
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2296
2444
|
model.recoveryQueued = kind;
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
const
|
|
2300
|
-
|
|
2301
|
-
|
|
2445
|
+
const phase = kind === "sync" ? model.sync?.getSnapshot().connection.phase : model.media?.getSnapshot().connection.phase;
|
|
2446
|
+
if (phase === "terminal" || phase === "failed") yield* transition("reconnecting");
|
|
2447
|
+
const epoch = model.epoch;
|
|
2448
|
+
yield* Effect2.forkIn(
|
|
2449
|
+
Effect2.gen(function* () {
|
|
2450
|
+
yield* Effect2.sleep(RECONNECT_GRACE_MS);
|
|
2451
|
+
const phase2 = kind === "sync" ? model.sync?.getSnapshot().connection.phase : model.media?.getSnapshot().connection.phase;
|
|
2452
|
+
if (phase2 === "live") {
|
|
2453
|
+
enqueueBackground(finishRecovery(0, epoch));
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
yield* enqueue(
|
|
2457
|
+
Effect2.gen(function* () {
|
|
2458
|
+
if (model.epoch === epoch && active(model)) yield* transition("reconnecting");
|
|
2459
|
+
})
|
|
2460
|
+
);
|
|
2461
|
+
let cycle = 0;
|
|
2462
|
+
const syncAttempt = { client: null, expiresAt: null, started: true };
|
|
2463
|
+
while (model.epoch === epoch && active(model)) {
|
|
2464
|
+
const plan = yield* recoveryPlan(model.recoveryQueued ?? kind);
|
|
2465
|
+
const outcome = yield* attemptRecovery(plan, mediaAttempt, syncAttempt).pipe(
|
|
2466
|
+
Effect2.match({
|
|
2467
|
+
onSuccess: (attempt) => ({ attempt, failure: null }),
|
|
2468
|
+
onFailure: (failure3) => ({ attempt: null, failure: failure3 })
|
|
2469
|
+
})
|
|
2470
|
+
);
|
|
2471
|
+
if (outcome.failure) {
|
|
2472
|
+
const failure3 = outcome.failure;
|
|
2473
|
+
enqueueBackground(Effect2.suspend(() => model.epoch === epoch && active(model) ? failForSnapshot(failure3) : Effect2.void));
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
if (outcome.attempt !== null) {
|
|
2477
|
+
enqueueBackground(finishRecovery(outcome.attempt, epoch));
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
yield* enqueue(
|
|
2481
|
+
Effect2.gen(function* () {
|
|
2482
|
+
if (model.epoch !== epoch || !active(model)) return;
|
|
2483
|
+
const code = plan.kind === "media" ? "media_recovery_exhausted" : "sync_recovery_exhausted";
|
|
2484
|
+
model.failure = toFailure(lifecycleFailure(code, true, "Connection interrupted. Reconnecting in the background."));
|
|
2485
|
+
diagnostics.record({ event: "recovery_exhausted", state: model.state, epoch, code });
|
|
2486
|
+
yield* publish();
|
|
2487
|
+
})
|
|
2488
|
+
);
|
|
2489
|
+
if (model.recoveryQueued !== plan.kind) continue;
|
|
2490
|
+
const delay = Math.min(BACKGROUND_RETRY_MAX_MS, 250 * 2 ** cycle);
|
|
2491
|
+
cycle = Math.min(cycle + 1, 8);
|
|
2492
|
+
yield* Effect2.race(Effect2.sleep(delay * (0.5 + Math.random() * 0.5)), waitForTransportChange(plan.kind));
|
|
2493
|
+
}
|
|
2494
|
+
}),
|
|
2495
|
+
scope
|
|
2496
|
+
);
|
|
2302
2497
|
});
|
|
2303
2498
|
const recoveryPlan = (kind) => Clock2.currentTimeMillis.pipe(
|
|
2304
2499
|
Effect2.map((now) => ({
|
|
@@ -2308,75 +2503,216 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2308
2503
|
delays: options.recovery?.backoffMs?.length ? options.recovery.backoffMs : [100, 250, 500]
|
|
2309
2504
|
}))
|
|
2310
2505
|
);
|
|
2311
|
-
const attemptRecovery = (plan) => Effect2.gen(function*
|
|
2506
|
+
const attemptRecovery = (plan, mediaAttempt2, syncAttempt) => Effect2.gen(function* () {
|
|
2312
2507
|
for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
|
|
2313
2508
|
const remaining = plan.deadline - (yield* Clock2.currentTimeMillis);
|
|
2314
2509
|
if (remaining <= 0) return null;
|
|
2510
|
+
recordReconnect(options.recordReconnect, "attempt", { recovery_kind: plan.kind, attempt });
|
|
2315
2511
|
diagnostics.record({ event: "recovery_attempt", state: model.state, epoch: model.epoch, attempt });
|
|
2316
|
-
const
|
|
2317
|
-
|
|
2318
|
-
|
|
2512
|
+
const failure3 = yield* recoveryOperation(mediaAttempt2, syncAttempt).pipe(
|
|
2513
|
+
Effect2.timeout(remaining),
|
|
2514
|
+
Effect2.catchTag("TimeoutError", () => Effect2.fail(lifecycleFailure("sync_start_failed", true, "Connection recovery is still waiting for the transport"))),
|
|
2515
|
+
Effect2.match({ onSuccess: () => null, onFailure: (cause) => cause })
|
|
2516
|
+
);
|
|
2517
|
+
if (failure3 === null) return attempt;
|
|
2518
|
+
if (!failure3.recoverable) return yield* Effect2.fail(failure3);
|
|
2519
|
+
if (attempt < plan.attempts) {
|
|
2520
|
+
const remaining2 = Math.max(0, plan.deadline - (yield* Clock2.currentTimeMillis));
|
|
2521
|
+
yield* traceRecovery("lifecycle_backoff", Effect2.sleep(Math.min(recoveryDelay(plan, attempt), remaining2)));
|
|
2522
|
+
}
|
|
2319
2523
|
}
|
|
2320
2524
|
return null;
|
|
2321
2525
|
});
|
|
2322
|
-
const recoveryOperation = (
|
|
2323
|
-
|
|
2324
|
-
|
|
2526
|
+
const recoveryOperation = (mediaAttempt2, syncAttempt) => Effect2.gen(function* () {
|
|
2527
|
+
const results = yield* Effect2.all(
|
|
2528
|
+
[recoverSync(syncAttempt), recoverMedia(mediaAttempt2)].map((effect) => effect.pipe(Effect2.matchEffect({ onSuccess: () => Effect2.succeed(null), onFailure: (failure4) => failure4.recoverable ? Effect2.succeed(failure4) : Effect2.fail(failure4) }))),
|
|
2529
|
+
{ concurrency: "unbounded" }
|
|
2530
|
+
);
|
|
2531
|
+
const failure3 = results.find((failure4) => failure4 !== null);
|
|
2532
|
+
if (failure3) return yield* Effect2.fail(failure3);
|
|
2533
|
+
});
|
|
2534
|
+
const recoveryDelay = (plan, attempt) => {
|
|
2535
|
+
const delay = plan.delays[Math.min(attempt - 1, plan.delays.length - 1)] ?? 0;
|
|
2536
|
+
return Number.isFinite(delay) ? Math.max(0, delay) : 0;
|
|
2537
|
+
};
|
|
2538
|
+
const finishRecovery = (attempt, epoch) => Effect2.gen(function* () {
|
|
2539
|
+
if (model.epoch !== epoch || !active(model)) return;
|
|
2325
2540
|
model.recoveryQueued = null;
|
|
2326
|
-
|
|
2541
|
+
model.syncSnapshot = model.sync?.getSnapshot() ?? null;
|
|
2542
|
+
model.mediaSnapshot = model.media?.getSnapshot() ?? null;
|
|
2543
|
+
model.failure = null;
|
|
2544
|
+
if (model.syncSnapshot?.connection.phase !== "live") return yield* recover("sync");
|
|
2545
|
+
if (model.mediaSnapshot?.connection.phase !== "live") return yield* recover("media");
|
|
2546
|
+
mediaAttempt.expiresAt = null;
|
|
2547
|
+
diagnostics.record({ event: "recovery_succeeded", state: model.state, epoch, attempt });
|
|
2327
2548
|
yield* transition("live");
|
|
2549
|
+
yield* publish();
|
|
2328
2550
|
yield* scheduleRefresh();
|
|
2329
2551
|
});
|
|
2330
|
-
const
|
|
2331
|
-
const
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2552
|
+
const waitForTransportChange = (kind) => Effect2.callback((resume) => {
|
|
2553
|
+
const sync = model.sync;
|
|
2554
|
+
const media = model.media;
|
|
2555
|
+
const syncPhase2 = sync?.getSnapshot().connection.phase;
|
|
2556
|
+
const mediaPhase2 = media?.getSnapshot().connection.phase;
|
|
2557
|
+
const observe = () => {
|
|
2558
|
+
if (model.recoveryQueued !== kind || model.sync !== sync || model.media !== media || model.sync?.getSnapshot().connection.phase !== syncPhase2 || model.media?.getSnapshot().connection.phase !== mediaPhase2 || syncPhase2 === "live" && mediaPhase2 === "live") resume(Effect2.void);
|
|
2559
|
+
};
|
|
2560
|
+
listeners.add(observe);
|
|
2561
|
+
const unsubscribeSync = sync?.subscribe(observe);
|
|
2562
|
+
const unsubscribeMedia = media?.subscribe(observe);
|
|
2563
|
+
observe();
|
|
2564
|
+
return Effect2.sync(() => {
|
|
2565
|
+
listeners.delete(observe);
|
|
2566
|
+
unsubscribeSync?.();
|
|
2567
|
+
unsubscribeMedia?.();
|
|
2568
|
+
});
|
|
2337
2569
|
});
|
|
2338
|
-
const
|
|
2570
|
+
const waitForMediaLive = (media) => Effect2.callback((resume) => {
|
|
2571
|
+
const observe = () => {
|
|
2572
|
+
const snapshot = media.getSnapshot();
|
|
2573
|
+
if (snapshot.connection.phase === "live") resume(Effect2.void);
|
|
2574
|
+
else if (snapshot.connection.phase === "failed") resume(Effect2.fail(lifecycleFailure("media_start_failed", snapshot.failure?.recoverable ?? true, "Media transport needs recovery")));
|
|
2575
|
+
};
|
|
2576
|
+
const unsubscribe = media.subscribe(observe);
|
|
2577
|
+
observe();
|
|
2578
|
+
return Effect2.sync(unsubscribe);
|
|
2579
|
+
});
|
|
2580
|
+
const recoverMedia = (attempt) => Effect2.gen(function* () {
|
|
2339
2581
|
const media = model.media;
|
|
2340
|
-
|
|
2341
|
-
if (
|
|
2342
|
-
|
|
2582
|
+
if (!media) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Media recovery requires active ports"));
|
|
2583
|
+
if (media.getSnapshot().connection.phase === "live") {
|
|
2584
|
+
attempt.expiresAt = null;
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
if (media.getSnapshot().connection.phase === "recovering") {
|
|
2588
|
+
const now = yield* Clock2.currentTimeMillis;
|
|
2589
|
+
attempt.expiresAt ??= now + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2590
|
+
attempt.expiresAt = Math.max(attempt.expiresAt, foregroundRecoveryDeadline);
|
|
2591
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2592
|
+
if (now < attempt.expiresAt) return yield* waitForMediaLive(media);
|
|
2593
|
+
}
|
|
2594
|
+
recordReconnect(options.recordReconnect, "media_decision", { strategy: "full_rebuild" });
|
|
2595
|
+
const grant = yield* traceRecovery("access_refresh", access.refresh("media_recovery", true).pipe(Effect2.mapError(accessFailure)));
|
|
2343
2596
|
const restartInput = grant.media.provider === "cloudflare_sfu" ? grant.media.clientPayload : grant.media;
|
|
2344
|
-
yield*
|
|
2345
|
-
|
|
2597
|
+
attempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2598
|
+
yield* traceRecovery(
|
|
2599
|
+
"media_rebuild",
|
|
2600
|
+
foreign7(() => media.restart(restartInput), "media_start_failed", "Media transport could not restart")
|
|
2601
|
+
);
|
|
2602
|
+
yield* waitForMediaLive(media);
|
|
2603
|
+
});
|
|
2604
|
+
const syncAttemptExpired = (attempt, sync, now) => {
|
|
2605
|
+
if (attempt.client !== sync) {
|
|
2606
|
+
attempt.client = sync;
|
|
2607
|
+
attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2608
|
+
attempt.started = true;
|
|
2609
|
+
}
|
|
2610
|
+
attempt.expiresAt = Math.max(attempt.expiresAt ?? 0, foregroundRecoveryDeadline);
|
|
2611
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2612
|
+
return now >= attempt.expiresAt && sync.getSnapshot().connection.phase !== "live";
|
|
2613
|
+
};
|
|
2614
|
+
const syncNeedsReplacement = (snapshot) => {
|
|
2615
|
+
if (snapshot.connection.phase === "idle" || snapshot.connection.phase === "stopped") return Effect2.succeed(true);
|
|
2616
|
+
if (snapshot.connection.phase !== "terminal") return Effect2.succeed(false);
|
|
2617
|
+
const failure3 = syncTerminalFailure(snapshot);
|
|
2618
|
+
return failure3.recoverable ? Effect2.succeed(true) : Effect2.fail(failure3);
|
|
2619
|
+
};
|
|
2620
|
+
const recoverSync = (attempt) => Effect2.gen(function* () {
|
|
2621
|
+
const sync = model.sync;
|
|
2622
|
+
if (!sync) return yield* restartSync(attempt);
|
|
2623
|
+
const snapshot = sync.getSnapshot();
|
|
2624
|
+
const now = yield* Clock2.currentTimeMillis;
|
|
2625
|
+
if (syncAttemptExpired(attempt, sync, now)) {
|
|
2626
|
+
if (!attempt.started || !sync.restartTransport) return yield* restartSync(attempt);
|
|
2627
|
+
sync.restartTransport();
|
|
2628
|
+
attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2629
|
+
}
|
|
2630
|
+
if ((attempt.started || snapshot.connection.phase !== "idle") && (yield* syncNeedsReplacement(snapshot))) return yield* restartSync(attempt);
|
|
2346
2631
|
yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4));
|
|
2347
2632
|
});
|
|
2348
|
-
const
|
|
2349
|
-
const
|
|
2350
|
-
const scope = model.activeScope;
|
|
2351
|
-
const grant = access.currentUnsafe();
|
|
2352
|
-
if (!media || !scope || !grant) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Sync recovery requires active ports"));
|
|
2633
|
+
const restartSync = (attempt) => Effect2.gen(function* () {
|
|
2634
|
+
const epoch = model.epoch;
|
|
2353
2635
|
yield* access.getSyncToken("sync_recovery").pipe(Effect2.mapError(accessFailure));
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2636
|
+
const sync = yield* enqueue(
|
|
2637
|
+
Effect2.gen(function* () {
|
|
2638
|
+
const media = model.media;
|
|
2639
|
+
const scope = model.activeScope;
|
|
2640
|
+
const grant = access.currentUnsafe();
|
|
2641
|
+
if (model.epoch !== epoch || !active(model) || !media || !scope || !grant) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Sync recovery requires active ports"));
|
|
2642
|
+
model.syncBindingCleanup?.();
|
|
2643
|
+
model.sync = null;
|
|
2644
|
+
const sync2 = yield* Effect2.try({
|
|
2645
|
+
try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, telemetry: options.telemetry, recordReconnect: options.recordReconnect }),
|
|
2646
|
+
catch: (cause) => lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
|
|
2647
|
+
});
|
|
2648
|
+
model.sync = sync2;
|
|
2649
|
+
yield* bindSync(scope, sync2);
|
|
2650
|
+
return sync2;
|
|
2651
|
+
})
|
|
2652
|
+
);
|
|
2653
|
+
attempt.client = sync;
|
|
2654
|
+
attempt.started = false;
|
|
2655
|
+
attempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2656
|
+
yield* foreign7(
|
|
2657
|
+
() => sync.start().then(() => {
|
|
2658
|
+
if (sync !== model.sync || model.epoch !== epoch) sync.stop();
|
|
2659
|
+
else if (attempt.client === sync) attempt.started = true;
|
|
2660
|
+
})
|
|
2661
|
+
).pipe(
|
|
2662
|
+
Effect2.tapError(
|
|
2663
|
+
() => enqueue(
|
|
2664
|
+
Effect2.gen(function* () {
|
|
2665
|
+
if (sync !== model.sync) return;
|
|
2666
|
+
model.syncBindingCleanup?.();
|
|
2667
|
+
model.sync = null;
|
|
2668
|
+
model.syncSnapshot = null;
|
|
2669
|
+
yield* publish();
|
|
2670
|
+
})
|
|
2671
|
+
)
|
|
2672
|
+
)
|
|
2673
|
+
);
|
|
2363
2674
|
yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4));
|
|
2364
|
-
yield* emitPorts();
|
|
2365
2675
|
});
|
|
2366
2676
|
const handleSyncSnapshot = (sync, snapshot) => Effect2.gen(function* handleSyncSnapshotEffect() {
|
|
2367
2677
|
if (!isCurrentSyncSnapshot(sync, snapshot)) return;
|
|
2368
2678
|
model.syncSnapshot = snapshot;
|
|
2369
2679
|
if (syncSubjectMismatched(access.currentUnsafe()?.subject ?? null, snapshot)) return yield* failForSnapshot(lifecycleFailure("invalid_access", false, "Sync authenticated a different participant subject"));
|
|
2370
|
-
if (
|
|
2680
|
+
if (yield* settleTerminalSnapshot(snapshot)) return;
|
|
2371
2681
|
if (syncNeedsRecovery(snapshot)) yield* recover("sync");
|
|
2372
2682
|
yield* publish();
|
|
2373
2683
|
});
|
|
2684
|
+
const settleTerminalSnapshot = (snapshot) => Effect2.gen(function* () {
|
|
2685
|
+
const ended = episodeEnded(snapshot);
|
|
2686
|
+
if (ended || snapshot.connection.terminalReason === "participant_inactive") {
|
|
2687
|
+
if (ended) model.episodeEndConfirmed = true;
|
|
2688
|
+
yield* finishTerminalSnapshot();
|
|
2689
|
+
return true;
|
|
2690
|
+
}
|
|
2691
|
+
if (snapshot.connection.phase !== "terminal") return false;
|
|
2692
|
+
const failure3 = syncTerminalFailure(snapshot);
|
|
2693
|
+
if (failure3.recoverable) return false;
|
|
2694
|
+
yield* failForSnapshot(failure3);
|
|
2695
|
+
return true;
|
|
2696
|
+
});
|
|
2374
2697
|
const isCurrentSyncSnapshot = (sync, snapshot) => sync === model.sync && snapshot !== model.syncSnapshot;
|
|
2375
2698
|
const syncSubjectMismatched = (subject, snapshot) => subject !== null && snapshot.participantId !== null && (subject.participantId !== snapshot.participantId || subject.participantGeneration !== snapshot.participantGeneration);
|
|
2376
|
-
const episodeEnded = (snapshot) => snapshot.control?.status === "ended" || snapshot.
|
|
2377
|
-
const
|
|
2378
|
-
|
|
2379
|
-
|
|
2699
|
+
const episodeEnded = (snapshot) => snapshot.control?.status === "ended" || snapshot.connection.terminalReason === "episode_ended";
|
|
2700
|
+
const finishTerminalSnapshot = () => Effect2.gen(function* () {
|
|
2701
|
+
model.failure = null;
|
|
2702
|
+
yield* transition("leaving");
|
|
2703
|
+
yield* stopPorts();
|
|
2704
|
+
yield* transition("left");
|
|
2705
|
+
});
|
|
2706
|
+
const syncNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "connecting" || snapshot.connection.phase === "recovering" || snapshot.connection.phase === "terminal");
|
|
2707
|
+
const recordFailure = (failure3) => Effect2.sync(() => {
|
|
2708
|
+
const reason = model.sync?.getSnapshot().connection.terminalReason;
|
|
2709
|
+
const terminalReason = reason === "participant_inactive" || reason === "stale_participant_generation" || reason === "episode_ended" ? reason : failure3.code;
|
|
2710
|
+
diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure3.code });
|
|
2711
|
+
recordReconnect(options.recordReconnect, "terminal_failure", { code: failure3.code, terminal_reason: terminalReason }, "failed");
|
|
2712
|
+
});
|
|
2713
|
+
const failForSnapshot = (failure3) => Effect2.gen(function* failForSnapshotEffect() {
|
|
2714
|
+
model.failure = toFailure(failure3);
|
|
2715
|
+
yield* recordFailure(failure3);
|
|
2380
2716
|
yield* stopPorts();
|
|
2381
2717
|
yield* transition("failed");
|
|
2382
2718
|
});
|
|
@@ -2384,21 +2720,43 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2384
2720
|
if (media !== model.media || media.getSnapshot() !== snapshot) return;
|
|
2385
2721
|
if (snapshot === model.mediaSnapshot) return;
|
|
2386
2722
|
model.mediaSnapshot = snapshot;
|
|
2387
|
-
if (active(model) && snapshot.connection.phase === "failed" && snapshot.failure?.recoverable) yield* recover("media");
|
|
2723
|
+
if (active(model) && (snapshot.connection.phase === "recovering" || snapshot.connection.phase === "failed" && snapshot.failure?.recoverable)) yield* recover("media");
|
|
2388
2724
|
yield* publish();
|
|
2389
2725
|
});
|
|
2390
2726
|
const handleMediaFailure = () => model.media ? handleMediaSnapshot(model.media, model.media.getSnapshot()) : Effect2.void;
|
|
2391
2727
|
const applyAutomaticMediaReplacement = (grant) => Effect2.gen(function* () {
|
|
2392
2728
|
const media = model.media;
|
|
2393
|
-
|
|
2729
|
+
const scope = model.activeScope;
|
|
2730
|
+
const epoch = model.epoch;
|
|
2731
|
+
if (!media || !scope || !active(model)) return;
|
|
2394
2732
|
const restartInput = grant.media.provider === "cloudflare_sfu" ? grant.media.clientPayload : grant.media;
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2733
|
+
mediaAttempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2734
|
+
yield* Effect2.forkIn(
|
|
2735
|
+
foreign7(
|
|
2736
|
+
() => media.restart(restartInput).then(() => {
|
|
2737
|
+
if (media !== model.media || model.epoch !== epoch) media.stop();
|
|
2738
|
+
}),
|
|
2739
|
+
"media_start_failed",
|
|
2740
|
+
"The media client could not apply a replacement access grant"
|
|
2741
|
+
).pipe(
|
|
2742
|
+
(effect) => withinForegroundBudget(effect, MEDIA_RECOVERY_TIMEOUT_MS, lifecycleFailure("media_start_failed", true, "Automatic media replacement did not finish")),
|
|
2743
|
+
Effect2.match({ onSuccess: () => true, onFailure: () => false }),
|
|
2744
|
+
Effect2.flatMap(
|
|
2745
|
+
(restarted) => enqueue(
|
|
2746
|
+
Effect2.gen(function* () {
|
|
2747
|
+
if (model.epoch !== epoch || media !== model.media || !active(model)) return;
|
|
2748
|
+
if (!restarted) {
|
|
2749
|
+
yield* recover("media");
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
model.mediaSnapshot = media.getSnapshot();
|
|
2753
|
+
yield* publish();
|
|
2754
|
+
})
|
|
2755
|
+
)
|
|
2756
|
+
)
|
|
2757
|
+
),
|
|
2758
|
+
scope
|
|
2759
|
+
);
|
|
2402
2760
|
});
|
|
2403
2761
|
const notifyScreenEnded = () => Effect2.sync(() => {
|
|
2404
2762
|
if (!active(model)) return;
|
|
@@ -2419,7 +2777,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2419
2777
|
}) : Deferred.complete(work.deferred, work.effect).pipe(Effect2.asVoid);
|
|
2420
2778
|
yield* Effect2.forkScoped(Queue.take(queue).pipe(Effect2.flatMap(process), Effect2.forever));
|
|
2421
2779
|
const accessReplacementUnsubscribe = yield* access.subscribeAutomaticMediaReplacement((grant) => enqueueBackground(applyAutomaticMediaReplacement(grant)));
|
|
2422
|
-
const foregroundUnsubscribe = platform.subscribeForeground?.(() =>
|
|
2780
|
+
const foregroundUnsubscribe = platform.subscribeForeground?.(() => {
|
|
2781
|
+
foregroundRecoveryDeadline = platform.clock.now() + MEDIA_RECOVERY_TIMEOUT_MS;
|
|
2782
|
+
enqueueBackground(refreshAccess());
|
|
2783
|
+
}) ?? null;
|
|
2423
2784
|
yield* Effect2.addFinalizer(
|
|
2424
2785
|
() => Effect2.gen(function* () {
|
|
2425
2786
|
model.closed = true;
|
|
@@ -2448,8 +2809,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2448
2809
|
},
|
|
2449
2810
|
getDiagnostics: () => diagnostics.snapshot(),
|
|
2450
2811
|
getJoinTrace: () => diagnostics.joinTrace(),
|
|
2451
|
-
setInitialMedia: (provider) => Effect2.sync(() => {
|
|
2812
|
+
setInitialMedia: (provider, intent) => Effect2.sync(() => {
|
|
2452
2813
|
model.initialMedia = provider;
|
|
2814
|
+
model.initialMediaIntent = intent ?? null;
|
|
2453
2815
|
}),
|
|
2454
2816
|
configureJoin: (intent) => Effect2.sync(() => {
|
|
2455
2817
|
if (model.state !== "idle" && model.state !== "left" && model.state !== "failed") throw lifecycleFailure("invalid_state", false, `Cannot configure Join while ${model.state}`);
|
|
@@ -2479,13 +2841,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
|
|
|
2479
2841
|
runCommand: (operation) => enqueue(
|
|
2480
2842
|
Effect2.gen(function* () {
|
|
2481
2843
|
const ports = portsFor(model);
|
|
2482
|
-
if (model.
|
|
2483
|
-
|
|
2484
|
-
if (model.state !== "live") return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The command belongs to an inactive Connection"));
|
|
2485
|
-
return value;
|
|
2844
|
+
if (!active(model) || !ports || ports.sync.getSnapshot().connection.phase !== "live") return yield* Effect2.fail(lifecycleFailure("invalid_state", true, "Connection interrupted. Try again when reconnected."));
|
|
2845
|
+
return ports;
|
|
2486
2846
|
})
|
|
2847
|
+
).pipe(
|
|
2848
|
+
Effect2.flatMap(
|
|
2849
|
+
(ports) => withFreshAccess(
|
|
2850
|
+
() => Effect2.suspend(() => {
|
|
2851
|
+
if (!active(model) || ports.sync !== model.sync) return Effect2.fail(lifecycleFailure("invalid_state", true, "Connection interrupted. Try again when reconnected."));
|
|
2852
|
+
return operation(ports).pipe(Effect2.flatMap((value) => active(model) && ports.sync === model.sync ? Effect2.succeed(value) : Effect2.fail(lifecycleFailure("invalid_state", true, "The command belongs to an inactive Connection"))));
|
|
2853
|
+
})
|
|
2854
|
+
)
|
|
2855
|
+
)
|
|
2487
2856
|
),
|
|
2488
|
-
runPortCommand: (operation) =>
|
|
2857
|
+
runPortCommand: (operation) => withFreshAccess(operation),
|
|
2489
2858
|
nowUnsafe: () => clock.currentTimeMillisUnsafe(),
|
|
2490
2859
|
scheduleUnsafe: (callback, milliseconds) => platform.clock.setTimeout(callback, milliseconds),
|
|
2491
2860
|
cancelScheduleUnsafe: (handle) => platform.clock.clearTimeout(handle),
|
|
@@ -2502,8 +2871,9 @@ function snapshotFor(model, access) {
|
|
|
2502
2871
|
state: model.state,
|
|
2503
2872
|
subject: subjectFor(access),
|
|
2504
2873
|
episode: episodeFor(access, control),
|
|
2505
|
-
connection: Object.freeze({ sync: syncPhase(model.syncSnapshot?.connection.phase), media: mediaPhase(model.mediaSnapshot?.connection.phase) }),
|
|
2506
|
-
failure: model.failure ? Object.freeze({ ...model.failure }) : null
|
|
2874
|
+
connection: Object.freeze({ sync: model.syncSnapshot?.connection.phase === "live" && model.syncNoticeUnresponsive ? "unresponsive" : syncPhase(model.syncSnapshot?.connection.phase), media: mediaPhase(model.mediaSnapshot?.connection.phase) }),
|
|
2875
|
+
failure: model.failure ? Object.freeze({ ...model.failure }) : null,
|
|
2876
|
+
episodeEnded: model.episodeEndConfirmed
|
|
2507
2877
|
});
|
|
2508
2878
|
}
|
|
2509
2879
|
function subjectFor(access) {
|
|
@@ -2522,7 +2892,7 @@ function portsFor(model) {
|
|
|
2522
2892
|
return model.sync && model.media ? { sync: model.sync, media: model.media } : null;
|
|
2523
2893
|
}
|
|
2524
2894
|
function active(model) {
|
|
2525
|
-
return model.state === "live" || model.state === "reconnecting";
|
|
2895
|
+
return !model.closed && (model.state === "live" || model.state === "reconnecting");
|
|
2526
2896
|
}
|
|
2527
2897
|
function lifecycleFailure(code, recoverable, message, cause) {
|
|
2528
2898
|
return new ConnectionLifecycleFailure({ code, recoverable, message, ...cause === void 0 ? {} : { cause } });
|
|
@@ -2531,11 +2901,60 @@ function toFailure(value) {
|
|
|
2531
2901
|
return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message });
|
|
2532
2902
|
}
|
|
2533
2903
|
function accessFailure(value) {
|
|
2534
|
-
return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", "Access was rejected", value.cause);
|
|
2904
|
+
return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", value.code === "access.invalid" ? "Access was rejected" : "Could not reach the Space. Check your network and try again.", value.cause);
|
|
2535
2905
|
}
|
|
2536
2906
|
function captureFailure(cause) {
|
|
2537
|
-
if (cause instanceof
|
|
2538
|
-
|
|
2907
|
+
if (cause instanceof ConnectionError && cause.cause === void 0) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
|
|
2908
|
+
const failure3 = describeMediaCaptureError(cause instanceof ConnectionError ? cause.cause : cause);
|
|
2909
|
+
if (failure3.code === "unsupported_environment") return lifecycleFailure("media_capture_failed", true, "Media capture failed. Try again or enter with devices off.", cause);
|
|
2910
|
+
return lifecycleFailure(failure3.code, failure3.recoverable, failure3.message, cause);
|
|
2911
|
+
}
|
|
2912
|
+
function acquireInitialMedia(provider, intent) {
|
|
2913
|
+
return provider(intent).pipe(
|
|
2914
|
+
Effect2.map((stream) => ({ stream, failure: null })),
|
|
2915
|
+
Effect2.catch((cause) => {
|
|
2916
|
+
const initialFailure = captureFailure(cause);
|
|
2917
|
+
if (!initialFailure.recoverable) return Effect2.fail(initialFailure);
|
|
2918
|
+
const tracks = /* @__PURE__ */ new Set();
|
|
2919
|
+
return Effect2.gen(function* () {
|
|
2920
|
+
const failures = [];
|
|
2921
|
+
for (const source of ["microphone", "camera"]) {
|
|
2922
|
+
if (!intent[source]) continue;
|
|
2923
|
+
if (!intent.microphone || !intent.camera) {
|
|
2924
|
+
failures.push({ source, failure: initialFailure });
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2927
|
+
yield* provider({ microphone: source === "microphone", camera: source === "camera" }).pipe(
|
|
2928
|
+
Effect2.tap(
|
|
2929
|
+
(stream) => Effect2.sync(() => {
|
|
2930
|
+
for (const track of stream.getTracks()) tracks.add(track);
|
|
2931
|
+
})
|
|
2932
|
+
),
|
|
2933
|
+
Effect2.catch(
|
|
2934
|
+
(cause2) => Effect2.sync(() => {
|
|
2935
|
+
failures.push({ source, failure: captureFailure(cause2) });
|
|
2936
|
+
})
|
|
2937
|
+
)
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
if (tracks.size === 0) yield* provider({ microphone: false, camera: false }).pipe(Effect2.mapError(captureFailure));
|
|
2941
|
+
return { stream: streamFromTracks([...tracks]), failure: initialCaptureWarning(failures) };
|
|
2942
|
+
}).pipe(
|
|
2943
|
+
Effect2.onInterrupt(
|
|
2944
|
+
() => Effect2.sync(() => {
|
|
2945
|
+
for (const track of tracks) track.stop();
|
|
2946
|
+
})
|
|
2947
|
+
)
|
|
2948
|
+
);
|
|
2949
|
+
})
|
|
2950
|
+
);
|
|
2951
|
+
}
|
|
2952
|
+
function initialCaptureWarning(failures) {
|
|
2953
|
+
const first = failures[0];
|
|
2954
|
+
if (!first) return null;
|
|
2955
|
+
const names = failures.length === 2 ? "Microphone and camera" : first.source === "microphone" ? "Microphone" : "Camera";
|
|
2956
|
+
const message = failures.length === 2 && first.failure.code !== failures[1]?.failure.code ? failures.map(({ source, failure: failure3 }) => `${source === "microphone" ? "Microphone" : "Camera"}: ${failure3.message}`).join(" ") : `${names}: ${first.failure.message}`;
|
|
2957
|
+
return lifecycleFailure(first.failure.code, true, message, first.failure.cause);
|
|
2539
2958
|
}
|
|
2540
2959
|
function failureFromCause(cause, code, message) {
|
|
2541
2960
|
if (cause instanceof ConnectionLifecycleFailure) return cause;
|
|
@@ -2569,6 +2988,12 @@ function mediaPhase(phase) {
|
|
|
2569
2988
|
if (phase === "connecting") return "connecting";
|
|
2570
2989
|
return "idle";
|
|
2571
2990
|
}
|
|
2991
|
+
function syncTerminalFailure(snapshot) {
|
|
2992
|
+
const reason = snapshot.connection.terminalReason;
|
|
2993
|
+
if (reason === "episode_ended") return lifecycleFailure("episode_ended", false, "The Episode has ended");
|
|
2994
|
+
if (reason === "participant_inactive" || reason === "stale_participant_generation") return lifecycleFailure("invalid_access", false, "Participant access is no longer valid");
|
|
2995
|
+
return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
|
|
2996
|
+
}
|
|
2572
2997
|
|
|
2573
2998
|
// src/space-client/errors.ts
|
|
2574
2999
|
import { Data as Data3 } from "effect";
|
|
@@ -2578,6 +3003,10 @@ var CONNECTION_ERROR_CODE_MAP = {
|
|
|
2578
3003
|
invalid_access: "access.invalid",
|
|
2579
3004
|
access_unavailable: "access.unavailable",
|
|
2580
3005
|
permission_denied: "media.permission_denied",
|
|
3006
|
+
device_not_found: "media.device_not_found",
|
|
3007
|
+
device_busy: "media.device_busy",
|
|
3008
|
+
device_constraint_invalid: "media.device_constraint_invalid",
|
|
3009
|
+
media_capture_failed: "media.capture_failed",
|
|
2581
3010
|
sync_start_failed: "connection.sync_start_failed",
|
|
2582
3011
|
media_start_failed: "connection.media_start_failed",
|
|
2583
3012
|
join_cleanup_unconfirmed: "connection.join_cleanup_unconfirmed",
|
|
@@ -2610,6 +3039,9 @@ function normalizeClientError(cause, fallback = "client.internal_error") {
|
|
|
2610
3039
|
message: cause.message
|
|
2611
3040
|
});
|
|
2612
3041
|
}
|
|
3042
|
+
if (cause instanceof V1SyncError && ["signaling_timeout", "negotiation_timeout", "media_timeout"].includes(cause.code)) {
|
|
3043
|
+
return new SpaceClientError({ code: "media.timeout", recoverable: true, message: "Media did not respond in time. Try again." });
|
|
3044
|
+
}
|
|
2613
3045
|
if (isAccessInvalid(cause)) {
|
|
2614
3046
|
return new SpaceClientError({ code: "access.invalid", recoverable: true, message: "The access grant was rejected" });
|
|
2615
3047
|
}
|
|
@@ -2633,6 +3065,7 @@ function connectionErrorCode(code, action) {
|
|
|
2633
3065
|
|
|
2634
3066
|
// src/space-client/store.ts
|
|
2635
3067
|
import { Context as Context4, Effect as Effect3, Layer as Layer4, SubscriptionRef as SubscriptionRef2 } from "effect";
|
|
3068
|
+
var INTERRUPTED_CONNECTION_PHASES = /* @__PURE__ */ new Set(["unresponsive", "connecting", "recovering"]);
|
|
2636
3069
|
var empty = (value) => Object.freeze(value);
|
|
2637
3070
|
var EMPTY = empty({
|
|
2638
3071
|
connection: empty({ status: "idle", episode: null, lastError: null }),
|
|
@@ -2648,6 +3081,8 @@ var EMPTY = empty({
|
|
|
2648
3081
|
}),
|
|
2649
3082
|
chat: empty({ status: "idle", messages: empty([]), pendingSends: empty([]), readReceipts: empty([]), unreadCount: 0, pagination: empty({ cursor: null, hasOlder: false, historyTruncated: false }), lastError: null }),
|
|
2650
3083
|
reactions: empty({ active: empty([]) }),
|
|
3084
|
+
recording: empty({ current: null }),
|
|
3085
|
+
offline: empty({ pending: empty([]), decisionNeeded: false, policy: "send" }),
|
|
2651
3086
|
whiteboard: empty({ open: false, engine: empty({ status: "unsubscribed", sceneId: null, revision: null, presenting: false, error: null }) })
|
|
2652
3087
|
});
|
|
2653
3088
|
var SpaceStore = class _SpaceStore {
|
|
@@ -2671,9 +3106,10 @@ var SpaceStore = class _SpaceStore {
|
|
|
2671
3106
|
};
|
|
2672
3107
|
updateConnection(snapshot) {
|
|
2673
3108
|
const current = this.getSnapshot().connection;
|
|
2674
|
-
const error2 = snapshot.failure ? empty({ code:
|
|
2675
|
-
|
|
2676
|
-
|
|
3109
|
+
const error2 = snapshot.failure ? empty({ code: publicCode(snapshot.failure.code), recoverable: snapshot.failure.recoverable, message: snapshot.failure.message }) : null;
|
|
3110
|
+
const status = snapshot.state === "live" && (INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.sync) || INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.media)) ? "reconnecting" : snapshot.state;
|
|
3111
|
+
if (current.status === status && sameEpisode(current.episode, snapshot.episode) && sameError(current.lastError, error2)) return;
|
|
3112
|
+
this.#replace("connection", empty({ status, episode: snapshot.episode, lastError: sameError(current.lastError, error2) ? current.lastError : error2 }));
|
|
2677
3113
|
}
|
|
2678
3114
|
updateSelf(value) {
|
|
2679
3115
|
this.#replace("self", value);
|
|
@@ -2690,6 +3126,12 @@ var SpaceStore = class _SpaceStore {
|
|
|
2690
3126
|
updateReactions(value) {
|
|
2691
3127
|
this.#replace("reactions", value);
|
|
2692
3128
|
}
|
|
3129
|
+
updateRecording(value) {
|
|
3130
|
+
this.#replace("recording", value);
|
|
3131
|
+
}
|
|
3132
|
+
updateOffline(value) {
|
|
3133
|
+
this.#replace("offline", value);
|
|
3134
|
+
}
|
|
2693
3135
|
updateWhiteboard(value) {
|
|
2694
3136
|
this.#replace("whiteboard", value);
|
|
2695
3137
|
}
|
|
@@ -2715,7 +3157,16 @@ var SpaceStoreService = class extends Context4.Service()("@chalk/client/SpaceSto
|
|
|
2715
3157
|
var makeSpaceStoreLayer = Layer4.effect(SpaceStoreService, SpaceStore.make());
|
|
2716
3158
|
var makeFakeSpaceStoreLayer = (store = new SpaceStore()) => Layer4.succeed(SpaceStoreService, store);
|
|
2717
3159
|
function mapCode(code) {
|
|
2718
|
-
return {
|
|
3160
|
+
return {
|
|
3161
|
+
invalid_access: "access.invalid",
|
|
3162
|
+
access_unavailable: "access.unavailable",
|
|
3163
|
+
episode_ended: "episode.ended",
|
|
3164
|
+
invalid_payload: "chat.payload_invalid",
|
|
3165
|
+
permission_denied: "media.permission_denied",
|
|
3166
|
+
unsupported_environment: "environment.unsupported",
|
|
3167
|
+
rate_limited: "command.rate_limited",
|
|
3168
|
+
command_rejected: "command.rejected"
|
|
3169
|
+
}[code] ?? "client.internal_error";
|
|
2719
3170
|
}
|
|
2720
3171
|
function sameEpisode(left, right) {
|
|
2721
3172
|
if (left === right) return true;
|
|
@@ -2727,49 +3178,995 @@ function sameError(left, right) {
|
|
|
2727
3178
|
if (!left || !right) return false;
|
|
2728
3179
|
return left.code === right.code && left.recoverable === right.recoverable && left.message === right.message;
|
|
2729
3180
|
}
|
|
3181
|
+
function publicCode(code) {
|
|
3182
|
+
switch (code) {
|
|
3183
|
+
case "device_not_found":
|
|
3184
|
+
return "media.device_not_found";
|
|
3185
|
+
case "device_busy":
|
|
3186
|
+
return "media.device_busy";
|
|
3187
|
+
case "device_constraint_invalid":
|
|
3188
|
+
return "media.device_constraint_invalid";
|
|
3189
|
+
case "media_capture_failed":
|
|
3190
|
+
return "media.capture_failed";
|
|
3191
|
+
default:
|
|
3192
|
+
return mapCode(code);
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
2730
3195
|
|
|
2731
|
-
// src/
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
3196
|
+
// src/feedback/types.ts
|
|
3197
|
+
var FEEDBACK_EVIDENCE_SCHEMA_VERSION = "FeedbackEvidence/v1";
|
|
3198
|
+
var FEEDBACK_REQUEST_SCHEMA_VERSION = "FeedbackReportRequest/v1";
|
|
3199
|
+
var FEEDBACK_RECEIPT_SCHEMA_VERSION = "FeedbackReportReceipt/v1";
|
|
3200
|
+
var FEEDBACK_SCREENSHOT_SCHEMA_VERSION = "FeedbackScreenshot/v1";
|
|
3201
|
+
var FEEDBACK_LOCAL_STATE_REGISTRY_VERSION = "FeedbackLocalState/v1";
|
|
3202
|
+
var FEEDBACK_COOKIE_REGISTRY_VERSION = "FeedbackCookies/v1";
|
|
3203
|
+
var FEEDBACK_MAX_MESSAGE_BYTES = 8e3;
|
|
3204
|
+
var FEEDBACK_MAX_EVIDENCE_BYTES = 128 * 1024;
|
|
3205
|
+
var FEEDBACK_MAX_REQUEST_BYTES = 1 << 20;
|
|
3206
|
+
var FEEDBACK_MAX_SCREENSHOT_BYTES = 450 * 1024;
|
|
3207
|
+
var FEEDBACK_MAX_SCREENSHOT_WIDTH = 1920;
|
|
3208
|
+
var FEEDBACK_MAX_SCREENSHOT_HEIGHT = 1080;
|
|
3209
|
+
var FEEDBACK_MAX_TELEMETRY_EVENTS = 50;
|
|
3210
|
+
var FEEDBACK_MAX_DIAGNOSTIC_EVENTS = 50;
|
|
3211
|
+
var FEEDBACK_MAX_LOCAL_STATE_ENTRIES = 32;
|
|
3212
|
+
var FEEDBACK_MAX_COOKIE_ENTRIES = 16;
|
|
2740
3213
|
|
|
2741
|
-
// src/
|
|
2742
|
-
import {
|
|
2743
|
-
var MAX_CHAT_PAGE_SIZE = 100;
|
|
2744
|
-
var MAX_LOADED_CHAT_MESSAGES = 500;
|
|
2745
|
-
var MAX_CHAT_TEXT_BYTES = 16384;
|
|
2746
|
-
var MAX_CHAT_TEXT_SCALARS = 4e3;
|
|
3214
|
+
// src/feedback/validation.ts
|
|
3215
|
+
import { isDiagnosticReference, parseDiagnosticEventDraft, redactDiagnosticAttributes } from "@q9labsai/diagnostics-contracts";
|
|
2747
3216
|
var encoder3 = new TextEncoder();
|
|
2748
|
-
var
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
3217
|
+
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
3218
|
+
var TRACE_ID_PATTERN = /^(?=.*[1-9a-f])[0-9a-f]{32}$/u;
|
|
3219
|
+
var SPAN_ID_PATTERN = /^(?=.*[1-9a-f])[0-9a-f]{16}$/u;
|
|
3220
|
+
var TRACEPARENT_PATTERN = /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/iu;
|
|
3221
|
+
var SAFE_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@+/=-]{0,127}$/u;
|
|
3222
|
+
var DASHBOARD_ACTION_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/u;
|
|
3223
|
+
var UNSAFE_VALUE_PATTERN = /(?:https?:\/\/|wss?:\/\/|bearer\s+[a-z0-9._~+\/-]+|-----begin|candidate:|v=0\r?\n|\b(?:\d{1,3}\.){3}\d{1,3}\b|[\w.+-]+@[\w.-]+\.[a-z]{2,})/iu;
|
|
3224
|
+
var TELEMETRY_KEYS = ["version", "event_id", "journey_id", "sequence", "occurred_at", "name", "phase", "state", "origin_kind", "first_observed_layer", "upstream_visibility", "parent_event_id", "trace_id", "span_id", "traceparent", "tracestate", "attributes"];
|
|
3225
|
+
var TELEMETRY_NAMES = ["journey.started", "journey.phase", "journey.terminal", "journey.linked", "http.request", "sync.frame", "rtc.summary", "diagnostic.timeline"];
|
|
3226
|
+
var TELEMETRY_PHASES = ["root", "authentication", "signaling", "media", "recovery", "terminal"];
|
|
3227
|
+
var TELEMETRY_STATES = ["started", "in_progress", "succeeded", "failed", "cancelled", "observed"];
|
|
3228
|
+
var TELEMETRY_ORIGINS = ["client", "diagnostic", "http", "rtc", "sync"];
|
|
3229
|
+
var TELEMETRY_VISIBILITY = ["local", "propagated"];
|
|
3230
|
+
var FeedbackValidationError = class extends Error {
|
|
3231
|
+
issues;
|
|
3232
|
+
constructor(message, issues = [{ path: "$", message }]) {
|
|
3233
|
+
super(message);
|
|
3234
|
+
this.name = "FeedbackValidationError";
|
|
3235
|
+
this.issues = issues;
|
|
3236
|
+
}
|
|
3237
|
+
};
|
|
3238
|
+
function validateFeedbackReportRequest(input) {
|
|
3239
|
+
const issues = [];
|
|
3240
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return failure2("$", "expected a feedback request object");
|
|
3241
|
+
checkKeys(input, ["schema_version", "category", "message", "source", "evidence", "screenshot"], issues, "$", false);
|
|
3242
|
+
if (property(input, "schema_version") !== FEEDBACK_REQUEST_SCHEMA_VERSION) issues.push({ path: "$.schema_version", message: "unsupported feedback request schema" });
|
|
3243
|
+
const fields = validateFeedbackRequestFields(input, issues);
|
|
3244
|
+
validateFeedbackScreenshotConsistency(fields, issues);
|
|
3245
|
+
if (!fields.category || !fields.source || !fields.message || !fields.evidence || !fields.screenshot_valid || issues.length > 0) return { ok: false, issues };
|
|
3246
|
+
const request = {
|
|
3247
|
+
schema_version: FEEDBACK_REQUEST_SCHEMA_VERSION,
|
|
3248
|
+
category: fields.category,
|
|
3249
|
+
message: fields.message,
|
|
3250
|
+
source: fields.source,
|
|
3251
|
+
evidence: fields.evidence,
|
|
3252
|
+
...fields.screenshot ? { screenshot: fields.screenshot } : {}
|
|
3253
|
+
};
|
|
3254
|
+
return validateFeedbackRequestSize(request, issues);
|
|
3255
|
+
}
|
|
3256
|
+
function validateFeedbackRequestFields(input, issues) {
|
|
3257
|
+
const category = enumValue(property(input, "category"), ["bug", "feature_request", "other"], "$.category", issues);
|
|
3258
|
+
const source = enumValue(property(input, "source"), ["embedded", "chalk_web", "chalk_mobile", "dashboard"], "$.source", issues);
|
|
3259
|
+
const message = validateMessage(property(input, "message"), issues);
|
|
3260
|
+
const evidenceResult = validateFeedbackEvidence(property(input, "evidence"));
|
|
3261
|
+
appendIssues(issues, evidenceResult);
|
|
3262
|
+
const screenshotResult = validateRequestScreenshot(property(input, "screenshot"));
|
|
3263
|
+
appendIssues(issues, screenshotResult);
|
|
3264
|
+
return {
|
|
3265
|
+
category,
|
|
3266
|
+
source,
|
|
3267
|
+
message,
|
|
3268
|
+
evidence: evidenceResult.ok ? evidenceResult.value : void 0,
|
|
3269
|
+
screenshot: screenshotResult.ok ? screenshotResult.value : void 0,
|
|
3270
|
+
screenshot_valid: screenshotResult.ok
|
|
3271
|
+
};
|
|
2756
3272
|
}
|
|
2757
|
-
function
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
3273
|
+
function validateRequestScreenshot(input) {
|
|
3274
|
+
return input === void 0 ? { ok: true, value: void 0 } : validateFeedbackScreenshot(input);
|
|
3275
|
+
}
|
|
3276
|
+
function validateFeedbackScreenshotConsistency(fields, issues) {
|
|
3277
|
+
if (!fields.evidence) return;
|
|
3278
|
+
if (!fields.screenshot && requiresScreenshot(fields.evidence)) {
|
|
3279
|
+
issues.push({ path: "$.screenshot", message: "a captured screenshot payload is required" });
|
|
3280
|
+
return;
|
|
3281
|
+
}
|
|
3282
|
+
if (!fields.screenshot) return;
|
|
3283
|
+
if (!requiresScreenshot(fields.evidence)) issues.push({ path: "$.evidence.screenshot.state", message: "screenshot state must indicate a capture" });
|
|
3284
|
+
if (fields.evidence.screenshot.captured_at !== fields.screenshot.captured_at) issues.push({ path: "$.screenshot.captured_at", message: "screenshot timestamps must match" });
|
|
3285
|
+
}
|
|
3286
|
+
function requiresScreenshot(evidence) {
|
|
3287
|
+
return evidence.screenshot.state === "captured" || evidence.screenshot.state === "partial";
|
|
3288
|
+
}
|
|
3289
|
+
function validateFeedbackRequestSize(request, issues) {
|
|
3290
|
+
if (encodedByteLength(request) > FEEDBACK_MAX_REQUEST_BYTES) issues.push({ path: "$", message: "feedback request exceeds 1 MiB" });
|
|
3291
|
+
return issues.length === 0 ? { ok: true, value: request } : { ok: false, issues };
|
|
3292
|
+
}
|
|
3293
|
+
function appendIssues(issues, result) {
|
|
3294
|
+
if (!result.ok) issues.push(...result.issues);
|
|
3295
|
+
}
|
|
3296
|
+
function parseFeedbackReportRequest(input) {
|
|
3297
|
+
const result = validateFeedbackReportRequest(input);
|
|
3298
|
+
if (result.ok) return result.value;
|
|
3299
|
+
throw new FeedbackValidationError("Invalid feedback request", result.issues);
|
|
3300
|
+
}
|
|
3301
|
+
function validateFeedbackEvidence(input) {
|
|
3302
|
+
const issues = [];
|
|
3303
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return failure2("$", "expected a feedback evidence object");
|
|
3304
|
+
checkKeys(input, ["schema_version", "collected_at", "app", "sdk", "platform", "connection", "scope", "correlations", "diagnostics", "local_state", "cookies", "screenshot"], issues, "$", false);
|
|
3305
|
+
if (property(input, "schema_version") !== FEEDBACK_EVIDENCE_SCHEMA_VERSION) issues.push({ path: "$.schema_version", message: "unsupported feedback evidence schema" });
|
|
3306
|
+
const app = validateApp(property(input, "app"), issues);
|
|
3307
|
+
const sdk = validateSDK(property(input, "sdk"), issues);
|
|
3308
|
+
const platform = validatePlatform(property(input, "platform"), issues);
|
|
3309
|
+
const connection = validateConnection(property(input, "connection"), issues);
|
|
3310
|
+
const scope = validateScope(property(input, "scope"), issues);
|
|
3311
|
+
const correlations = validateCorrelations(property(input, "correlations"), issues);
|
|
3312
|
+
const diagnostics = validateDiagnostics(property(input, "diagnostics"), issues);
|
|
3313
|
+
const localState = validateLocalState(property(input, "local_state"), issues);
|
|
3314
|
+
const cookies = validateCookies(property(input, "cookies"), issues);
|
|
3315
|
+
const screenshot = validateScreenshotState(property(input, "screenshot"), issues);
|
|
3316
|
+
if (!sdk || !platform || !correlations || !diagnostics || !localState || !cookies || !screenshot || issues.length > 0) return { ok: false, issues };
|
|
3317
|
+
const collectedAt = checkDateTimeValue(property(input, "collected_at"), "$.collected_at", issues);
|
|
3318
|
+
if (!collectedAt) return { ok: false, issues };
|
|
3319
|
+
const evidence = {
|
|
3320
|
+
schema_version: FEEDBACK_EVIDENCE_SCHEMA_VERSION,
|
|
3321
|
+
collected_at: collectedAt,
|
|
3322
|
+
...app ? { app } : {},
|
|
3323
|
+
sdk,
|
|
3324
|
+
platform,
|
|
3325
|
+
...connection ? { connection } : {},
|
|
3326
|
+
...scope ? { scope } : {},
|
|
3327
|
+
correlations,
|
|
3328
|
+
diagnostics,
|
|
3329
|
+
local_state: localState,
|
|
3330
|
+
cookies,
|
|
3331
|
+
screenshot
|
|
3332
|
+
};
|
|
3333
|
+
if (encodedByteLength(evidence) > FEEDBACK_MAX_EVIDENCE_BYTES) return failure2("$", "feedback evidence exceeds 128 KiB");
|
|
3334
|
+
return { ok: true, value: evidence };
|
|
3335
|
+
}
|
|
3336
|
+
function parseFeedbackEvidence(input) {
|
|
3337
|
+
const result = validateFeedbackEvidence(input);
|
|
3338
|
+
if (result.ok) return result.value;
|
|
3339
|
+
throw new FeedbackValidationError("Invalid feedback evidence", result.issues);
|
|
3340
|
+
}
|
|
3341
|
+
function validateFeedbackScreenshot(input) {
|
|
3342
|
+
const issues = [];
|
|
3343
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return failure2("$", "expected a screenshot object");
|
|
3344
|
+
checkKeys(input, ["schema_version", "mime_type", "width", "height", "captured_at", "data_base64"], issues, "$", false);
|
|
3345
|
+
if (property(input, "schema_version") !== FEEDBACK_SCREENSHOT_SCHEMA_VERSION) issues.push({ path: "$.schema_version", message: "unsupported screenshot schema" });
|
|
3346
|
+
const mimeType = enumValue(property(input, "mime_type"), ["image/jpeg", "image/png", "image/webp"], "$.mime_type", issues);
|
|
3347
|
+
const width = boundedInteger2(property(input, "width"), 1, FEEDBACK_MAX_SCREENSHOT_WIDTH, "$.width", issues);
|
|
3348
|
+
const height = boundedInteger2(property(input, "height"), 1, FEEDBACK_MAX_SCREENSHOT_HEIGHT, "$.height", issues);
|
|
3349
|
+
const capturedAt = property(input, "captured_at");
|
|
3350
|
+
checkDateTime(capturedAt, "$.captured_at", issues);
|
|
3351
|
+
const dataInput = property(input, "data_base64");
|
|
3352
|
+
const data = typeof dataInput === "string" ? dataInput : void 0;
|
|
3353
|
+
if (!data || !validBase64(data)) issues.push({ path: "$.data_base64", message: "screenshot data must be base64" });
|
|
3354
|
+
else if (base64ByteLength(data) > FEEDBACK_MAX_SCREENSHOT_BYTES) issues.push({ path: "$.data_base64", message: "screenshot exceeds 450 KiB" });
|
|
3355
|
+
if (!mimeType || width === void 0 || height === void 0 || typeof capturedAt !== "string" || !data || issues.length > 0) return { ok: false, issues };
|
|
3356
|
+
return { ok: true, value: { schema_version: FEEDBACK_SCREENSHOT_SCHEMA_VERSION, mime_type: mimeType, width, height, captured_at: capturedAt, data_base64: data } };
|
|
3357
|
+
}
|
|
3358
|
+
function parseFeedbackScreenshot(input) {
|
|
3359
|
+
const result = validateFeedbackScreenshot(input);
|
|
3360
|
+
if (result.ok) return result.value;
|
|
3361
|
+
throw new FeedbackValidationError("Invalid feedback screenshot", result.issues);
|
|
3362
|
+
}
|
|
3363
|
+
function assertFeedbackReceipt(input) {
|
|
3364
|
+
const issues = [];
|
|
3365
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) throw new FeedbackValidationError("Invalid feedback receipt");
|
|
3366
|
+
checkKeys(input, ["schema_version", "id", "submitted_at"], issues, "$", false);
|
|
3367
|
+
const schemaVersion = property(input, "schema_version");
|
|
3368
|
+
const id = property(input, "id");
|
|
3369
|
+
const submittedAt = property(input, "submitted_at");
|
|
3370
|
+
if (schemaVersion !== FEEDBACK_RECEIPT_SCHEMA_VERSION) issues.push({ path: "$.schema_version", message: "unsupported receipt schema" });
|
|
3371
|
+
if (!isUUID(id)) issues.push({ path: "$.id", message: "receipt ID is not a valid ID" });
|
|
3372
|
+
checkDateTime(submittedAt, "$.submitted_at", issues);
|
|
3373
|
+
if (issues.length > 0 || typeof id !== "string" || typeof submittedAt !== "string") throw new FeedbackValidationError("Invalid feedback receipt", issues);
|
|
3374
|
+
return { schema_version: FEEDBACK_RECEIPT_SCHEMA_VERSION, id, submitted_at: submittedAt };
|
|
3375
|
+
}
|
|
3376
|
+
function assertFeedbackMessage(message) {
|
|
3377
|
+
const issues = [];
|
|
3378
|
+
const normalized = validateMessage(message, issues);
|
|
3379
|
+
if (!normalized) throw new FeedbackValidationError("Invalid feedback message", issues);
|
|
3380
|
+
return normalized;
|
|
3381
|
+
}
|
|
3382
|
+
function validateMessage(value, issues) {
|
|
3383
|
+
if (typeof value !== "string") {
|
|
3384
|
+
issues.push({ path: "$.message", message: "message must be a string" });
|
|
3385
|
+
return void 0;
|
|
3386
|
+
}
|
|
3387
|
+
const message = value.trim();
|
|
3388
|
+
if (invalidMessageLength(message)) issues.push({ path: "$.message", message: "message must be between 1 and 8,000 UTF-8 bytes" });
|
|
3389
|
+
if (hasForbiddenMessageControl(message)) issues.push({ path: "$.message", message: "message contains a forbidden control character" });
|
|
3390
|
+
return hasMessageIssue(issues) ? void 0 : message;
|
|
3391
|
+
}
|
|
3392
|
+
function invalidMessageLength(message) {
|
|
3393
|
+
const bytes = encoder3.encode(message).byteLength;
|
|
3394
|
+
return bytes < 1 || bytes > FEEDBACK_MAX_MESSAGE_BYTES;
|
|
3395
|
+
}
|
|
3396
|
+
function hasForbiddenMessageControl(message) {
|
|
3397
|
+
for (const character of message) if (isForbiddenMessageControl(character)) return true;
|
|
3398
|
+
return false;
|
|
3399
|
+
}
|
|
3400
|
+
function isForbiddenMessageControl(character) {
|
|
3401
|
+
return isControl(character) && character !== "\n" && character !== "\r" && character !== " ";
|
|
3402
|
+
}
|
|
3403
|
+
function hasMessageIssue(issues) {
|
|
3404
|
+
return issues.some((issue) => issue.path === "$.message");
|
|
3405
|
+
}
|
|
3406
|
+
function validateApp(input, issues) {
|
|
3407
|
+
if (input === void 0) return void 0;
|
|
3408
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3409
|
+
issues.push({ path: "$.app", message: "app must be an object" });
|
|
3410
|
+
return void 0;
|
|
3411
|
+
}
|
|
3412
|
+
checkKeys(input, ["name", "version", "build"], issues, "$.app", false);
|
|
3413
|
+
const name = metadata(property(input, "name"), "$.app.name", issues, true);
|
|
3414
|
+
const version = metadata(property(input, "version"), "$.app.version", issues, false);
|
|
3415
|
+
const build = metadata(property(input, "build"), "$.app.build", issues, false);
|
|
3416
|
+
return name ? { name, ...version ? { version } : {}, ...build ? { build } : {} } : void 0;
|
|
3417
|
+
}
|
|
3418
|
+
function validateSDK(input, issues) {
|
|
3419
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3420
|
+
issues.push({ path: "$.sdk", message: "sdk must be an object" });
|
|
3421
|
+
return void 0;
|
|
3422
|
+
}
|
|
3423
|
+
checkKeys(input, ["client", "react", "react_native"], issues, "$.sdk", false);
|
|
3424
|
+
const client = metadata(property(input, "client"), "$.sdk.client", issues, true);
|
|
3425
|
+
const react = metadata(property(input, "react"), "$.sdk.react", issues, false);
|
|
3426
|
+
const reactNative = metadata(property(input, "react_native"), "$.sdk.react_native", issues, false);
|
|
3427
|
+
return client ? { client, ...react ? { react } : {}, ...reactNative ? { react_native: reactNative } : {} } : void 0;
|
|
3428
|
+
}
|
|
3429
|
+
function validatePlatform(input, issues) {
|
|
3430
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3431
|
+
issues.push({ path: "$.platform", message: "platform must be an object" });
|
|
3432
|
+
return void 0;
|
|
3433
|
+
}
|
|
3434
|
+
checkKeys(input, ["kind", "os_name", "os_version", "browser_name", "browser_version", "device_class", "device_model"], issues, "$.platform", false);
|
|
3435
|
+
const kind = enumValue(property(input, "kind"), ["web", "ios", "android", "macos"], "$.platform.kind", issues);
|
|
3436
|
+
const deviceClassInput = property(input, "device_class");
|
|
3437
|
+
const deviceClass = deviceClassInput === void 0 ? void 0 : enumValue(deviceClassInput, ["phone", "tablet", "desktop"], "$.platform.device_class", issues);
|
|
3438
|
+
const values = {
|
|
3439
|
+
os_name: metadata(property(input, "os_name"), "$.platform.os_name", issues, false),
|
|
3440
|
+
os_version: metadata(property(input, "os_version"), "$.platform.os_version", issues, false),
|
|
3441
|
+
browser_name: metadata(property(input, "browser_name"), "$.platform.browser_name", issues, false),
|
|
3442
|
+
browser_version: metadata(property(input, "browser_version"), "$.platform.browser_version", issues, false),
|
|
3443
|
+
device_model: metadata(property(input, "device_model"), "$.platform.device_model", issues, false)
|
|
3444
|
+
};
|
|
3445
|
+
if (!kind) return void 0;
|
|
3446
|
+
return buildFeedbackPlatform(kind, values, deviceClass);
|
|
2762
3447
|
}
|
|
2763
|
-
function
|
|
2764
|
-
return
|
|
3448
|
+
function buildFeedbackPlatform(kind, values, deviceClass) {
|
|
3449
|
+
return {
|
|
3450
|
+
kind,
|
|
3451
|
+
...values.os_name ? { os_name: values.os_name } : {},
|
|
3452
|
+
...values.os_version ? { os_version: values.os_version } : {},
|
|
3453
|
+
...values.browser_name ? { browser_name: values.browser_name } : {},
|
|
3454
|
+
...values.browser_version ? { browser_version: values.browser_version } : {},
|
|
3455
|
+
...deviceClass ? { device_class: deviceClass } : {},
|
|
3456
|
+
...values.device_model ? { device_model: values.device_model } : {}
|
|
3457
|
+
};
|
|
2765
3458
|
}
|
|
2766
|
-
function
|
|
2767
|
-
|
|
3459
|
+
function validateConnection(input, issues) {
|
|
3460
|
+
if (input === void 0) return void 0;
|
|
3461
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3462
|
+
issues.push({ path: "$.connection", message: "connection must be an object" });
|
|
3463
|
+
return void 0;
|
|
3464
|
+
}
|
|
3465
|
+
checkKeys(input, ["state", "error_code"], issues, "$.connection", false);
|
|
3466
|
+
const state = safeToken(property(input, "state"), "$.connection.state", issues, true);
|
|
3467
|
+
const errorCode = safeToken(property(input, "error_code"), "$.connection.error_code", issues, false);
|
|
3468
|
+
return state ? { state, ...errorCode ? { error_code: errorCode } : {} } : void 0;
|
|
2768
3469
|
}
|
|
2769
|
-
function
|
|
2770
|
-
|
|
3470
|
+
function validateScope(input, issues) {
|
|
3471
|
+
if (input === void 0) return void 0;
|
|
3472
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3473
|
+
issues.push({ path: "$.scope", message: "scope must be an object" });
|
|
3474
|
+
return void 0;
|
|
3475
|
+
}
|
|
3476
|
+
checkKeys(input, ["space_id", "episode_id", "participant_id"], issues, "$.scope", false);
|
|
3477
|
+
return buildFeedbackScope(optionalUUID(property(input, "space_id"), "$.scope.space_id", issues), optionalUUID(property(input, "episode_id"), "$.scope.episode_id", issues), optionalUUID(property(input, "participant_id"), "$.scope.participant_id", issues));
|
|
2771
3478
|
}
|
|
2772
|
-
function
|
|
3479
|
+
function buildFeedbackScope(space, episode, participant) {
|
|
3480
|
+
if (!space && !episode && !participant) return {};
|
|
3481
|
+
return {
|
|
3482
|
+
...space ? { space_id: space } : {},
|
|
3483
|
+
...episode ? { episode_id: episode } : {},
|
|
3484
|
+
...participant ? { participant_id: participant } : {}
|
|
3485
|
+
};
|
|
3486
|
+
}
|
|
3487
|
+
function validateCorrelations(input, issues) {
|
|
3488
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3489
|
+
issues.push({ path: "$.correlations", message: "correlations must be an object" });
|
|
3490
|
+
return void 0;
|
|
3491
|
+
}
|
|
3492
|
+
checkKeys(input, ["journey_id", "root_journey_id", "trace_id", "span_id", "request_id", "command_id", "diagnostic_reference"], issues, "$.correlations", false);
|
|
3493
|
+
const journey = optionalUUID(property(input, "journey_id"), "$.correlations.journey_id", issues);
|
|
3494
|
+
const root = optionalUUID(property(input, "root_journey_id"), "$.correlations.root_journey_id", issues);
|
|
3495
|
+
const trace = optionalHex(property(input, "trace_id"), TRACE_ID_PATTERN, "$.correlations.trace_id", issues);
|
|
3496
|
+
const span = optionalHex(property(input, "span_id"), SPAN_ID_PATTERN, "$.correlations.span_id", issues);
|
|
3497
|
+
const request = optionalUUID(property(input, "request_id"), "$.correlations.request_id", issues);
|
|
3498
|
+
const command = optionalUUID(property(input, "command_id"), "$.correlations.command_id", issues);
|
|
3499
|
+
const diagnostic = validateDiagnosticReference(property(input, "diagnostic_reference"), issues);
|
|
3500
|
+
return buildFeedbackCorrelations(journey, root, trace, span, request, command, diagnostic);
|
|
3501
|
+
}
|
|
3502
|
+
function validateDiagnosticReference(input, issues) {
|
|
3503
|
+
if (input === void 0) return void 0;
|
|
3504
|
+
if (typeof input !== "string" || !isDiagnosticReference(input)) {
|
|
3505
|
+
issues.push({ path: "$.correlations.diagnostic_reference", message: "diagnostic reference is malformed" });
|
|
3506
|
+
return void 0;
|
|
3507
|
+
}
|
|
3508
|
+
return input;
|
|
3509
|
+
}
|
|
3510
|
+
function buildFeedbackCorrelations(journey, root, trace, span, request, command, diagnostic) {
|
|
3511
|
+
return {
|
|
3512
|
+
...journey ? { journey_id: journey } : {},
|
|
3513
|
+
...root ? { root_journey_id: root } : {},
|
|
3514
|
+
...trace ? { trace_id: trace } : {},
|
|
3515
|
+
...span ? { span_id: span } : {},
|
|
3516
|
+
...request ? { request_id: request } : {},
|
|
3517
|
+
...command ? { command_id: command } : {},
|
|
3518
|
+
...diagnostic ? { diagnostic_reference: diagnostic } : {}
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
3521
|
+
function validateDiagnostics(input, issues) {
|
|
3522
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3523
|
+
issues.push({ path: "$.diagnostics", message: "diagnostics must be an object" });
|
|
3524
|
+
return void 0;
|
|
3525
|
+
}
|
|
3526
|
+
checkKeys(input, ["availability", "dropped_count", "telemetry_events", "diagnostic_events"], issues, "$.diagnostics", false);
|
|
3527
|
+
const availability = enumValue(property(input, "availability"), ["available", "disabled", "disposed", "unavailable"], "$.diagnostics.availability", issues);
|
|
3528
|
+
const dropped = boundedInteger2(property(input, "dropped_count"), 0, Number.MAX_SAFE_INTEGER, "$.diagnostics.dropped_count", issues);
|
|
3529
|
+
const telemetry = validateTelemetryEvents(property(input, "telemetry_events"), issues);
|
|
3530
|
+
const diagnostic = validateDiagnosticEvents(property(input, "diagnostic_events"), issues);
|
|
3531
|
+
if (!availability || dropped === void 0 || !telemetry || !diagnostic) return void 0;
|
|
3532
|
+
return { availability, dropped_count: dropped, telemetry_events: telemetry, diagnostic_events: diagnostic };
|
|
3533
|
+
}
|
|
3534
|
+
function validateTelemetryEvents(input, issues) {
|
|
3535
|
+
if (!Array.isArray(input)) {
|
|
3536
|
+
issues.push({ path: "$.diagnostics.telemetry_events", message: "telemetry_events must be an array" });
|
|
3537
|
+
return void 0;
|
|
3538
|
+
}
|
|
3539
|
+
if (input.length > FEEDBACK_MAX_TELEMETRY_EVENTS) issues.push({ path: "$.diagnostics.telemetry_events", message: "too many telemetry events" });
|
|
3540
|
+
const events = [];
|
|
3541
|
+
input.slice(0, FEEDBACK_MAX_TELEMETRY_EVENTS).forEach((event, index) => {
|
|
3542
|
+
const parsed = sanitizeTelemetryEvent(event, `$.diagnostics.telemetry_events[${index}]`, issues);
|
|
3543
|
+
if (parsed) events.push(parsed);
|
|
3544
|
+
});
|
|
3545
|
+
return events;
|
|
3546
|
+
}
|
|
3547
|
+
function validateDiagnosticEvents(input, issues) {
|
|
3548
|
+
if (!Array.isArray(input)) {
|
|
3549
|
+
issues.push({ path: "$.diagnostics.diagnostic_events", message: "diagnostic_events must be an array" });
|
|
3550
|
+
return void 0;
|
|
3551
|
+
}
|
|
3552
|
+
if (input.length > FEEDBACK_MAX_DIAGNOSTIC_EVENTS) issues.push({ path: "$.diagnostics.diagnostic_events", message: "too many diagnostic events" });
|
|
3553
|
+
const events = [];
|
|
3554
|
+
input.slice(0, FEEDBACK_MAX_DIAGNOSTIC_EVENTS).forEach((event, index) => {
|
|
3555
|
+
if (typeof event !== "object" || event === null || Array.isArray(event)) {
|
|
3556
|
+
issues.push({ path: `$.diagnostics.diagnostic_events[${index}]`, message: "diagnostic event must be an object" });
|
|
3557
|
+
return;
|
|
3558
|
+
}
|
|
3559
|
+
const attributes = redactDiagnosticAttributes(property(event, "attributes")).attributes;
|
|
3560
|
+
const sanitized = { ...event, ...Object.keys(attributes).length > 0 ? { attributes } : {} };
|
|
3561
|
+
try {
|
|
3562
|
+
events.push(parseDiagnosticEventDraft(sanitized));
|
|
3563
|
+
} catch {
|
|
3564
|
+
issues.push({ path: `$.diagnostics.diagnostic_events[${index}]`, message: "diagnostic event failed the diagnostics contract" });
|
|
3565
|
+
}
|
|
3566
|
+
});
|
|
3567
|
+
return events;
|
|
3568
|
+
}
|
|
3569
|
+
function sanitizeTelemetryEvent(input, path, issues) {
|
|
3570
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3571
|
+
issues.push({ path, message: "telemetry event must be an object" });
|
|
3572
|
+
return void 0;
|
|
3573
|
+
}
|
|
3574
|
+
checkKeys(input, TELEMETRY_KEYS, issues, path, false);
|
|
3575
|
+
const identity = validateTelemetryEventIdentity(input, path, issues);
|
|
3576
|
+
const shape = validateTelemetryEventShape(input, path, issues);
|
|
3577
|
+
if (!identity || !shape) return void 0;
|
|
3578
|
+
const optional = validateTelemetryEventOptionalFields(input, path, issues);
|
|
3579
|
+
return buildSanitizedTelemetryEvent(identity, shape, optional);
|
|
3580
|
+
}
|
|
3581
|
+
function buildSanitizedTelemetryEvent(identity, shape, optional) {
|
|
3582
|
+
const event = {
|
|
3583
|
+
version: 1,
|
|
3584
|
+
event_id: identity.eventId,
|
|
3585
|
+
journey_id: identity.journeyId,
|
|
3586
|
+
sequence: identity.sequence,
|
|
3587
|
+
occurred_at: identity.occurredAt,
|
|
3588
|
+
name: shape.name,
|
|
3589
|
+
phase: shape.phase,
|
|
3590
|
+
state: shape.state,
|
|
3591
|
+
origin_kind: shape.origin,
|
|
3592
|
+
first_observed_layer: shape.firstObserved,
|
|
3593
|
+
upstream_visibility: shape.visibility,
|
|
3594
|
+
...optional.parentEvent ? { parent_event_id: optional.parentEvent } : {},
|
|
3595
|
+
...optional.traceId ? { trace_id: optional.traceId } : {},
|
|
3596
|
+
...optional.spanId ? { span_id: optional.spanId } : {},
|
|
3597
|
+
...optional.traceparent ? { traceparent: optional.traceparent } : {},
|
|
3598
|
+
...optional.tracestate ? { tracestate: optional.tracestate } : {},
|
|
3599
|
+
...optional.attributes ? { attributes: optional.attributes } : {}
|
|
3600
|
+
};
|
|
3601
|
+
return isSanitizedTelemetryEvent(event) ? event : void 0;
|
|
3602
|
+
}
|
|
3603
|
+
function validateTelemetryEventIdentity(input, path, issues) {
|
|
3604
|
+
const version = property(input, "version") === 1;
|
|
3605
|
+
if (!version) issues.push({ path: `${path}.version`, message: "unsupported telemetry version" });
|
|
3606
|
+
const eventId = requiredUUID(property(input, "event_id"), `${path}.event_id`, issues);
|
|
3607
|
+
const journeyId = requiredUUID(property(input, "journey_id"), `${path}.journey_id`, issues);
|
|
3608
|
+
const sequence = boundedInteger2(property(input, "sequence"), 0, Number.MAX_SAFE_INTEGER, `${path}.sequence`, issues);
|
|
3609
|
+
const occurredAt = checkDateTimeValue(property(input, "occurred_at"), `${path}.occurred_at`, issues);
|
|
3610
|
+
if (!version || !eventId || !journeyId || sequence === void 0 || !occurredAt) return void 0;
|
|
3611
|
+
return { eventId, journeyId, sequence, occurredAt };
|
|
3612
|
+
}
|
|
3613
|
+
function validateTelemetryEventShape(input, path, issues) {
|
|
3614
|
+
const name = enumValue(property(input, "name"), TELEMETRY_NAMES, `${path}.name`, issues);
|
|
3615
|
+
const phase = enumValue(property(input, "phase"), TELEMETRY_PHASES, `${path}.phase`, issues);
|
|
3616
|
+
const state = enumValue(property(input, "state"), TELEMETRY_STATES, `${path}.state`, issues);
|
|
3617
|
+
const origin = enumValue(property(input, "origin_kind"), TELEMETRY_ORIGINS, `${path}.origin_kind`, issues);
|
|
3618
|
+
const firstObserved = enumValue(property(input, "first_observed_layer"), TELEMETRY_ORIGINS, `${path}.first_observed_layer`, issues);
|
|
3619
|
+
const visibility = enumValue(property(input, "upstream_visibility"), TELEMETRY_VISIBILITY, `${path}.upstream_visibility`, issues);
|
|
3620
|
+
if (!name || !phase || !state || !origin || !firstObserved || !visibility) return void 0;
|
|
3621
|
+
return { name, phase, state, origin, firstObserved, visibility };
|
|
3622
|
+
}
|
|
3623
|
+
function validateTelemetryEventOptionalFields(input, path, issues) {
|
|
3624
|
+
return {
|
|
3625
|
+
parentEvent: optionalUUID(property(input, "parent_event_id"), `${path}.parent_event_id`, issues),
|
|
3626
|
+
traceId: optionalHex(property(input, "trace_id"), TRACE_ID_PATTERN, `${path}.trace_id`, issues),
|
|
3627
|
+
spanId: optionalHex(property(input, "span_id"), SPAN_ID_PATTERN, `${path}.span_id`, issues),
|
|
3628
|
+
traceparent: optionalTraceparent(property(input, "traceparent"), `${path}.traceparent`, issues),
|
|
3629
|
+
tracestate: optionalSafeText(property(input, "tracestate"), `${path}.tracestate`, 512, issues),
|
|
3630
|
+
attributes: sanitizeTelemetryAttributes(property(input, "attributes"), `${path}.attributes`, issues)
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
function isSanitizedTelemetryEvent(value) {
|
|
3634
|
+
switch (value.name) {
|
|
3635
|
+
case "sync.frame":
|
|
3636
|
+
return value.origin_kind === "sync";
|
|
3637
|
+
case "http.request":
|
|
3638
|
+
return value.origin_kind === "http";
|
|
3639
|
+
case "rtc.summary":
|
|
3640
|
+
return value.origin_kind === "rtc";
|
|
3641
|
+
case "diagnostic.timeline":
|
|
3642
|
+
return value.origin_kind === "diagnostic";
|
|
3643
|
+
case "journey.started":
|
|
3644
|
+
return isJourneyStartedEvent(value);
|
|
3645
|
+
case "journey.phase":
|
|
3646
|
+
return isJourneyPhaseEvent(value);
|
|
3647
|
+
case "journey.terminal":
|
|
3648
|
+
return isJourneyTerminalEvent(value);
|
|
3649
|
+
case "journey.linked":
|
|
3650
|
+
return isJourneyLinkedEvent(value);
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
function isJourneyStartedEvent(value) {
|
|
3654
|
+
return value.origin_kind === "client" && value.phase === "root" && value.state === "started";
|
|
3655
|
+
}
|
|
3656
|
+
function isJourneyPhaseEvent(value) {
|
|
3657
|
+
return value.origin_kind === "client" && value.phase !== "root" && value.phase !== "terminal" && (value.state === "in_progress" || value.state === "observed");
|
|
3658
|
+
}
|
|
3659
|
+
function isJourneyTerminalEvent(value) {
|
|
3660
|
+
return value.origin_kind === "client" && value.phase === "terminal" && (value.state === "succeeded" || value.state === "failed" || value.state === "cancelled");
|
|
3661
|
+
}
|
|
3662
|
+
function isJourneyLinkedEvent(value) {
|
|
3663
|
+
return value.origin_kind === "client" && value.phase === "root" && value.state === "observed" && typeof value.parent_event_id === "string";
|
|
3664
|
+
}
|
|
3665
|
+
function sanitizeTelemetryAttributes(input, path, issues) {
|
|
3666
|
+
if (input === void 0) return void 0;
|
|
3667
|
+
if (!isTelemetryAttributeObject(input)) {
|
|
3668
|
+
issues.push({ path, message: "telemetry attributes are not safe" });
|
|
3669
|
+
return void 0;
|
|
3670
|
+
}
|
|
3671
|
+
const redacted = redactDiagnosticAttributes(input).attributes;
|
|
3672
|
+
const normalized = normalizeTelemetryAttributes(redacted);
|
|
3673
|
+
if (!normalized) return void 0;
|
|
3674
|
+
for (const [key, value] of Object.entries(normalized)) if (typeof value === "string" && (value.length > 256 || isControlText(value) || UNSAFE_VALUE_PATTERN.test(value))) issues.push({ path: `${path}.${key}`, message: "telemetry attribute value is unsafe" });
|
|
3675
|
+
return normalized;
|
|
3676
|
+
}
|
|
3677
|
+
function isTelemetryAttributeObject(value) {
|
|
3678
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
3679
|
+
return Object.keys(value).every((key) => {
|
|
3680
|
+
const entry = property(value, key);
|
|
3681
|
+
return typeof entry === "boolean" || typeof entry === "string" || typeof entry === "number" && Number.isFinite(entry);
|
|
3682
|
+
});
|
|
3683
|
+
}
|
|
3684
|
+
function validateLocalState(input, issues) {
|
|
3685
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3686
|
+
issues.push({ path: "$.local_state", message: "local_state must be an object" });
|
|
3687
|
+
return void 0;
|
|
3688
|
+
}
|
|
3689
|
+
checkKeys(input, ["registry_version", "entries"], issues, "$.local_state", false);
|
|
3690
|
+
if (property(input, "registry_version") !== FEEDBACK_LOCAL_STATE_REGISTRY_VERSION) issues.push({ path: "$.local_state.registry_version", message: "unsupported local state registry" });
|
|
3691
|
+
const entriesInput = property(input, "entries");
|
|
3692
|
+
if (!Array.isArray(entriesInput)) {
|
|
3693
|
+
issues.push({ path: "$.local_state.entries", message: "local state entries must be an array" });
|
|
3694
|
+
return void 0;
|
|
3695
|
+
}
|
|
3696
|
+
if (entriesInput.length > FEEDBACK_MAX_LOCAL_STATE_ENTRIES) issues.push({ path: "$.local_state.entries", message: "too many local state entries" });
|
|
3697
|
+
const entries = [];
|
|
3698
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3699
|
+
entriesInput.slice(0, FEEDBACK_MAX_LOCAL_STATE_ENTRIES).forEach((entry, index) => {
|
|
3700
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
3701
|
+
issues.push({ path: `$.local_state.entries[${index}]`, message: "local state entry must be an object" });
|
|
3702
|
+
return;
|
|
3703
|
+
}
|
|
3704
|
+
checkKeys(entry, ["key", "value"], issues, `$.local_state.entries[${index}]`, false);
|
|
3705
|
+
const key = property(entry, "key");
|
|
3706
|
+
const entryValue = property(entry, "value");
|
|
3707
|
+
if (typeof key !== "string" || key.length === 0 || seen.has(key)) {
|
|
3708
|
+
issues.push({ path: `$.local_state.entries[${index}].key`, message: "local state key is invalid or duplicated" });
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
seen.add(key);
|
|
3712
|
+
if (!validLocalStateEntry(key, entryValue)) issues.push({ path: `$.local_state.entries[${index}]`, message: "local state entry is not allowlisted" });
|
|
3713
|
+
else if (isFeedbackLocalStateValue(entryValue)) entries.push({ key, value: entryValue });
|
|
3714
|
+
});
|
|
3715
|
+
return { registry_version: FEEDBACK_LOCAL_STATE_REGISTRY_VERSION, entries };
|
|
3716
|
+
}
|
|
3717
|
+
function validateCookies(input, issues) {
|
|
3718
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3719
|
+
issues.push({ path: "$.cookies", message: "cookies must be an object" });
|
|
3720
|
+
return void 0;
|
|
3721
|
+
}
|
|
3722
|
+
checkKeys(input, ["registry_version", "entries"], issues, "$.cookies", false);
|
|
3723
|
+
if (property(input, "registry_version") !== FEEDBACK_COOKIE_REGISTRY_VERSION) issues.push({ path: "$.cookies.registry_version", message: "unsupported cookie registry" });
|
|
3724
|
+
const entriesInput = property(input, "entries");
|
|
3725
|
+
if (!Array.isArray(entriesInput)) {
|
|
3726
|
+
issues.push({ path: "$.cookies.entries", message: "cookie entries must be an array" });
|
|
3727
|
+
return void 0;
|
|
3728
|
+
}
|
|
3729
|
+
if (entriesInput.length > FEEDBACK_MAX_COOKIE_ENTRIES) issues.push({ path: "$.cookies.entries", message: "too many cookie entries" });
|
|
3730
|
+
const entries = [];
|
|
3731
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3732
|
+
for (const [index, entry] of entriesInput.slice(0, FEEDBACK_MAX_COOKIE_ENTRIES).entries()) {
|
|
3733
|
+
const parsed = validateCookieEntry(entry, index, seen, issues);
|
|
3734
|
+
if (parsed) entries.push(parsed);
|
|
3735
|
+
}
|
|
3736
|
+
return { registry_version: FEEDBACK_COOKIE_REGISTRY_VERSION, entries };
|
|
3737
|
+
}
|
|
3738
|
+
function validateCookieEntry(input, index, seen, issues) {
|
|
3739
|
+
const path = `$.cookies.entries[${index}]`;
|
|
3740
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3741
|
+
issues.push({ path, message: "cookie entry must be an object" });
|
|
3742
|
+
return void 0;
|
|
3743
|
+
}
|
|
3744
|
+
checkKeys(input, ["name", "present", "value"], issues, path, false);
|
|
3745
|
+
const name = enumValue(property(input, "name"), ["chalk_theme", "chalk_sidebar_state", "account", "csrf"], `${path}.name`, issues);
|
|
3746
|
+
const present = property(input, "present");
|
|
3747
|
+
if (!name || typeof present !== "boolean" || seen.has(name)) {
|
|
3748
|
+
issues.push({ path, message: "cookie entry is invalid or duplicated" });
|
|
3749
|
+
return void 0;
|
|
3750
|
+
}
|
|
3751
|
+
seen.add(name);
|
|
3752
|
+
return buildCookieEntry(name, present, property(input, "value"), path, issues);
|
|
3753
|
+
}
|
|
3754
|
+
function buildCookieEntry(name, present, input, path, issues) {
|
|
3755
|
+
const value = cookieValue(name, input, `${path}.value`, issues);
|
|
3756
|
+
if (!present && value !== void 0) issues.push({ path: `${path}.value`, message: "absent cookies cannot include values" });
|
|
3757
|
+
return { name, present, ...value !== void 0 ? { value } : {} };
|
|
3758
|
+
}
|
|
3759
|
+
function cookieValue(name, input, path, issues) {
|
|
3760
|
+
if (input === void 0) return void 0;
|
|
3761
|
+
if (typeof input !== "string") {
|
|
3762
|
+
issues.push({ path, message: "cookie value is invalid" });
|
|
3763
|
+
return void 0;
|
|
3764
|
+
}
|
|
3765
|
+
if (name === "account" || name === "csrf") {
|
|
3766
|
+
issues.push({ path, message: "account and CSRF cookie values are never collected" });
|
|
3767
|
+
return void 0;
|
|
3768
|
+
}
|
|
3769
|
+
if (name === "chalk_theme") {
|
|
3770
|
+
const value2 = ["light", "dark", "system"];
|
|
3771
|
+
const match2 = value2.find((candidate) => candidate === input);
|
|
3772
|
+
if (match2 !== void 0) return match2;
|
|
3773
|
+
issues.push({ path, message: "theme cookie value is invalid" });
|
|
3774
|
+
return void 0;
|
|
3775
|
+
}
|
|
3776
|
+
const value = ["true", "false"];
|
|
3777
|
+
const match = value.find((candidate) => candidate === input);
|
|
3778
|
+
if (match !== void 0) return match;
|
|
3779
|
+
issues.push({ path, message: "sidebar cookie value is invalid" });
|
|
3780
|
+
return void 0;
|
|
3781
|
+
}
|
|
3782
|
+
function validateScreenshotState(input, issues) {
|
|
3783
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
3784
|
+
issues.push({ path: "$.screenshot", message: "screenshot state must be an object" });
|
|
3785
|
+
return void 0;
|
|
3786
|
+
}
|
|
3787
|
+
checkKeys(input, ["state", "captured_at", "failure_code"], issues, "$.screenshot", false);
|
|
3788
|
+
const state = enumValue(property(input, "state"), ["captured", "partial", "removed", "unavailable"], "$.screenshot.state", issues);
|
|
3789
|
+
const failureInput = property(input, "failure_code");
|
|
3790
|
+
const failure3 = failureInput === void 0 ? void 0 : enumValue(failureInput, ["capture_failed", "unsupported", "tainted", "secure_surface", "too_large"], "$.screenshot.failure_code", issues);
|
|
3791
|
+
const capturedAtInput = property(input, "captured_at");
|
|
3792
|
+
const capturedAt = capturedAtInput === void 0 ? void 0 : checkDateTimeValue(capturedAtInput, "$.screenshot.captured_at", issues);
|
|
3793
|
+
validateScreenshotStateConsistency(state, capturedAt, failure3, issues);
|
|
3794
|
+
if (!state) return void 0;
|
|
3795
|
+
return { state, ...capturedAt ? { captured_at: capturedAt } : {}, ...failure3 ? { failure_code: failure3 } : {} };
|
|
3796
|
+
}
|
|
3797
|
+
function validateScreenshotStateConsistency(state, capturedAt, failure3, issues) {
|
|
3798
|
+
const captured = state === "captured" || state === "partial";
|
|
3799
|
+
if (captured && !capturedAt) issues.push({ path: "$.screenshot.captured_at", message: "captured screenshots require a timestamp" });
|
|
3800
|
+
if (captured && failure3) issues.push({ path: "$.screenshot.failure_code", message: "captured screenshots cannot have a failure code" });
|
|
3801
|
+
}
|
|
3802
|
+
function validLocalStateEntry(key, value) {
|
|
3803
|
+
if (key === "chalk.tenant-hint") return isUUID(value);
|
|
3804
|
+
if (key === "chalk.web.telemetry.v1" || key === "chalk.mobile.telemetry.v1") return validTelemetrySummary(value);
|
|
3805
|
+
if (key.startsWith("chalk.dashboard-request.")) return DASHBOARD_ACTION_PATTERN.test(key.slice("chalk.dashboard-request.".length)) && value === true;
|
|
3806
|
+
return false;
|
|
3807
|
+
}
|
|
3808
|
+
function isFeedbackLocalStateValue(value) {
|
|
3809
|
+
if (typeof value === "string" || typeof value === "boolean") return true;
|
|
3810
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
3811
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).every((key) => {
|
|
3812
|
+
const entry = property(value, key);
|
|
3813
|
+
return typeof entry === "number" && Number.isFinite(entry);
|
|
3814
|
+
});
|
|
3815
|
+
}
|
|
3816
|
+
function validTelemetrySummary(value) {
|
|
3817
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
3818
|
+
const keys = ["pending_count", "timeline_count", "dropped_count"];
|
|
3819
|
+
if (!keys.some((key) => property(value, key) !== void 0)) return false;
|
|
3820
|
+
return keys.every((key) => {
|
|
3821
|
+
const entry = property(value, key);
|
|
3822
|
+
return entry === void 0 || typeof entry === "number" && Number.isSafeInteger(entry) && entry >= 0 && entry <= 500;
|
|
3823
|
+
});
|
|
3824
|
+
}
|
|
3825
|
+
function metadata(value, path, issues, required) {
|
|
3826
|
+
if (value === void 0 && !required) return void 0;
|
|
3827
|
+
if (typeof value !== "string" || !isSafeMetadata(value)) {
|
|
3828
|
+
issues.push({ path, message: required ? "metadata is required and unsafe" : "metadata is unsafe" });
|
|
3829
|
+
return void 0;
|
|
3830
|
+
}
|
|
3831
|
+
return value;
|
|
3832
|
+
}
|
|
3833
|
+
function isSafeMetadata(value) {
|
|
3834
|
+
return value.length > 0 && value.length <= 128 && value.trim() === value && !isControlText(value) && !UNSAFE_VALUE_PATTERN.test(value);
|
|
3835
|
+
}
|
|
3836
|
+
function safeToken(value, path, issues, required) {
|
|
3837
|
+
if (value === void 0 && !required) return void 0;
|
|
3838
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 128 || !SAFE_TOKEN_PATTERN.test(value) || UNSAFE_VALUE_PATTERN.test(value)) {
|
|
3839
|
+
issues.push({ path, message: required ? "token is required and unsafe" : "token is unsafe" });
|
|
3840
|
+
return void 0;
|
|
3841
|
+
}
|
|
3842
|
+
return value;
|
|
3843
|
+
}
|
|
3844
|
+
function optionalUUID(value, path, issues) {
|
|
3845
|
+
if (value === void 0) return void 0;
|
|
3846
|
+
return requiredUUID(value, path, issues);
|
|
3847
|
+
}
|
|
3848
|
+
function requiredUUID(value, path, issues) {
|
|
3849
|
+
if (typeof value !== "string" || !isUUID(value)) {
|
|
3850
|
+
issues.push({ path, message: "expected a non-zero UUID" });
|
|
3851
|
+
return void 0;
|
|
3852
|
+
}
|
|
3853
|
+
return value;
|
|
3854
|
+
}
|
|
3855
|
+
function optionalHex(value, pattern, path, issues) {
|
|
3856
|
+
if (value === void 0) return void 0;
|
|
3857
|
+
if (typeof value !== "string" || !pattern.test(value.toLowerCase()) || value !== value.toLowerCase()) {
|
|
3858
|
+
issues.push({ path, message: "expected a lower-case non-zero hexadecimal ID" });
|
|
3859
|
+
return void 0;
|
|
3860
|
+
}
|
|
3861
|
+
return value;
|
|
3862
|
+
}
|
|
3863
|
+
function optionalTraceparent(value, path, issues) {
|
|
3864
|
+
if (value === void 0) return void 0;
|
|
3865
|
+
if (typeof value !== "string" || !TRACEPARENT_PATTERN.test(value)) {
|
|
3866
|
+
issues.push({ path, message: "traceparent is malformed" });
|
|
3867
|
+
return void 0;
|
|
3868
|
+
}
|
|
3869
|
+
return value;
|
|
3870
|
+
}
|
|
3871
|
+
function optionalSafeText(value, path, length, issues) {
|
|
3872
|
+
if (value === void 0) return void 0;
|
|
3873
|
+
if (typeof value !== "string" || value.length > length || isControlText(value) || UNSAFE_VALUE_PATTERN.test(value)) {
|
|
3874
|
+
issues.push({ path, message: "text is unsafe" });
|
|
3875
|
+
return void 0;
|
|
3876
|
+
}
|
|
3877
|
+
return value;
|
|
3878
|
+
}
|
|
3879
|
+
function checkDateTime(value, path, issues) {
|
|
3880
|
+
checkDateTimeValue(value, path, issues);
|
|
3881
|
+
}
|
|
3882
|
+
function checkDateTimeValue(value, path, issues) {
|
|
3883
|
+
if (typeof value !== "string" || value.length > 64 || !Number.isFinite(Date.parse(value))) {
|
|
3884
|
+
issues.push({ path, message: "expected an ISO date-time" });
|
|
3885
|
+
return void 0;
|
|
3886
|
+
}
|
|
3887
|
+
return value;
|
|
3888
|
+
}
|
|
3889
|
+
function boundedInteger2(value, minimum, maximum, path, issues) {
|
|
3890
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
3891
|
+
issues.push({ path, message: "expected a bounded integer" });
|
|
3892
|
+
return void 0;
|
|
3893
|
+
}
|
|
3894
|
+
return value;
|
|
3895
|
+
}
|
|
3896
|
+
function enumValue(value, values, path, issues) {
|
|
3897
|
+
const match = values.find((candidate) => candidate === value);
|
|
3898
|
+
if (match !== void 0) return match;
|
|
3899
|
+
issues.push({ path, message: `expected one of: ${values.join(", ")}` });
|
|
3900
|
+
return void 0;
|
|
3901
|
+
}
|
|
3902
|
+
function checkKeys(value, allowed, issues, path, allowNullable) {
|
|
3903
|
+
const allowedSet = new Set(allowed);
|
|
3904
|
+
for (const key of Object.keys(value)) if (!allowedSet.has(key) && !(allowNullable && key === "value")) issues.push({ path: `${path}.${key}`, message: "unknown property" });
|
|
3905
|
+
}
|
|
3906
|
+
function failure2(path, message) {
|
|
3907
|
+
return { ok: false, issues: [{ path, message }] };
|
|
3908
|
+
}
|
|
3909
|
+
function property(value, key) {
|
|
3910
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
3911
|
+
return Object.prototype.hasOwnProperty.call(value, key) ? Reflect.get(value, key) : void 0;
|
|
3912
|
+
}
|
|
3913
|
+
function isUUID(value) {
|
|
3914
|
+
return typeof value === "string" && UUID_PATTERN.test(value) && value.replaceAll("0", "").replaceAll("-", "") !== "";
|
|
3915
|
+
}
|
|
3916
|
+
function validBase64(value) {
|
|
3917
|
+
return value.length > 0 && value.length % 4 === 0 && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value);
|
|
3918
|
+
}
|
|
3919
|
+
function base64ByteLength(value) {
|
|
3920
|
+
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
|
3921
|
+
return Math.floor(value.length * 3 / 4) - padding;
|
|
3922
|
+
}
|
|
3923
|
+
function encodedByteLength(value) {
|
|
3924
|
+
return encoder3.encode(JSON.stringify(value)).byteLength;
|
|
3925
|
+
}
|
|
3926
|
+
function isControl(character) {
|
|
3927
|
+
return /\p{Cc}/u.test(character);
|
|
3928
|
+
}
|
|
3929
|
+
function isControlText(value) {
|
|
3930
|
+
for (const character of value) if (isControl(character)) return true;
|
|
3931
|
+
return false;
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3934
|
+
// src/feedback/collector.ts
|
|
3935
|
+
import { validateDiagnosticEventDraft } from "@q9labsai/diagnostics-contracts";
|
|
3936
|
+
|
|
3937
|
+
// src/feedback/context.ts
|
|
3938
|
+
function safeSubject(snapshot) {
|
|
3939
|
+
const subject = snapshot.subject;
|
|
3940
|
+
if (!subject) return void 0;
|
|
3941
|
+
return {
|
|
3942
|
+
tenant_id: subject.tenantId,
|
|
3943
|
+
space_id: subject.spaceId,
|
|
3944
|
+
episode_id: subject.episodeId,
|
|
3945
|
+
participant_id: subject.participantId,
|
|
3946
|
+
participant_generation: subject.participantGeneration
|
|
3947
|
+
};
|
|
3948
|
+
}
|
|
3949
|
+
|
|
3950
|
+
// src/feedback/collector.ts
|
|
3951
|
+
function collectFeedbackEvidence(input, context = {}) {
|
|
3952
|
+
const collectedAt = new Date((context.now ?? Date.now)()).toISOString();
|
|
3953
|
+
const evidence = {
|
|
3954
|
+
schema_version: FEEDBACK_EVIDENCE_SCHEMA_VERSION,
|
|
3955
|
+
collected_at: collectedAt,
|
|
3956
|
+
...input.app ? { app: input.app } : {},
|
|
3957
|
+
sdk: input.sdk,
|
|
3958
|
+
platform: input.platform,
|
|
3959
|
+
...context.connection ? { connection: context.connection } : {},
|
|
3960
|
+
...context.scope ? { scope: context.scope } : {},
|
|
3961
|
+
correlations: context.correlations ?? {},
|
|
3962
|
+
diagnostics: context.diagnostics ?? unavailableDiagnostics(),
|
|
3963
|
+
local_state: { registry_version: FEEDBACK_LOCAL_STATE_REGISTRY_VERSION, entries: collectLocalState(input.local_state) },
|
|
3964
|
+
cookies: { registry_version: FEEDBACK_COOKIE_REGISTRY_VERSION, entries: collectCookies(input.cookies) },
|
|
3965
|
+
screenshot: screenshotState(input.screenshot)
|
|
3966
|
+
};
|
|
3967
|
+
return parseFeedbackEvidence(evidence);
|
|
3968
|
+
}
|
|
3969
|
+
function collectFeedbackEvidenceFromContext(input, context) {
|
|
3970
|
+
const snapshot = context.connection();
|
|
3971
|
+
const subject = safeSubject(snapshot);
|
|
3972
|
+
const activeTelemetry = context.telemetry;
|
|
3973
|
+
const activeDiagnostics = context.diagnosticContext();
|
|
3974
|
+
const correlationContext = activeTelemetry ?? activeDiagnostics;
|
|
3975
|
+
const trace = parseTraceparent(correlationContext?.traceparent);
|
|
3976
|
+
const diagnostics = context.diagnosticSnapshot();
|
|
3977
|
+
const availability = context.diagnosticAvailability();
|
|
3978
|
+
return collectFeedbackEvidence(input, {
|
|
3979
|
+
now: context.now,
|
|
3980
|
+
connection: {
|
|
3981
|
+
state: snapshot.state,
|
|
3982
|
+
...snapshot.failure?.code ? { error_code: snapshot.failure.code } : {}
|
|
3983
|
+
},
|
|
3984
|
+
...subject ? {
|
|
3985
|
+
scope: {
|
|
3986
|
+
space_id: subject.space_id,
|
|
3987
|
+
episode_id: subject.episode_id,
|
|
3988
|
+
participant_id: subject.participant_id
|
|
3989
|
+
}
|
|
3990
|
+
} : {},
|
|
3991
|
+
correlations: {
|
|
3992
|
+
...correlationContext ? { journey_id: correlationContext.journeyId } : {},
|
|
3993
|
+
...activeTelemetry ? { root_journey_id: activeTelemetry.rootJourneyId } : {},
|
|
3994
|
+
...trace ? { trace_id: trace.traceId, span_id: trace.spanId } : {}
|
|
3995
|
+
},
|
|
3996
|
+
diagnostics: {
|
|
3997
|
+
availability,
|
|
3998
|
+
dropped_count: diagnostics.dropped,
|
|
3999
|
+
telemetry_events: input.local_state?.telemetry?.events ?? [],
|
|
4000
|
+
diagnostic_events: diagnostics.events.filter(isDiagnosticEvent)
|
|
4001
|
+
}
|
|
4002
|
+
});
|
|
4003
|
+
}
|
|
4004
|
+
function collectLocalState(input) {
|
|
4005
|
+
if (!input) return [];
|
|
4006
|
+
const entries = [];
|
|
4007
|
+
if (input.telemetry) {
|
|
4008
|
+
const summary = telemetrySummary(input.telemetry);
|
|
4009
|
+
if (summary) entries.push({ key: input.telemetry.storage_key ?? "chalk.web.telemetry.v1", value: summary });
|
|
4010
|
+
}
|
|
4011
|
+
if (input.tenant_hint && validUUID(input.tenant_hint)) entries.push({ key: "chalk.tenant-hint", value: input.tenant_hint });
|
|
4012
|
+
for (const request of input.dashboard_requests ?? []) {
|
|
4013
|
+
if (!request.pending || !/^[a-z][a-z0-9_-]{0,63}$/u.test(request.action)) continue;
|
|
4014
|
+
entries.push({ key: `chalk.dashboard-request.${request.action}`, value: true });
|
|
4015
|
+
}
|
|
4016
|
+
return entries.slice(0, 32);
|
|
4017
|
+
}
|
|
4018
|
+
function collectCookies(input) {
|
|
4019
|
+
if (!input) return [];
|
|
4020
|
+
const entries = [];
|
|
4021
|
+
if (input.theme !== void 0) entries.push({ name: "chalk_theme", present: true, value: input.theme });
|
|
4022
|
+
if (input.sidebar_state !== void 0) entries.push({ name: "chalk_sidebar_state", present: true, value: input.sidebar_state ? "true" : "false" });
|
|
4023
|
+
if (input.account_present !== void 0) entries.push({ name: "account", present: input.account_present });
|
|
4024
|
+
if (input.csrf_present !== void 0) entries.push({ name: "csrf", present: input.csrf_present });
|
|
4025
|
+
return entries;
|
|
4026
|
+
}
|
|
4027
|
+
function telemetrySummary(input) {
|
|
4028
|
+
const summary = {
|
|
4029
|
+
...input.pending_count !== void 0 && boundedCount(input.pending_count) ? { pending_count: input.pending_count } : {},
|
|
4030
|
+
...input.timeline_count !== void 0 && boundedCount(input.timeline_count) ? { timeline_count: input.timeline_count } : {},
|
|
4031
|
+
...input.dropped_count !== void 0 && boundedCount(input.dropped_count) ? { dropped_count: input.dropped_count } : {}
|
|
4032
|
+
};
|
|
4033
|
+
return Object.keys(summary).length > 0 ? summary : void 0;
|
|
4034
|
+
}
|
|
4035
|
+
function boundedCount(value) {
|
|
4036
|
+
return Number.isSafeInteger(value) && value >= 0 && value <= 500;
|
|
4037
|
+
}
|
|
4038
|
+
function screenshotState(input) {
|
|
4039
|
+
if (!input) return { state: "unavailable", failure_code: "unsupported" };
|
|
4040
|
+
if (input.state === "captured" || input.state === "partial") return { state: input.state, captured_at: input.captured_at };
|
|
4041
|
+
return "failure_code" in input && input.failure_code ? { state: input.state, failure_code: input.failure_code } : { state: input.state };
|
|
4042
|
+
}
|
|
4043
|
+
function unavailableDiagnostics() {
|
|
4044
|
+
return { availability: "unavailable", dropped_count: 0, telemetry_events: [], diagnostic_events: [] };
|
|
4045
|
+
}
|
|
4046
|
+
function isDiagnosticEvent(value) {
|
|
4047
|
+
return validateDiagnosticEventDraft(value).ok;
|
|
4048
|
+
}
|
|
4049
|
+
function validUUID(value) {
|
|
4050
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value) && value.replaceAll("0", "").replaceAll("-", "") !== "";
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
// src/feedback/transport.ts
|
|
4054
|
+
var FeedbackTransportError = class extends Error {
|
|
4055
|
+
code;
|
|
4056
|
+
status;
|
|
4057
|
+
recoverable;
|
|
4058
|
+
constructor(input) {
|
|
4059
|
+
super(input.message);
|
|
4060
|
+
this.name = "FeedbackTransportError";
|
|
4061
|
+
this.code = input.code;
|
|
4062
|
+
this.recoverable = input.recoverable;
|
|
4063
|
+
this.status = input.status;
|
|
4064
|
+
}
|
|
4065
|
+
};
|
|
4066
|
+
function createFeedbackTransport(options) {
|
|
4067
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
4068
|
+
const baseUrl = options.baseUrl.replace(/\/+$/u, "");
|
|
4069
|
+
return {
|
|
4070
|
+
send: async ({ request, idempotencyKey }) => {
|
|
4071
|
+
const parsedRequest = parseFeedbackReportRequest(request);
|
|
4072
|
+
validateTransportBounds(parsedRequest);
|
|
4073
|
+
validateIdempotencyKey(idempotencyKey);
|
|
4074
|
+
const credential = options.credential();
|
|
4075
|
+
if (!credential) throw new FeedbackTransportError({ code: "unauthenticated", recoverable: false, message: "Feedback authentication is unavailable.", status: 401 });
|
|
4076
|
+
const headers = feedbackHeaders(idempotencyKey, credential.token, options.telemetry?.());
|
|
4077
|
+
const response = await sendFeedbackRequest(fetchImplementation, `${baseUrl}/v1/feedback-reports`, headers, parsedRequest);
|
|
4078
|
+
return parseFeedbackResponse(response);
|
|
4079
|
+
}
|
|
4080
|
+
};
|
|
4081
|
+
}
|
|
4082
|
+
function validateIdempotencyKey(idempotencyKey) {
|
|
4083
|
+
if (idempotencyKey.length < 16 || idempotencyKey.length > 128 || !/^[A-Za-z0-9_-]+$/u.test(idempotencyKey)) throw new FeedbackTransportError({ code: "invalid_request", recoverable: false, message: "Feedback idempotency key is invalid.", status: 400 });
|
|
4084
|
+
}
|
|
4085
|
+
function feedbackHeaders(idempotencyKey, credentialToken, telemetry) {
|
|
4086
|
+
const headers = new Headers({ "content-type": "application/json", authorization: `Bearer ${credentialToken}`, "idempotency-key": idempotencyKey });
|
|
4087
|
+
if (!telemetry) return headers;
|
|
4088
|
+
for (const [key, value] of Object.entries(journeyHeaders(telemetry))) headers.set(key, value);
|
|
4089
|
+
return headers;
|
|
4090
|
+
}
|
|
4091
|
+
async function sendFeedbackRequest(fetchImplementation, url, headers, request) {
|
|
4092
|
+
try {
|
|
4093
|
+
return await fetchImplementation(url, { method: "POST", headers, body: JSON.stringify(request) });
|
|
4094
|
+
} catch {
|
|
4095
|
+
throw new FeedbackTransportError({ code: "unavailable", recoverable: true, message: "Feedback service is unavailable." });
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
async function parseFeedbackResponse(response) {
|
|
4099
|
+
if (!response.ok) throw responseError3(response.status);
|
|
4100
|
+
let body;
|
|
4101
|
+
try {
|
|
4102
|
+
body = await response.json();
|
|
4103
|
+
} catch {
|
|
4104
|
+
throw new FeedbackTransportError({ code: "invalid_response", recoverable: true, message: "Feedback service returned an invalid receipt.", status: response.status });
|
|
4105
|
+
}
|
|
4106
|
+
try {
|
|
4107
|
+
return assertFeedbackReceipt(body);
|
|
4108
|
+
} catch {
|
|
4109
|
+
throw new FeedbackTransportError({ code: "invalid_response", recoverable: true, message: "Feedback service returned an invalid receipt.", status: response.status });
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
function validateTransportBounds(request) {
|
|
4113
|
+
const evidenceBytes = new TextEncoder().encode(JSON.stringify(request.evidence)).byteLength;
|
|
4114
|
+
const requestBytes = new TextEncoder().encode(JSON.stringify(request)).byteLength;
|
|
4115
|
+
if (evidenceBytes > FEEDBACK_MAX_EVIDENCE_BYTES) throw new FeedbackTransportError({ code: "invalid_request", recoverable: false, message: "Feedback evidence exceeds the size limit." });
|
|
4116
|
+
if (requestBytes > FEEDBACK_MAX_REQUEST_BYTES) throw new FeedbackTransportError({ code: "invalid_request", recoverable: false, message: "Feedback request exceeds the size limit." });
|
|
4117
|
+
}
|
|
4118
|
+
function responseError3(status) {
|
|
4119
|
+
if (status === 400) return new FeedbackTransportError({ code: "invalid_request", recoverable: false, message: "Feedback request was rejected.", status });
|
|
4120
|
+
if (status === 413) return new FeedbackTransportError({ code: "invalid_request", recoverable: false, message: "Feedback request exceeds the size limit.", status });
|
|
4121
|
+
if (status === 401) return new FeedbackTransportError({ code: "unauthenticated", recoverable: false, message: "Feedback authentication was rejected.", status });
|
|
4122
|
+
if (status === 403) return new FeedbackTransportError({ code: "forbidden", recoverable: false, message: "Feedback submission is not allowed.", status });
|
|
4123
|
+
if (status === 409) return new FeedbackTransportError({ code: "conflict", recoverable: false, message: "Feedback idempotency key conflicts with another submission.", status });
|
|
4124
|
+
if (status === 429) return new FeedbackTransportError({ code: "rate_limited", recoverable: true, message: "Feedback submissions are temporarily limited.", status });
|
|
4125
|
+
return new FeedbackTransportError({ code: "unavailable", recoverable: true, message: "Feedback service is unavailable.", status });
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
// src/space-client/core.ts
|
|
4129
|
+
import { Clock as Clock5, Context as Context13, Duration as Duration2, Effect as Effect12, Layer as Layer13 } from "effect";
|
|
4130
|
+
|
|
4131
|
+
// src/space-client/controller-effects.ts
|
|
4132
|
+
import { Context as Context12, Effect as Effect11, Layer as Layer12 } from "effect";
|
|
4133
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
|
|
4134
|
+
|
|
4135
|
+
// src/space-client/chat-controller.ts
|
|
4136
|
+
import { Context as Context5, Effect as Effect4, Layer as Layer5 } from "effect";
|
|
4137
|
+
|
|
4138
|
+
// src/space-client/chat-controller-helpers.ts
|
|
4139
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
4140
|
+
var MAX_CHAT_PAGE_SIZE = 100;
|
|
4141
|
+
var MAX_LOADED_CHAT_MESSAGES = 500;
|
|
4142
|
+
var MAX_CHAT_TEXT_BYTES = 16384;
|
|
4143
|
+
var MAX_CHAT_TEXT_SCALARS = 4e3;
|
|
4144
|
+
var encoder4 = new TextEncoder();
|
|
4145
|
+
var allowedMimeTypes = new Set(CHALK_CHAT_ATTACHMENT_MIME_TYPES);
|
|
4146
|
+
function validateChatMessage(input) {
|
|
4147
|
+
const attachments = input.attachments ?? [];
|
|
4148
|
+
if (input.text.length === 0 && attachments.length === 0) return "A chat message requires text or an attachment";
|
|
4149
|
+
if (Array.from(input.text).length > MAX_CHAT_TEXT_SCALARS || encoder4.encode(input.text).byteLength > MAX_CHAT_TEXT_BYTES) return `Chat text must not exceed ${MAX_CHAT_TEXT_SCALARS} characters or ${MAX_CHAT_TEXT_BYTES} bytes`;
|
|
4150
|
+
if (attachments.length > CHALK_CHAT_ATTACHMENT_LIMITS.maximumPerMessage) return `A chat message supports at most ${CHALK_CHAT_ATTACHMENT_LIMITS.maximumPerMessage} attachments`;
|
|
4151
|
+
if (new Set(attachments.map((attachment2) => attachment2.attachmentId)).size !== attachments.length) return "Chat attachment IDs must be unique";
|
|
4152
|
+
return attachments.map(validateChatAttachment).find((value) => value !== null) ?? null;
|
|
4153
|
+
}
|
|
4154
|
+
function validateChatUpload(file, bytes, clientAttachmentId) {
|
|
4155
|
+
const fileName = "fileName" in file ? file.fileName : file.name;
|
|
4156
|
+
const mimeType = "mimeType" in file ? file.mimeType : file.type;
|
|
4157
|
+
const error2 = validateChatAttachment({ attachmentId: clientAttachmentId, fileName, mimeType, byteLength: bytes.byteLength });
|
|
4158
|
+
return error2 ?? { fileName, mimeType };
|
|
4159
|
+
}
|
|
4160
|
+
function chatDigest(bytes) {
|
|
4161
|
+
return Array.from(sha256(new Uint8Array(bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
4162
|
+
}
|
|
4163
|
+
function chatMessageFor(message) {
|
|
4164
|
+
return Object.freeze({ ...message, attachments: Object.freeze([...message.attachments]) });
|
|
4165
|
+
}
|
|
4166
|
+
function chatReceiptFor(receipt) {
|
|
4167
|
+
return Object.freeze({ ...receipt });
|
|
4168
|
+
}
|
|
4169
|
+
function mergeChatMessage(messages, existing, incoming) {
|
|
2773
4170
|
if (sameChatMessage(existing, incoming)) return messages;
|
|
2774
4171
|
return Object.freeze(
|
|
2775
4172
|
messages.map((message) => message === existing ? incoming : message).sort((left, right) => compareChatSequence(left.sequence, right.sequence)).slice(-MAX_LOADED_CHAT_MESSAGES)
|
|
@@ -2782,16 +4179,16 @@ function compareChatSequence(left, right) {
|
|
|
2782
4179
|
const normalizedRight = right.replace(/^0+/u, "") || "0";
|
|
2783
4180
|
return normalizedLeft.length === normalizedRight.length ? normalizedLeft < normalizedRight ? -1 : 1 : normalizedLeft.length - normalizedRight.length;
|
|
2784
4181
|
}
|
|
2785
|
-
function validateChatAttachment(
|
|
2786
|
-
const fileNameBytes =
|
|
2787
|
-
if (!
|
|
2788
|
-
if (!Number.isSafeInteger(
|
|
2789
|
-
return allowedMimeTypes.has(
|
|
4182
|
+
function validateChatAttachment(attachment2) {
|
|
4183
|
+
const fileNameBytes = encoder4.encode(attachment2.fileName).byteLength;
|
|
4184
|
+
if (!attachment2.attachmentId || fileNameBytes < 1 || fileNameBytes > CHALK_CHAT_ATTACHMENT_LIMITS.maximumFileNameBytes) return "Chat attachment metadata is invalid";
|
|
4185
|
+
if (!Number.isSafeInteger(attachment2.byteLength) || attachment2.byteLength < 1 || attachment2.byteLength > CHALK_CHAT_ATTACHMENT_LIMITS.maximumByteLength) return "Chat attachment metadata is invalid";
|
|
4186
|
+
return allowedMimeTypes.has(attachment2.mimeType) ? null : "Chat attachment MIME type is not allowed";
|
|
2790
4187
|
}
|
|
2791
4188
|
function sameChatMessage(left, right) {
|
|
2792
|
-
return left.messageId === right.messageId && left.clientMessageId === right.clientMessageId && left.sequence === right.sequence && left.participantId === right.participantId && left.displayName === right.displayName && left.text === right.text && left.createdAt === right.createdAt && left.attachments.length === right.attachments.length && left.attachments.every((
|
|
4189
|
+
return left.messageId === right.messageId && left.clientMessageId === right.clientMessageId && left.sequence === right.sequence && left.participantId === right.participantId && left.displayName === right.displayName && left.text === right.text && left.createdAt === right.createdAt && left.attachments.length === right.attachments.length && left.attachments.every((attachment2, index) => {
|
|
2793
4190
|
const candidate = right.attachments[index];
|
|
2794
|
-
return candidate?.attachmentId ===
|
|
4191
|
+
return candidate?.attachmentId === attachment2.attachmentId && candidate.fileName === attachment2.fileName && candidate.mimeType === attachment2.mimeType && candidate.byteLength === attachment2.byteLength;
|
|
2795
4192
|
});
|
|
2796
4193
|
}
|
|
2797
4194
|
|
|
@@ -2807,7 +4204,7 @@ var makeChatController = (input) => Effect4.gen(function* () {
|
|
|
2807
4204
|
const controller = yield* Effect4.sync(() => {
|
|
2808
4205
|
let instance = null;
|
|
2809
4206
|
const transport = input.createTransport?.({ token: () => Effect4.runPromiseWith(context)(input.connection.getSyncToken()) }) ?? null;
|
|
2810
|
-
instance = new ChatControllerRuntime(input.connection, input.store, transport, input.apiBaseUrl ?? "https://api.chalkmeet.com", input.fetch ?? globalThis.fetch, fork, input.episodeDiagnostics);
|
|
4207
|
+
instance = new ChatControllerRuntime(input.connection, input.store, transport, input.apiBaseUrl ?? "https://api.chalkmeet.com", input.fetch ?? globalThis.fetch, fork, input.episodeDiagnostics, input.offline);
|
|
2811
4208
|
return instance;
|
|
2812
4209
|
});
|
|
2813
4210
|
yield* Effect4.addFinalizer(() => Effect4.sync(() => controller.dispose()));
|
|
@@ -2821,8 +4218,10 @@ var ChatControllerRuntime = class {
|
|
|
2821
4218
|
#fetch;
|
|
2822
4219
|
#fork;
|
|
2823
4220
|
#diagnostics;
|
|
4221
|
+
#offline;
|
|
2824
4222
|
#catchUpRunning = false;
|
|
2825
4223
|
#catchUpRequested = false;
|
|
4224
|
+
#contiguousSequence = null;
|
|
2826
4225
|
#hasOlder = false;
|
|
2827
4226
|
#historyTruncated = false;
|
|
2828
4227
|
#initialCatchUpComplete = true;
|
|
@@ -2838,7 +4237,8 @@ var ChatControllerRuntime = class {
|
|
|
2838
4237
|
#unsubscribeConnection = null;
|
|
2839
4238
|
#unsubscribeEvents = null;
|
|
2840
4239
|
#unsubscribeSnapshot = null;
|
|
2841
|
-
constructor(connection, store, transport, apiBaseUrl, fetch, fork, diagnostics) {
|
|
4240
|
+
constructor(connection, store, transport, apiBaseUrl, fetch, fork, diagnostics, offline) {
|
|
4241
|
+
this.#offline = offline;
|
|
2842
4242
|
this.#apiBaseUrl = apiBaseUrl.replace(/\/+$/u, "");
|
|
2843
4243
|
this.#connection = connection;
|
|
2844
4244
|
this.#store = store;
|
|
@@ -2854,9 +4254,12 @@ var ChatControllerRuntime = class {
|
|
|
2854
4254
|
Effect4.tap(() => Effect4.sync(() => operation?.observe("observed", "validation"))),
|
|
2855
4255
|
Effect4.map((attachments) => ({ clientMessageId: this.#connection.createId(), attachments })),
|
|
2856
4256
|
Effect4.flatMap(({ clientMessageId, attachments }) => {
|
|
2857
|
-
this.#
|
|
4257
|
+
const command = this.#connection.runCommand(({ sync }) => foreign(() => sync.sendChatMessage({ text: input.text, attachments, clientMessageId })));
|
|
4258
|
+
const sendNow = () => Effect4.sync(() => this.#upsertPending(clientMessageId, input.text, attachments, "sending", null)).pipe(Effect4.andThen(command));
|
|
4259
|
+
const held = this.#offline?.hold({ kind: "chat_message", text: input.text, clientMessageId, attachments }, sendNow);
|
|
4260
|
+
if (held) this.#upsertPending(clientMessageId, input.text, attachments, "queued", null);
|
|
2858
4261
|
operation?.observe("observed", "authorization");
|
|
2859
|
-
return
|
|
4262
|
+
return (held ?? sendNow()).pipe(Effect4.tap(() => Effect4.sync(() => operation?.observe("observed", "durable_commit")))).pipe(
|
|
2860
4263
|
Effect4.map(chatMessageFor),
|
|
2861
4264
|
Effect4.tap(() => Effect4.sync(() => this.#removePending(clientMessageId))),
|
|
2862
4265
|
Effect4.map((message) => this.#observeMessage(message, false)),
|
|
@@ -2870,7 +4273,8 @@ var ChatControllerRuntime = class {
|
|
|
2870
4273
|
Effect4.catch((cause) => {
|
|
2871
4274
|
const error2 = normalizeClientError(cause, "chat.payload_invalid");
|
|
2872
4275
|
return Effect4.sync(() => {
|
|
2873
|
-
|
|
4276
|
+
if (error2.code === "offline.discarded") this.#removePending(clientMessageId);
|
|
4277
|
+
else this.#upsertPending(clientMessageId, input.text, attachments, "failed", failureFromError(error2));
|
|
2874
4278
|
operation?.fail("send_failed");
|
|
2875
4279
|
}).pipe(Effect4.andThen(Effect4.fail(error2)));
|
|
2876
4280
|
})
|
|
@@ -2930,6 +4334,7 @@ var ChatControllerRuntime = class {
|
|
|
2930
4334
|
});
|
|
2931
4335
|
};
|
|
2932
4336
|
upload = (file) => {
|
|
4337
|
+
const fetch = this.#fetch;
|
|
2933
4338
|
const prepareOperation = this.#diagnostics?.startOperation("chat.attachment.prepare");
|
|
2934
4339
|
return Effect4.suspend(() => {
|
|
2935
4340
|
if (!this.#transport) {
|
|
@@ -2950,7 +4355,7 @@ var ChatControllerRuntime = class {
|
|
|
2950
4355
|
prepareOperation?.succeed();
|
|
2951
4356
|
}).pipe(
|
|
2952
4357
|
Effect4.andThen(
|
|
2953
|
-
foreign(() =>
|
|
4358
|
+
foreign(() => fetch(upload.uploadUrl, { method: upload.method, headers: upload.headers, body: bytes })).pipe(
|
|
2954
4359
|
Effect4.flatMap((response) => {
|
|
2955
4360
|
const commitOperation = this.#diagnostics?.startOperation("chat.attachment.commit");
|
|
2956
4361
|
if (!response.ok) {
|
|
@@ -2988,7 +4393,11 @@ var ChatControllerRuntime = class {
|
|
|
2988
4393
|
);
|
|
2989
4394
|
});
|
|
2990
4395
|
};
|
|
2991
|
-
url = (
|
|
4396
|
+
url = (attachment2) => `${this.#apiBaseUrl}/v1/chat/attachments/${encodeURIComponent(attachment2.attachmentId)}/download`;
|
|
4397
|
+
resolveUrl = (attachment2) => Effect4.suspend(() => {
|
|
4398
|
+
if (!this.#transport) return Effect4.fail(new SpaceClientError({ code: "collaboration.unavailable", recoverable: false, message: "Chat file download is unavailable" }));
|
|
4399
|
+
return this.#connection.runPortCommand(() => foreign(() => this.#transport.getDownloadUrl(attachment2.attachmentId)).pipe(Effect4.map((descriptor) => descriptor.downloadUrl))).pipe(Effect4.mapError(normalizeClientError));
|
|
4400
|
+
});
|
|
2992
4401
|
dispose() {
|
|
2993
4402
|
this.#unsubscribeConnection?.();
|
|
2994
4403
|
this.#unsubscribeConnection = null;
|
|
@@ -3006,7 +4415,8 @@ var ChatControllerRuntime = class {
|
|
|
3006
4415
|
this.#ports = ports;
|
|
3007
4416
|
if (!ports) return this.#reset();
|
|
3008
4417
|
this.#status = "ready";
|
|
3009
|
-
this.#
|
|
4418
|
+
if (this.#contiguousSequence === null && ports.sync.getCollaborationExtensionState().chatHeadSequence === null) this.#contiguousSequence = "0";
|
|
4419
|
+
this.#initialCatchUpComplete = this.#contiguousSequence !== null;
|
|
3010
4420
|
this.#unsubscribeEvents = ports.sync.subscribeCollaboration((event) => {
|
|
3011
4421
|
if (event.type === "chat_message") this.#observeMessage(chatMessageFor(event.message), this.#initialCatchUpComplete);
|
|
3012
4422
|
else if (event.type === "chat_read_receipt") this.#mergeReceipt(chatReceiptFor(event.receipt));
|
|
@@ -3018,7 +4428,12 @@ var ChatControllerRuntime = class {
|
|
|
3018
4428
|
}
|
|
3019
4429
|
#scheduleCatchUp() {
|
|
3020
4430
|
const ports = this.#ports;
|
|
3021
|
-
|
|
4431
|
+
const floor = ports?.sync.getCollaborationExtensionState().retainedFloorSequence;
|
|
4432
|
+
if (this.#contiguousSequence === "0" && floor) {
|
|
4433
|
+
this.#contiguousSequence = String(BigInt(floor) - 1n);
|
|
4434
|
+
this.#advanceContiguousSequence();
|
|
4435
|
+
}
|
|
4436
|
+
if (!ports || ports.sync.getSnapshot().connection.phase !== "live" || !catchUpRequest(this.#contiguousSequence, this.#messages.at(-1)?.sequence ?? null, ports)) return;
|
|
3022
4437
|
if (this.#catchUpRunning) {
|
|
3023
4438
|
this.#catchUpRequested = true;
|
|
3024
4439
|
return;
|
|
@@ -3038,7 +4453,6 @@ var ChatControllerRuntime = class {
|
|
|
3038
4453
|
Effect4.ensuring(
|
|
3039
4454
|
Effect4.sync(() => {
|
|
3040
4455
|
this.#catchUpRunning = false;
|
|
3041
|
-
this.#initialCatchUpComplete = true;
|
|
3042
4456
|
if (this.#catchUpRequested) {
|
|
3043
4457
|
this.#catchUpRequested = false;
|
|
3044
4458
|
this.#scheduleCatchUp();
|
|
@@ -3051,17 +4465,19 @@ var ChatControllerRuntime = class {
|
|
|
3051
4465
|
#catchUp(ports) {
|
|
3052
4466
|
return Effect4.suspend(() => {
|
|
3053
4467
|
if (this.#ports !== ports || ports.sync.getSnapshot().connection.phase !== "live") return Effect4.void;
|
|
3054
|
-
const request = catchUpRequest(this.#messages.at(-1)?.sequence ?? null, ports);
|
|
4468
|
+
const request = catchUpRequest(this.#contiguousSequence, this.#messages.at(-1)?.sequence ?? null, ports);
|
|
3055
4469
|
if (!request) return Effect4.void;
|
|
3056
4470
|
return foreign(() => ports.sync.readChatPage(request.input)).pipe(
|
|
3057
4471
|
Effect4.flatMap((result) => {
|
|
3058
4472
|
if (this.#ports !== ports) return Effect4.void;
|
|
3059
4473
|
if (result.status === "cursor_reset") return Effect4.sync(() => this.#applyCursorReset(result.retainedFloorSequence)).pipe(Effect4.andThen(this.#reloadAfterCursorReset(ports)));
|
|
3060
4474
|
if (request.kind === "initial") {
|
|
4475
|
+
this.#completeInitialPage(ports);
|
|
3061
4476
|
this.#hasOlder = result.hasOlder;
|
|
3062
4477
|
this.#publish();
|
|
3063
4478
|
}
|
|
3064
|
-
|
|
4479
|
+
const progressed = request.kind === "initial" || this.#contiguousSequence !== request.input.afterSequence;
|
|
4480
|
+
return progressed ? this.#catchUp(ports) : Effect4.void;
|
|
3065
4481
|
})
|
|
3066
4482
|
);
|
|
3067
4483
|
});
|
|
@@ -3071,11 +4487,12 @@ var ChatControllerRuntime = class {
|
|
|
3071
4487
|
Effect4.tap(
|
|
3072
4488
|
(result) => Effect4.sync(() => {
|
|
3073
4489
|
if (this.#ports !== ports || result.status === "cursor_reset") return;
|
|
4490
|
+
this.#completeInitialPage(ports);
|
|
3074
4491
|
this.#hasOlder = result.hasOlder;
|
|
3075
4492
|
this.#publish();
|
|
3076
4493
|
})
|
|
3077
4494
|
),
|
|
3078
|
-
Effect4.
|
|
4495
|
+
Effect4.andThen(this.#catchUp(ports))
|
|
3079
4496
|
);
|
|
3080
4497
|
}
|
|
3081
4498
|
#applyPage(result) {
|
|
@@ -3091,8 +4508,32 @@ var ChatControllerRuntime = class {
|
|
|
3091
4508
|
this.#hasOlder = false;
|
|
3092
4509
|
this.#historyTruncated = true;
|
|
3093
4510
|
this.#retainedFloorSequence = retainedFloorSequence;
|
|
4511
|
+
this.#messages = Object.freeze(this.#messages.filter((message) => compareChatSequence(message.sequence, retainedFloorSequence) >= 0));
|
|
4512
|
+
this.#contiguousSequence = null;
|
|
4513
|
+
this.#initialCatchUpComplete = false;
|
|
3094
4514
|
this.#publish();
|
|
3095
4515
|
}
|
|
4516
|
+
#completeInitialPage(ports) {
|
|
4517
|
+
const floor = this.#messages[0]?.sequence ?? ports.sync.getCollaborationExtensionState().retainedFloorSequence;
|
|
4518
|
+
this.#contiguousSequence = floor === null ? "0" : String(BigInt(floor) - 1n);
|
|
4519
|
+
this.#advanceContiguousSequence();
|
|
4520
|
+
this.#initialCatchUpComplete = true;
|
|
4521
|
+
}
|
|
4522
|
+
#advanceContiguousSequence(message) {
|
|
4523
|
+
if (this.#contiguousSequence === null) return;
|
|
4524
|
+
let sequence = BigInt(this.#contiguousSequence);
|
|
4525
|
+
if (message) {
|
|
4526
|
+
if (BigInt(message.sequence) !== sequence + 1n) return;
|
|
4527
|
+
sequence += 1n;
|
|
4528
|
+
}
|
|
4529
|
+
for (const message2 of this.#messages) {
|
|
4530
|
+
const candidate = BigInt(message2.sequence);
|
|
4531
|
+
if (candidate <= sequence) continue;
|
|
4532
|
+
if (candidate !== sequence + 1n) break;
|
|
4533
|
+
sequence = candidate;
|
|
4534
|
+
}
|
|
4535
|
+
this.#contiguousSequence = String(sequence);
|
|
4536
|
+
}
|
|
3096
4537
|
#observeMessage(message, countUnread) {
|
|
3097
4538
|
const existing = this.#messages.find((candidate) => candidate.messageId === message.messageId || candidate.sequence === message.sequence);
|
|
3098
4539
|
const next = this.#messagesWith(message, existing);
|
|
@@ -3100,11 +4541,13 @@ var ChatControllerRuntime = class {
|
|
|
3100
4541
|
const unread = this.#unreadCountFor(message, existing, countUnread);
|
|
3101
4542
|
if (this.#messageStateMatches(next, pendingSends, unread)) return existing ?? message;
|
|
3102
4543
|
this.#messages = next;
|
|
4544
|
+
this.#advanceContiguousSequence(message);
|
|
3103
4545
|
this.#pendingSends = Object.freeze(pendingSends);
|
|
3104
4546
|
this.#unreadCount = unread;
|
|
3105
4547
|
this.#status = "ready";
|
|
3106
4548
|
this.#lastError = null;
|
|
3107
4549
|
this.#publish();
|
|
4550
|
+
if (!this.#catchUpRunning && countUnread) this.#scheduleCatchUp();
|
|
3108
4551
|
return message;
|
|
3109
4552
|
}
|
|
3110
4553
|
#messagesWith(message, existing) {
|
|
@@ -3149,6 +4592,7 @@ var ChatControllerRuntime = class {
|
|
|
3149
4592
|
}
|
|
3150
4593
|
#reset() {
|
|
3151
4594
|
this.#catchUpRequested = false;
|
|
4595
|
+
this.#contiguousSequence = null;
|
|
3152
4596
|
this.#hasOlder = false;
|
|
3153
4597
|
this.#historyTruncated = false;
|
|
3154
4598
|
this.#initialCatchUpComplete = true;
|
|
@@ -3190,13 +4634,13 @@ function validateUpload(file, bytes, clientAttachmentId) {
|
|
|
3190
4634
|
return upload;
|
|
3191
4635
|
}
|
|
3192
4636
|
function bytesFor(file) {
|
|
3193
|
-
return "
|
|
4637
|
+
return "fileName" in file ? Effect4.succeed(file.bytes) : foreign(() => file.arrayBuffer());
|
|
3194
4638
|
}
|
|
3195
|
-
function catchUpRequest(latestSequence, ports) {
|
|
4639
|
+
function catchUpRequest(contiguousSequence, latestSequence, ports) {
|
|
3196
4640
|
const extension = ports.sync.getCollaborationExtensionState();
|
|
3197
|
-
const head = extension.chatHeadSequence;
|
|
3198
|
-
if (!extension.negotiated || head === null ||
|
|
3199
|
-
return
|
|
4641
|
+
const head = latestSequence !== null && (extension.chatHeadSequence === null || compareChatSequence(latestSequence, extension.chatHeadSequence) > 0) ? latestSequence : extension.chatHeadSequence;
|
|
4642
|
+
if (!extension.negotiated || head === null || contiguousSequence !== null && compareChatSequence(contiguousSequence, head) >= 0) return null;
|
|
4643
|
+
return contiguousSequence === null ? { kind: "initial", input: { limit: MAX_CHAT_PAGE_SIZE } } : { kind: "newer", input: { afterSequence: contiguousSequence, limit: MAX_CHAT_PAGE_SIZE } };
|
|
3200
4644
|
}
|
|
3201
4645
|
function chatError(message) {
|
|
3202
4646
|
return new SpaceClientError({ code: "chat.payload_invalid", recoverable: false, message });
|
|
@@ -3228,7 +4672,10 @@ var makeMediaController = (connection, store, selection, diagnostics) => Effect5
|
|
|
3228
4672
|
fork,
|
|
3229
4673
|
diagnostics
|
|
3230
4674
|
);
|
|
3231
|
-
yield* connection.setInitialMedia(
|
|
4675
|
+
yield* connection.setInitialMedia(
|
|
4676
|
+
(intent) => controller.captureInitial(intent),
|
|
4677
|
+
() => controller.initialIntent()
|
|
4678
|
+
);
|
|
3232
4679
|
yield* Effect5.addFinalizer(() => Effect5.sync(() => controller.dispose()));
|
|
3233
4680
|
controller.refreshDevicesInBackground();
|
|
3234
4681
|
return controller;
|
|
@@ -3242,6 +4689,10 @@ var MediaControllerRuntime = class {
|
|
|
3242
4689
|
#fork;
|
|
3243
4690
|
#diagnostics;
|
|
3244
4691
|
#tracks = /* @__PURE__ */ new Map();
|
|
4692
|
+
#observedPublicationIds = /* @__PURE__ */ new Map();
|
|
4693
|
+
#intentRevisions = /* @__PURE__ */ new Map();
|
|
4694
|
+
#pendingEnables = /* @__PURE__ */ new Map();
|
|
4695
|
+
#pendingSources = /* @__PURE__ */ new Set();
|
|
3245
4696
|
#requestGenerations = /* @__PURE__ */ new Map();
|
|
3246
4697
|
#intent = { microphone: true, camera: true };
|
|
3247
4698
|
#requests = Object.freeze([]);
|
|
@@ -3319,14 +4770,22 @@ var MediaControllerRuntime = class {
|
|
|
3319
4770
|
Effect5.tapError(() => Effect5.sync(() => operation?.fail("decline_failed")))
|
|
3320
4771
|
);
|
|
3321
4772
|
};
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
4773
|
+
initialIntent() {
|
|
4774
|
+
return { ...this.#intent };
|
|
4775
|
+
}
|
|
4776
|
+
captureInitial(intent) {
|
|
4777
|
+
if (!intent.microphone && !intent.camera) {
|
|
4778
|
+
this.#intent = { microphone: this.#tracks.has("microphone"), camera: this.#tracks.has("camera") };
|
|
4779
|
+
this.#publish();
|
|
4780
|
+
return Effect5.succeed(streamFromTracks([...this.#tracks.values()]));
|
|
4781
|
+
}
|
|
4782
|
+
return foreign2(() => this.#selection.getUserMedia({ audio: intent.microphone, video: intent.camera })).pipe(
|
|
3325
4783
|
Effect5.flatMap(
|
|
3326
|
-
(stream) => Effect5.try({ try: () => selectInitialTracks(stream,
|
|
4784
|
+
(stream) => Effect5.try({ try: () => selectInitialTracks(stream, intent), catch: (cause) => cause }).pipe(
|
|
3327
4785
|
Effect5.tap(
|
|
3328
4786
|
(tracks) => Effect5.sync(() => {
|
|
3329
4787
|
for (const [source, track] of tracks) this.#tracks.set(source, track);
|
|
4788
|
+
this.#intent = { microphone: this.#tracks.has("microphone"), camera: this.#tracks.has("camera") };
|
|
3330
4789
|
this.#publish();
|
|
3331
4790
|
})
|
|
3332
4791
|
),
|
|
@@ -3368,161 +4827,268 @@ var MediaControllerRuntime = class {
|
|
|
3368
4827
|
#set(source, enabled) {
|
|
3369
4828
|
const operation = this.#diagnostics?.startOperation(`${source}.${enabled ? "publish" : "unpublish"}`);
|
|
3370
4829
|
operation?.observe("observed", "intent");
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
4830
|
+
let previousIntent = this.#intent[source];
|
|
4831
|
+
let intentRevision = 0;
|
|
4832
|
+
let earlyPause = null;
|
|
4833
|
+
const localPause = Effect5.suspend(() => {
|
|
4834
|
+
previousIntent = this.#intent[source];
|
|
4835
|
+
intentRevision = (this.#intentRevisions.get(source) ?? 0) + 1;
|
|
4836
|
+
this.#intentRevisions.set(source, intentRevision);
|
|
4837
|
+
this.#intent[source] = enabled;
|
|
4838
|
+
if (enabled) this.#pendingEnables.set(source, intentRevision);
|
|
4839
|
+
this.#ports?.media.setLocalSourceIntent?.(source, enabled);
|
|
4840
|
+
if (!enabled && this.#ports) earlyPause = this.#beginLocalPause(this.#ports, source);
|
|
4841
|
+
return Effect5.void;
|
|
4842
|
+
});
|
|
4843
|
+
return localPause.pipe(
|
|
4844
|
+
Effect5.andThen(
|
|
4845
|
+
this.#serialize(
|
|
4846
|
+
source,
|
|
4847
|
+
this.#connection.runCommand((ports) => {
|
|
4848
|
+
let captured = null;
|
|
4849
|
+
let prepared = false;
|
|
4850
|
+
const action = source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled";
|
|
4851
|
+
return Effect5.suspend(() => {
|
|
4852
|
+
if (!enabled) {
|
|
4853
|
+
let paused = false;
|
|
4854
|
+
return (earlyPause ? foreign2(() => earlyPause) : this.#localTarget(ports, source, false)).pipe(
|
|
4855
|
+
Effect5.tap(
|
|
4856
|
+
() => Effect5.sync(() => {
|
|
4857
|
+
paused = true;
|
|
4858
|
+
})
|
|
4859
|
+
),
|
|
4860
|
+
Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "local_track_state"))),
|
|
4861
|
+
Effect5.andThen(source === "microphone" ? foreign2(() => ports.sync.setMicrophoneEnabled(false)) : foreign2(() => ports.sync.setCameraEnabled(false))),
|
|
4862
|
+
Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "sync_commit"))),
|
|
4863
|
+
Effect5.tap(() => Effect5.sync(() => operation?.succeed())),
|
|
4864
|
+
Effect5.asVoid,
|
|
4865
|
+
Effect5.catch((cause) => {
|
|
4866
|
+
if (paused && !isSyncRejection(cause) || this.#intentRevisions.get(source) !== intentRevision) return Effect5.fail(cause);
|
|
4867
|
+
this.#intent[source] = previousIntent;
|
|
4868
|
+
ports.media.setLocalSourceIntent?.(source, previousIntent);
|
|
4869
|
+
return this.#localTarget(ports, source, previousIntent).pipe(
|
|
4870
|
+
Effect5.catch(() => Effect5.void),
|
|
4871
|
+
Effect5.andThen(Effect5.fail(cause))
|
|
4872
|
+
);
|
|
4873
|
+
})
|
|
4874
|
+
);
|
|
4875
|
+
}
|
|
4876
|
+
const acquire = enabled && !this.#tracks.has(source) ? this.#captureSource(source).pipe(
|
|
4877
|
+
Effect5.tap(
|
|
4878
|
+
(track) => Effect5.sync(() => {
|
|
4879
|
+
captured = track;
|
|
4880
|
+
try {
|
|
4881
|
+
this.#assertActivePorts(ports, action);
|
|
4882
|
+
} catch (cause) {
|
|
4883
|
+
track.stop();
|
|
4884
|
+
throw cause;
|
|
4885
|
+
}
|
|
4886
|
+
this.#tracks.set(source, track);
|
|
4887
|
+
ports.media.prepareLocalTrack(source, track);
|
|
4888
|
+
prepared = true;
|
|
4889
|
+
operation?.observe("observed", "local_track_state");
|
|
4890
|
+
this.#publish();
|
|
4891
|
+
})
|
|
4892
|
+
)
|
|
4893
|
+
) : Effect5.void;
|
|
4894
|
+
return acquire.pipe(
|
|
4895
|
+
Effect5.asVoid,
|
|
4896
|
+
Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "local_track_state"))),
|
|
4897
|
+
Effect5.andThen(source === "microphone" ? foreign2(() => ports.sync.setMicrophoneEnabled(enabled)) : foreign2(() => ports.sync.setCameraEnabled(enabled))),
|
|
4898
|
+
Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "sync_commit"))),
|
|
4899
|
+
Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, action))),
|
|
4900
|
+
Effect5.tap(
|
|
4901
|
+
() => Effect5.sync(() => {
|
|
4902
|
+
operation?.observe("observed", "sfu_publication");
|
|
4903
|
+
operation?.succeed();
|
|
4904
|
+
})
|
|
4905
|
+
),
|
|
4906
|
+
Effect5.asVoid,
|
|
4907
|
+
Effect5.catch(
|
|
4908
|
+
(cause) => this.#rollbackCapture({
|
|
4909
|
+
action,
|
|
4910
|
+
cause,
|
|
4911
|
+
fallback: Effect5.void,
|
|
4912
|
+
media: ports.media,
|
|
4913
|
+
permissionMessage: `${source} permission was denied`,
|
|
4914
|
+
prepared,
|
|
4915
|
+
restoreIntent: () => {
|
|
4916
|
+
if (this.#intentRevisions.get(source) === intentRevision) this.#intent[source] = previousIntent;
|
|
4917
|
+
},
|
|
4918
|
+
source,
|
|
4919
|
+
track: captured
|
|
4920
|
+
})
|
|
4921
|
+
)
|
|
4922
|
+
);
|
|
4923
|
+
});
|
|
4924
|
+
})
|
|
4925
|
+
)
|
|
4926
|
+
)
|
|
3429
4927
|
).pipe(
|
|
3430
4928
|
Effect5.mapError(normalizeClientError),
|
|
3431
|
-
Effect5.tapError(() => Effect5.sync(() => operation?.fail("media_failed")))
|
|
4929
|
+
Effect5.tapError(() => Effect5.sync(() => operation?.fail("media_failed"))),
|
|
4930
|
+
Effect5.ensuring(
|
|
4931
|
+
Effect5.sync(() => {
|
|
4932
|
+
if (this.#pendingEnables.get(source) === intentRevision) this.#pendingEnables.delete(source);
|
|
4933
|
+
})
|
|
4934
|
+
)
|
|
3432
4935
|
);
|
|
3433
4936
|
}
|
|
3434
4937
|
#startScreen() {
|
|
3435
4938
|
const operation = this.#diagnostics?.startOperation("screen.start");
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
this.#
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
this.#assertActivePorts(ports, "startScreenShare");
|
|
3457
|
-
track = requireDisplayVideoTrack(captured);
|
|
3458
|
-
this.#tracks.set("screen", track);
|
|
3459
|
-
ports.media.prepareLocalTrack("screen", track);
|
|
3460
|
-
prepared = true;
|
|
3461
|
-
operation?.observe("observed", "track_acquisition");
|
|
3462
|
-
track.addEventListener("ended", () => this.#handleScreenEnded(track));
|
|
3463
|
-
this.#screenEndedPending = false;
|
|
3464
|
-
this.#publish();
|
|
3465
|
-
})
|
|
3466
|
-
),
|
|
3467
|
-
Effect5.andThen(foreign2(() => ports.sync.setScreenShareEnabled(true))),
|
|
3468
|
-
Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "sync_commit"))),
|
|
3469
|
-
Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "startScreenShare"))),
|
|
3470
|
-
Effect5.tap(
|
|
3471
|
-
() => Effect5.sync(() => {
|
|
3472
|
-
operation?.observe("observed", "sfu_publication");
|
|
4939
|
+
let intentRevision = 0;
|
|
4940
|
+
return Effect5.sync(() => {
|
|
4941
|
+
intentRevision = (this.#intentRevisions.get("screen") ?? 0) + 1;
|
|
4942
|
+
this.#intentRevisions.set("screen", intentRevision);
|
|
4943
|
+
this.#pendingEnables.set("screen", intentRevision);
|
|
4944
|
+
this.#ports?.media.setLocalSourceIntent?.("screen", true);
|
|
4945
|
+
}).pipe(
|
|
4946
|
+
Effect5.andThen(
|
|
4947
|
+
this.#serialize(
|
|
4948
|
+
"screen",
|
|
4949
|
+
this.#connection.runCommand((ports) => {
|
|
4950
|
+
let stream = null;
|
|
4951
|
+
let track = null;
|
|
4952
|
+
let prepared = false;
|
|
4953
|
+
return Effect5.suspend(() => {
|
|
4954
|
+
if (this.#tracks.has("screen")) {
|
|
4955
|
+
operation?.notObservable("permission", "already_active");
|
|
4956
|
+
operation?.notObservable("track_acquisition", "already_active");
|
|
4957
|
+
operation?.notObservable("sync_commit", "already_active");
|
|
4958
|
+
operation?.notObservable("sfu_publication", "already_active");
|
|
3473
4959
|
operation?.succeed();
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
4960
|
+
return Effect5.void;
|
|
4961
|
+
}
|
|
4962
|
+
return foreign2(() => this.#selection.getDisplayMedia({ video: true, audio: false })).pipe(
|
|
4963
|
+
Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "permission"))),
|
|
4964
|
+
Effect5.tap(
|
|
4965
|
+
(captured) => Effect5.sync(() => {
|
|
4966
|
+
stream = captured;
|
|
4967
|
+
this.#assertActivePorts(ports, "startScreenShare");
|
|
4968
|
+
track = requireDisplayVideoTrack(captured);
|
|
4969
|
+
this.#tracks.set("screen", track);
|
|
4970
|
+
ports.media.prepareLocalTrack("screen", track);
|
|
4971
|
+
prepared = true;
|
|
4972
|
+
operation?.observe("observed", "track_acquisition");
|
|
4973
|
+
track.addEventListener("ended", () => this.#handleScreenEnded(track));
|
|
4974
|
+
this.#screenEndedPending = false;
|
|
4975
|
+
this.#publish();
|
|
4976
|
+
})
|
|
4977
|
+
),
|
|
4978
|
+
Effect5.andThen(foreign2(() => ports.sync.setScreenShareEnabled(true))),
|
|
4979
|
+
Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "sync_commit"))),
|
|
4980
|
+
Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "startScreenShare"))),
|
|
4981
|
+
Effect5.tap(
|
|
4982
|
+
() => Effect5.sync(() => {
|
|
4983
|
+
operation?.observe("observed", "sfu_publication");
|
|
4984
|
+
operation?.succeed();
|
|
4985
|
+
})
|
|
4986
|
+
),
|
|
4987
|
+
Effect5.asVoid,
|
|
4988
|
+
Effect5.catch((cause) => this.#rollbackCapture({ action: "startScreenShare", cause, fallback: Effect5.sync(() => stopStream(stream)), media: ports.media, permissionMessage: "Screen sharing permission was denied", prepared, source: "screen", track }))
|
|
4989
|
+
);
|
|
4990
|
+
});
|
|
4991
|
+
})
|
|
4992
|
+
)
|
|
4993
|
+
)
|
|
3481
4994
|
).pipe(
|
|
3482
4995
|
Effect5.mapError(normalizeClientError),
|
|
3483
|
-
Effect5.tapError(() => Effect5.sync(() => operation?.fail("screen_start_failed")))
|
|
4996
|
+
Effect5.tapError(() => Effect5.sync(() => operation?.fail("screen_start_failed"))),
|
|
4997
|
+
Effect5.ensuring(
|
|
4998
|
+
Effect5.sync(() => {
|
|
4999
|
+
if (this.#pendingEnables.get("screen") === intentRevision) this.#pendingEnables.delete("screen");
|
|
5000
|
+
})
|
|
5001
|
+
)
|
|
3484
5002
|
);
|
|
3485
5003
|
}
|
|
3486
5004
|
#stopScreen() {
|
|
3487
5005
|
const operation = this.#diagnostics?.startOperation("screen.stop");
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
5006
|
+
let intentRevision = 0;
|
|
5007
|
+
let earlyPause = null;
|
|
5008
|
+
const localPause = Effect5.suspend(() => {
|
|
5009
|
+
intentRevision = (this.#intentRevisions.get("screen") ?? 0) + 1;
|
|
5010
|
+
this.#intentRevisions.set("screen", intentRevision);
|
|
5011
|
+
this.#ports?.media.setLocalSourceIntent?.("screen", false);
|
|
5012
|
+
if (this.#ports) earlyPause = this.#beginLocalPause(this.#ports, "screen");
|
|
5013
|
+
return Effect5.void;
|
|
5014
|
+
});
|
|
5015
|
+
return localPause.pipe(
|
|
5016
|
+
Effect5.andThen(
|
|
5017
|
+
this.#serialize(
|
|
5018
|
+
"screen",
|
|
5019
|
+
this.#connection.runCommand(
|
|
5020
|
+
(ports) => Effect5.suspend(() => {
|
|
5021
|
+
const track = this.#tracks.get("screen");
|
|
5022
|
+
let paused = false;
|
|
5023
|
+
return (earlyPause ? foreign2(() => earlyPause) : this.#localTarget(ports, "screen", false)).pipe(
|
|
5024
|
+
Effect5.tap(
|
|
5025
|
+
() => Effect5.sync(() => {
|
|
5026
|
+
paused = true;
|
|
5027
|
+
})
|
|
5028
|
+
),
|
|
5029
|
+
Effect5.andThen(foreign2(() => ports.sync.setScreenShareEnabled(false))),
|
|
5030
|
+
Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "stopScreenShare"))),
|
|
5031
|
+
Effect5.andThen(foreign2(() => ports.media.clearPreparedLocalTrack("screen"))),
|
|
5032
|
+
Effect5.tap(
|
|
5033
|
+
() => Effect5.sync(() => {
|
|
5034
|
+
this.#assertActivePorts(ports, "stopScreenShare");
|
|
5035
|
+
if (this.#tracks.get("screen") === track) this.#tracks.delete("screen");
|
|
5036
|
+
track?.stop();
|
|
5037
|
+
this.#screenEndedPending = false;
|
|
5038
|
+
this.#publish();
|
|
5039
|
+
operation?.observe("observed", "stop_confirmation");
|
|
5040
|
+
operation?.succeed();
|
|
5041
|
+
})
|
|
5042
|
+
),
|
|
5043
|
+
Effect5.asVoid,
|
|
5044
|
+
Effect5.catch(
|
|
5045
|
+
(cause) => paused && !isSyncRejection(cause) || this.#intentRevisions.get("screen") !== intentRevision ? Effect5.fail(cause) : Effect5.sync(() => ports.media.setLocalSourceIntent?.("screen", true)).pipe(
|
|
5046
|
+
Effect5.andThen(this.#localTarget(ports, "screen", true)),
|
|
5047
|
+
Effect5.catch(() => Effect5.void),
|
|
5048
|
+
Effect5.andThen(Effect5.fail(cause))
|
|
5049
|
+
)
|
|
5050
|
+
)
|
|
5051
|
+
);
|
|
5052
|
+
})
|
|
5053
|
+
)
|
|
5054
|
+
)
|
|
3515
5055
|
)
|
|
3516
5056
|
).pipe(
|
|
3517
5057
|
Effect5.mapError(normalizeClientError),
|
|
3518
5058
|
Effect5.tapError(() => Effect5.sync(() => operation?.fail("screen_stop_failed")))
|
|
3519
5059
|
);
|
|
3520
5060
|
}
|
|
5061
|
+
#beginLocalPause(ports, source) {
|
|
5062
|
+
const pause = Effect5.runPromise(this.#localTarget(ports, source, false));
|
|
5063
|
+
void pause.catch(() => void 0);
|
|
5064
|
+
return pause;
|
|
5065
|
+
}
|
|
3521
5066
|
#serialize(source, effect) {
|
|
3522
|
-
return this.#gates.get(source).withPermit(
|
|
5067
|
+
return this.#gates.get(source).withPermit(
|
|
5068
|
+
Effect5.sync(() => {
|
|
5069
|
+
this.#pendingSources.add(source);
|
|
5070
|
+
this.#publish();
|
|
5071
|
+
}).pipe(
|
|
5072
|
+
Effect5.andThen(effect),
|
|
5073
|
+
Effect5.ensuring(
|
|
5074
|
+
Effect5.sync(() => {
|
|
5075
|
+
this.#pendingSources.delete(source);
|
|
5076
|
+
this.#publish();
|
|
5077
|
+
})
|
|
5078
|
+
)
|
|
5079
|
+
)
|
|
5080
|
+
);
|
|
5081
|
+
}
|
|
5082
|
+
#localTarget(ports, source, enabled) {
|
|
5083
|
+
return foreign2(() => ports.media.setLocalPublicationTarget({ operationId: `privacy-${source}-${enabled ? "restore" : "off"}`, participantId: ports.sync.getSnapshot().participantId ?? "", source, enabled })).pipe(
|
|
5084
|
+
Effect5.flatMap(
|
|
5085
|
+
(result) => result.outcome === "confirmed" || result.outcome === "satisfied" || !enabled && result.errorCode === "source_unavailable" ? Effect5.void : Effect5.fail(new SpaceClientError({ code: "media.capture_failed", recoverable: true, message: `Could not ${enabled ? "restore" : "pause"} ${source}` }))
|
|
5086
|
+
)
|
|
5087
|
+
);
|
|
3523
5088
|
}
|
|
3524
5089
|
#captureSource(source) {
|
|
3525
5090
|
return foreign2(() => this.#selection.getUserMedia({ audio: source === "microphone", video: source === "camera" })).pipe(
|
|
5091
|
+
Effect5.mapError((cause) => captureError(cause, source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled", `${source} permission was denied`)),
|
|
3526
5092
|
Effect5.tap(() => this.#refreshDevices().pipe(Effect5.ignore)),
|
|
3527
5093
|
Effect5.flatMap(
|
|
3528
5094
|
(stream) => Effect5.try({
|
|
@@ -3568,8 +5134,8 @@ var MediaControllerRuntime = class {
|
|
|
3568
5134
|
input.restoreIntent?.();
|
|
3569
5135
|
const rollback = input.track ? this.#discardCaptured(input.source, input.track, input.media, input.prepared) : input.fallback;
|
|
3570
5136
|
const captureWasUnavailable = input.action === "startScreenShare" && !input.prepared && input.track === null;
|
|
3571
|
-
const
|
|
3572
|
-
return rollback.pipe(Effect5.andThen(Effect5.sync(() => this.#publish())), Effect5.andThen(Effect5.fail(
|
|
5137
|
+
const failure3 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
|
|
5138
|
+
return rollback.pipe(Effect5.andThen(Effect5.sync(() => this.#publish())), Effect5.andThen(Effect5.fail(failure3)));
|
|
3573
5139
|
}
|
|
3574
5140
|
#assertActivePorts(ports, action) {
|
|
3575
5141
|
if (this.#connection.getSnapshot().state === "live" && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
|
|
@@ -3582,6 +5148,7 @@ var MediaControllerRuntime = class {
|
|
|
3582
5148
|
this.#unsubscribeMedia = null;
|
|
3583
5149
|
this.#unsubscribeRequests = null;
|
|
3584
5150
|
this.#unsubscribeSync = null;
|
|
5151
|
+
if (this.#ports?.media !== ports?.media) this.#observedPublicationIds.clear();
|
|
3585
5152
|
this.#ports = ports;
|
|
3586
5153
|
if (!ports) {
|
|
3587
5154
|
for (const track of this.#tracks.values()) track.stop();
|
|
@@ -3591,11 +5158,57 @@ var MediaControllerRuntime = class {
|
|
|
3591
5158
|
this.#publish();
|
|
3592
5159
|
return;
|
|
3593
5160
|
}
|
|
3594
|
-
this.#unsubscribeMedia = ports.media.subscribe(() =>
|
|
5161
|
+
this.#unsubscribeMedia = ports.media.subscribe(() => {
|
|
5162
|
+
this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
|
|
5163
|
+
this.#publish();
|
|
5164
|
+
});
|
|
3595
5165
|
this.#unsubscribeRequests = ports.sync.onDirectedRequest((request) => this.#request(request));
|
|
3596
|
-
this.#unsubscribeSync = ports.sync.subscribe((snapshot) =>
|
|
5166
|
+
this.#unsubscribeSync = ports.sync.subscribe((snapshot) => {
|
|
5167
|
+
this.#removeRequestsFromMissingParticipants(snapshot);
|
|
5168
|
+
this.#applyRemoteMediaState(ports, snapshot);
|
|
5169
|
+
this.#applyForcedMediaState(ports, snapshot);
|
|
5170
|
+
});
|
|
5171
|
+
this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
|
|
5172
|
+
this.#applyForcedMediaState(ports, ports.sync.getSnapshot());
|
|
5173
|
+
this.#publish();
|
|
5174
|
+
}
|
|
5175
|
+
#applyRemoteMediaState(ports, snapshot) {
|
|
5176
|
+
for (const publication of ports.media.getSnapshot().remoteTracks) {
|
|
5177
|
+
publication.track.enabled = snapshot.media?.items.some((item) => item.participantId === publication.participantId && item.source === publication.source && item.enabled) ?? false;
|
|
5178
|
+
}
|
|
5179
|
+
}
|
|
5180
|
+
#applyForcedMediaState(ports, snapshot) {
|
|
5181
|
+
if (!canApplyForcedMediaState(snapshot)) return;
|
|
5182
|
+
for (const source of MEDIA_SOURCES) {
|
|
5183
|
+
const projected = snapshot.media.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
|
|
5184
|
+
if (projected?.publicationId) {
|
|
5185
|
+
this.#observedPublicationIds.set(source, projected.publicationId);
|
|
5186
|
+
continue;
|
|
5187
|
+
}
|
|
5188
|
+
const previousPublicationId = this.#observedPublicationIds.get(source);
|
|
5189
|
+
if (!this.#observedPublicationIds.delete(source)) continue;
|
|
5190
|
+
const local = ports.media.getSnapshot().localTracks.find((publication) => publication.source === source);
|
|
5191
|
+
if (local && local.publicationId !== previousPublicationId) continue;
|
|
5192
|
+
this.#pauseForcedSource(ports, source);
|
|
5193
|
+
}
|
|
5194
|
+
}
|
|
5195
|
+
#pauseForcedSource(ports, source) {
|
|
5196
|
+
const pendingEnable = this.#hasPendingEnable(source);
|
|
5197
|
+
if (!pendingEnable) {
|
|
5198
|
+
this.#intentRevisions.set(source, (this.#intentRevisions.get(source) ?? 0) + 1);
|
|
5199
|
+
if (source !== "screen") this.#intent[source] = false;
|
|
5200
|
+
}
|
|
5201
|
+
const track = this.#tracks.get(source);
|
|
5202
|
+
if (track) track.enabled = false;
|
|
5203
|
+
ports.media.setLocalSourceIntent?.(source, false);
|
|
5204
|
+
if (ports.media.closeForcedLocalPublication) void ports.media.closeForcedLocalPublication(source).catch(() => void 0);
|
|
5205
|
+
if (pendingEnable) ports.media.setLocalSourceIntent?.(source, true);
|
|
3597
5206
|
this.#publish();
|
|
3598
5207
|
}
|
|
5208
|
+
#hasPendingEnable(source) {
|
|
5209
|
+
const revision = this.#pendingEnables.get(source);
|
|
5210
|
+
return revision !== void 0 && revision === this.#intentRevisions.get(source);
|
|
5211
|
+
}
|
|
3599
5212
|
#request(request) {
|
|
3600
5213
|
if (request.expires_at_ms <= this.#now()) return;
|
|
3601
5214
|
const control = this.#ports?.sync.getSnapshot().optimisticControl ?? this.#ports?.sync.getSnapshot().control;
|
|
@@ -3665,7 +5278,11 @@ var MediaControllerRuntime = class {
|
|
|
3665
5278
|
const current = this.#store.getSnapshot();
|
|
3666
5279
|
const snapshot = this.#ports?.media.getSnapshot();
|
|
3667
5280
|
const state = this.#connection.getSnapshot().state;
|
|
3668
|
-
const local = Object.freeze({
|
|
5281
|
+
const local = Object.freeze({
|
|
5282
|
+
microphone: localMedia("microphone", this.#tracks, snapshot, this.#intent.microphone, state, this.#pendingSources.has("microphone")),
|
|
5283
|
+
camera: localMedia("camera", this.#tracks, snapshot, this.#intent.camera, state, this.#pendingSources.has("camera")),
|
|
5284
|
+
screen: localMedia("screen", this.#tracks, snapshot, false, state, this.#pendingSources.has("screen"))
|
|
5285
|
+
});
|
|
3669
5286
|
const remote = Object.freeze((snapshot?.remoteTracks ?? []).map((track) => Object.freeze({ participantId: track.participantId, source: track.source, publicationId: track.publicationId, track: track.track })));
|
|
3670
5287
|
const media = Object.freeze({ ...current.media, local, remote, screenShare: local.screen, incomingRequests: this.#requests });
|
|
3671
5288
|
if (!sameMediaSlice(current.media, media)) this.#store.updateMedia(media);
|
|
@@ -3702,12 +5319,18 @@ function initialTrackEntries(microphone, camera) {
|
|
|
3702
5319
|
if (camera) entries.push(["camera", camera]);
|
|
3703
5320
|
return entries;
|
|
3704
5321
|
}
|
|
3705
|
-
function localMedia(source, tracks, snapshot, intended, connectionState) {
|
|
5322
|
+
function localMedia(source, tracks, snapshot, intended, connectionState, pending) {
|
|
3706
5323
|
const track = tracks.get(source) ?? null;
|
|
3707
5324
|
const publication = snapshot?.localTracks.find((candidate) => candidate.source === source);
|
|
5325
|
+
if (locallyMuted(intended, track, publication)) return Object.freeze({ source, state: "disabled", track });
|
|
5326
|
+
if (pending) return Object.freeze({ source, state: "requesting", track });
|
|
3708
5327
|
if (publication?.enabled) return Object.freeze({ source, state: "enabled", track });
|
|
5328
|
+
if (publication && connectionState === "live") return Object.freeze({ source, state: "disabled", track });
|
|
3709
5329
|
return Object.freeze({ source, state: localMediaState(source, track, intended, connectionState), track });
|
|
3710
5330
|
}
|
|
5331
|
+
function locallyMuted(intended, track, publication) {
|
|
5332
|
+
return !intended && track?.enabled === false && publication?.enabled === false;
|
|
5333
|
+
}
|
|
3711
5334
|
function localMediaState(source, track, intended, connectionState) {
|
|
3712
5335
|
if (!mediaIsDesired(source, track, intended)) return inactiveLocalMediaState(connectionState, track);
|
|
3713
5336
|
if (connectionState === "joining" || track) return "requesting";
|
|
@@ -3750,19 +5373,268 @@ function sameDeviceList(left, right) {
|
|
|
3750
5373
|
return left.length === right.length && left.every((device, index) => device.deviceId === right[index]?.deviceId && device.label === right[index]?.label);
|
|
3751
5374
|
}
|
|
3752
5375
|
function captureError(cause, action, permissionMessage) {
|
|
5376
|
+
if (cause instanceof ConnectionError) return cause;
|
|
3753
5377
|
if (isPermissionDenied(cause)) return new ConnectionError({ code: "permission_denied", action, recoverable: true, message: permissionMessage }, { cause });
|
|
3754
|
-
|
|
5378
|
+
const failure3 = describeMediaCaptureError(cause);
|
|
5379
|
+
return new ConnectionError({ ...failure3, action, message: action === "startScreenShare" ? "Screen sharing could not start. Check screen recording permission and try again in a supported browser or app." : failure3.message }, { cause });
|
|
3755
5380
|
}
|
|
3756
5381
|
function isPermissionDenied(cause) {
|
|
3757
|
-
return
|
|
5382
|
+
return describeMediaCaptureError(cause).code === "permission_denied";
|
|
3758
5383
|
}
|
|
3759
5384
|
function isAccessInvalid2(cause) {
|
|
3760
5385
|
return typeof cause === "object" && cause !== null && "code" in cause && (cause.code === "access.invalid" || cause.code === "invalid_access");
|
|
3761
5386
|
}
|
|
5387
|
+
function isSyncRejection(cause) {
|
|
5388
|
+
return isAccessInvalid2(cause) || cause instanceof V1SyncError && cause.code === "terminal_failure";
|
|
5389
|
+
}
|
|
5390
|
+
function canApplyForcedMediaState(snapshot) {
|
|
5391
|
+
return !!snapshot.media && !!snapshot.participantId && snapshot.control?.status !== "ended" && snapshot.connection.phase !== "terminal";
|
|
5392
|
+
}
|
|
3762
5393
|
|
|
3763
|
-
// src/space-client/
|
|
5394
|
+
// src/space-client/offline-actions.ts
|
|
3764
5395
|
import { Context as Context7, Effect as Effect6, Layer as Layer7 } from "effect";
|
|
3765
5396
|
|
|
5397
|
+
// src/space-client/offline-actions-storage.ts
|
|
5398
|
+
import { Schema as Schema3 } from "effect";
|
|
5399
|
+
var OFFLINE_ACTION_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
5400
|
+
function browserOfflineActionsStorage() {
|
|
5401
|
+
if (typeof window === "undefined") return void 0;
|
|
5402
|
+
try {
|
|
5403
|
+
return globalThis.localStorage;
|
|
5404
|
+
} catch {
|
|
5405
|
+
return void 0;
|
|
5406
|
+
}
|
|
5407
|
+
}
|
|
5408
|
+
var attachment = Schema3.Struct({ attachmentId: Schema3.String, fileName: Schema3.String, mimeType: ChatAttachmentMimeTypeSchema, byteLength: Schema3.Number });
|
|
5409
|
+
var StoredOfflineAction = Schema3.Struct({
|
|
5410
|
+
id: Schema3.String,
|
|
5411
|
+
kind: Schema3.Literals(["chat_message", "hand_raise", "hand_lower"]),
|
|
5412
|
+
text: Schema3.NullOr(Schema3.String),
|
|
5413
|
+
queuedAt: Schema3.Number,
|
|
5414
|
+
clientMessageId: Schema3.optional(Schema3.String),
|
|
5415
|
+
attachments: Schema3.optional(Schema3.Array(attachment))
|
|
5416
|
+
});
|
|
5417
|
+
var StoredOfflineActions = Schema3.Struct({
|
|
5418
|
+
version: Schema3.Literal(1),
|
|
5419
|
+
subject: Schema3.Struct({ tenantId: Schema3.String, spaceId: Schema3.String, episodeId: Schema3.String, participantId: Schema3.String, participantGeneration: Schema3.Number }),
|
|
5420
|
+
actions: Schema3.Array(StoredOfflineAction)
|
|
5421
|
+
});
|
|
5422
|
+
var decodeOfflineActions = Schema3.decodeUnknownSync(StoredOfflineActions);
|
|
5423
|
+
|
|
5424
|
+
// src/space-client/offline-actions.ts
|
|
5425
|
+
var OfflineActionsService = class extends Context7.Service()("@chalk/client/OfflineActions") {
|
|
5426
|
+
};
|
|
5427
|
+
var sameSubject = (current, saved) => !!current && !!saved && current.tenantId === saved.tenantId && current.spaceId === saved.spaceId && current.episodeId === saved.episodeId && current.participantId === saved.participantId && current.participantGeneration === saved.participantGeneration;
|
|
5428
|
+
var terminalConnection = (snapshot) => snapshot.state === "left" || snapshot.state === "leaving" || snapshot.state === "failed" && !snapshot.failure?.recoverable;
|
|
5429
|
+
var validStoredAction = (action, now) => Number.isFinite(action.queuedAt) && action.queuedAt <= now && (action.kind !== "chat_message" || !!action.clientMessageId && action.text !== null && validateChatMessage({ text: action.text, attachments: action.attachments }) === null);
|
|
5430
|
+
var discardedError = () => new SpaceClientError({ code: "offline.discarded", recoverable: false, message: "This action was not sent." });
|
|
5431
|
+
var makeOfflineActions = (input) => Effect6.gen(function* () {
|
|
5432
|
+
const runtime = new OfflineActionsRuntime(input.connection, input.store, input.storage ?? browserOfflineActionsStorage(), input.storageKey);
|
|
5433
|
+
yield* Effect6.addFinalizer(() => Effect6.sync(() => runtime.dispose()));
|
|
5434
|
+
return runtime;
|
|
5435
|
+
});
|
|
5436
|
+
var OfflineActionsRuntime = class {
|
|
5437
|
+
#connection;
|
|
5438
|
+
#store;
|
|
5439
|
+
#unsubscribe;
|
|
5440
|
+
#waiters = [];
|
|
5441
|
+
#decisionNeeded = false;
|
|
5442
|
+
#policy = "send";
|
|
5443
|
+
#policyExplicit = false;
|
|
5444
|
+
#restored = false;
|
|
5445
|
+
#subject = null;
|
|
5446
|
+
#notice;
|
|
5447
|
+
#storage;
|
|
5448
|
+
#storageKey;
|
|
5449
|
+
constructor(connection, store, storage, storageKey = "chalk:offline-actions") {
|
|
5450
|
+
this.#connection = connection;
|
|
5451
|
+
this.#store = store;
|
|
5452
|
+
this.#storage = storage;
|
|
5453
|
+
this.#storageKey = storageKey;
|
|
5454
|
+
this.#unsubscribe = connection.subscribe(() => this.#connectionChanged());
|
|
5455
|
+
this.#connectionChanged();
|
|
5456
|
+
}
|
|
5457
|
+
send = () => Effect6.sync(() => this.#settle("send"));
|
|
5458
|
+
discard = () => Effect6.sync(() => this.#settle("discard"));
|
|
5459
|
+
setPolicy = (policy) => Effect6.sync(() => {
|
|
5460
|
+
this.#policyExplicit = true;
|
|
5461
|
+
this.#policy = policy;
|
|
5462
|
+
if (this.#decisionNeeded && policy !== "ask") this.#settle(policy);
|
|
5463
|
+
else this.#publish();
|
|
5464
|
+
});
|
|
5465
|
+
hold = (input, run) => {
|
|
5466
|
+
if (this.#connection.getSnapshot().state !== "reconnecting" && !this.#decisionNeeded) return null;
|
|
5467
|
+
const queuedSubject = this.#connection.getSnapshot().subject;
|
|
5468
|
+
const action = Object.freeze({ ...input, id: this.#connection.createId(), text: input.text ?? null, queuedAt: this.#connection.nowUnsafe() });
|
|
5469
|
+
return Effect6.callback((resume) => {
|
|
5470
|
+
if (this.#waiters.length === 0) this.#subject = queuedSubject;
|
|
5471
|
+
this.#notice = void 0;
|
|
5472
|
+
this.#waiters = [...this.#waiters, { action, resume }];
|
|
5473
|
+
this.#persist();
|
|
5474
|
+
this.#publish();
|
|
5475
|
+
return Effect6.sync(() => this.#drop(action.id));
|
|
5476
|
+
}).pipe(Effect6.andThen(Effect6.suspend(() => sameSubject(this.#connection.getSnapshot().subject, queuedSubject) ? run() : Effect6.fail(discardedError()))));
|
|
5477
|
+
};
|
|
5478
|
+
dispose() {
|
|
5479
|
+
this.#unsubscribe();
|
|
5480
|
+
const waiters = this.#waiters;
|
|
5481
|
+
this.#waiters = [];
|
|
5482
|
+
for (const waiter of waiters) waiter.resume(Effect6.fail(discardedError()));
|
|
5483
|
+
}
|
|
5484
|
+
#connectionChanged() {
|
|
5485
|
+
const snapshot = this.#connection.getSnapshot();
|
|
5486
|
+
const state = snapshot.state;
|
|
5487
|
+
const terminal = terminalConnection(snapshot);
|
|
5488
|
+
if (state === "live") this.#restore();
|
|
5489
|
+
if (this.#waiters.length === 0) {
|
|
5490
|
+
if (terminal) this.#discardSaved();
|
|
5491
|
+
return;
|
|
5492
|
+
}
|
|
5493
|
+
if (state === "live") {
|
|
5494
|
+
if (!this.#validScope()) {
|
|
5495
|
+
this.#notice = "Your offline actions were not sent because the Episode or participant changed.";
|
|
5496
|
+
this.#settle("discard");
|
|
5497
|
+
return;
|
|
5498
|
+
}
|
|
5499
|
+
if (this.#policy === "ask") {
|
|
5500
|
+
this.#decisionNeeded = true;
|
|
5501
|
+
this.#publish();
|
|
5502
|
+
} else this.#settle(this.#policy);
|
|
5503
|
+
} else if (terminal) {
|
|
5504
|
+
this.#notice = "Your offline actions were not sent because the Episode ended or the connection closed.";
|
|
5505
|
+
this.#settle("discard");
|
|
5506
|
+
this.#clearStorage();
|
|
5507
|
+
}
|
|
5508
|
+
}
|
|
5509
|
+
#settle(decision) {
|
|
5510
|
+
if (decision === "send" && this.#connection.getSnapshot().state !== "live") return;
|
|
5511
|
+
if (decision === "send" && !this.#validScope()) {
|
|
5512
|
+
this.#notice = "Your offline actions were not sent because the Episode or participant changed.";
|
|
5513
|
+
decision = "discard";
|
|
5514
|
+
}
|
|
5515
|
+
if (!this.#clearStorage() && this.#storage) {
|
|
5516
|
+
this.#notice = "Your offline actions could not be sent safely. Try again when storage is available.";
|
|
5517
|
+
this.#publish();
|
|
5518
|
+
return;
|
|
5519
|
+
}
|
|
5520
|
+
const waiters = this.#waiters;
|
|
5521
|
+
this.#waiters = [];
|
|
5522
|
+
this.#decisionNeeded = false;
|
|
5523
|
+
this.#publish();
|
|
5524
|
+
for (const waiter of waiters) this.#resumeWaiter(waiter, decision);
|
|
5525
|
+
this.#publish();
|
|
5526
|
+
}
|
|
5527
|
+
#drop(id) {
|
|
5528
|
+
const next = this.#waiters.filter((waiter) => waiter.action.id !== id);
|
|
5529
|
+
if (next.length === this.#waiters.length) return;
|
|
5530
|
+
this.#waiters = next;
|
|
5531
|
+
this.#persist();
|
|
5532
|
+
if (next.length === 0) this.#decisionNeeded = false;
|
|
5533
|
+
this.#publish();
|
|
5534
|
+
}
|
|
5535
|
+
#validScope() {
|
|
5536
|
+
const current = this.#connection.getSnapshot().subject;
|
|
5537
|
+
const saved = this.#subject;
|
|
5538
|
+
return sameSubject(current, saved);
|
|
5539
|
+
}
|
|
5540
|
+
#clearStorage() {
|
|
5541
|
+
try {
|
|
5542
|
+
this.#storage?.removeItem(this.#storageKey);
|
|
5543
|
+
return true;
|
|
5544
|
+
} catch {
|
|
5545
|
+
return false;
|
|
5546
|
+
}
|
|
5547
|
+
}
|
|
5548
|
+
#persist() {
|
|
5549
|
+
if (!this.#storage || !this.#subject) return;
|
|
5550
|
+
try {
|
|
5551
|
+
if (this.#waiters.length === 0) this.#storage.removeItem(this.#storageKey);
|
|
5552
|
+
else this.#storage.setItem(this.#storageKey, JSON.stringify({ version: 1, subject: this.#subject, actions: this.#waiters.map(({ action }) => action) }));
|
|
5553
|
+
} catch {
|
|
5554
|
+
this.#notice = "Your offline actions are held, but could not be saved. Keep this page open.";
|
|
5555
|
+
}
|
|
5556
|
+
}
|
|
5557
|
+
#restore() {
|
|
5558
|
+
if (this.#restored || !this.#connection.getSnapshot().subject) return;
|
|
5559
|
+
this.#restored = true;
|
|
5560
|
+
if (this.#waiters.length > 0) return;
|
|
5561
|
+
const saved = this.#readStored();
|
|
5562
|
+
if (!saved) return;
|
|
5563
|
+
this.#subject = saved.subject;
|
|
5564
|
+
if (!this.#validScope()) {
|
|
5565
|
+
this.#notice = "Your offline actions were not sent because the Episode or participant changed.";
|
|
5566
|
+
this.#clearStorage();
|
|
5567
|
+
this.#publish();
|
|
5568
|
+
return;
|
|
5569
|
+
}
|
|
5570
|
+
const actions = saved.actions.filter((action) => this.#connection.nowUnsafe() - action.queuedAt < OFFLINE_ACTION_MAX_AGE_MS);
|
|
5571
|
+
if (actions.length !== saved.actions.length) this.#notice = "Your expired offline actions were not sent. Actions are kept for up to 24 hours.";
|
|
5572
|
+
if (!this.#policyExplicit) this.#policy = "ask";
|
|
5573
|
+
const restoredSubject = saved.subject;
|
|
5574
|
+
this.#waiters = actions.map((action) => ({
|
|
5575
|
+
action,
|
|
5576
|
+
resume: (permission) => {
|
|
5577
|
+
const command = this.#connection.runCommand(
|
|
5578
|
+
({ sync }) => Effect6.tryPromise({
|
|
5579
|
+
try: () => action.kind === "chat_message" ? sync.sendChatMessage({ text: action.text ?? "", clientMessageId: action.clientMessageId ?? action.id, attachments: action.attachments ?? [] }).then(() => void 0) : sync.setHandRaised(action.kind === "hand_raise"),
|
|
5580
|
+
catch: () => discardedError()
|
|
5581
|
+
})
|
|
5582
|
+
);
|
|
5583
|
+
void Effect6.runPromiseExit(
|
|
5584
|
+
permission.pipe(
|
|
5585
|
+
Effect6.andThen(
|
|
5586
|
+
Effect6.suspend(() => sameSubject(this.#connection.getSnapshot().subject, restoredSubject) ? command : Effect6.fail(discardedError())).pipe(
|
|
5587
|
+
Effect6.tapError(
|
|
5588
|
+
() => Effect6.sync(() => {
|
|
5589
|
+
this.#notice = "An offline action was not sent. Please try it again.";
|
|
5590
|
+
this.#publish();
|
|
5591
|
+
})
|
|
5592
|
+
)
|
|
5593
|
+
)
|
|
5594
|
+
)
|
|
5595
|
+
)
|
|
5596
|
+
);
|
|
5597
|
+
}
|
|
5598
|
+
}));
|
|
5599
|
+
this.#persist();
|
|
5600
|
+
this.#publish();
|
|
5601
|
+
}
|
|
5602
|
+
#resumeWaiter(waiter, decision) {
|
|
5603
|
+
const expired = this.#connection.nowUnsafe() - waiter.action.queuedAt >= OFFLINE_ACTION_MAX_AGE_MS;
|
|
5604
|
+
if (expired) this.#notice = "Your expired offline actions were not sent. Actions are kept for up to 24 hours.";
|
|
5605
|
+
waiter.resume(decision === "send" && !expired ? Effect6.void : Effect6.fail(discardedError()));
|
|
5606
|
+
}
|
|
5607
|
+
#discardSaved() {
|
|
5608
|
+
try {
|
|
5609
|
+
if (!this.#storage?.getItem(this.#storageKey)) return;
|
|
5610
|
+
this.#notice = "Your offline actions were not sent because the Episode ended or the connection closed.";
|
|
5611
|
+
this.#clearStorage();
|
|
5612
|
+
} catch {
|
|
5613
|
+
this.#notice = "Saved offline actions could not be read. They will not be sent.";
|
|
5614
|
+
}
|
|
5615
|
+
this.#publish();
|
|
5616
|
+
}
|
|
5617
|
+
#readStored() {
|
|
5618
|
+
try {
|
|
5619
|
+
const raw = this.#storage?.getItem(this.#storageKey);
|
|
5620
|
+
if (!raw) return null;
|
|
5621
|
+
const saved = decodeOfflineActions(JSON.parse(raw));
|
|
5622
|
+
if (saved.actions.some((action) => !validStoredAction(action, this.#connection.nowUnsafe()))) throw new Error("Invalid offline action");
|
|
5623
|
+
return saved;
|
|
5624
|
+
} catch {
|
|
5625
|
+
this.#clearStorage();
|
|
5626
|
+
return null;
|
|
5627
|
+
}
|
|
5628
|
+
}
|
|
5629
|
+
#publish() {
|
|
5630
|
+
const slice = Object.freeze({ pending: Object.freeze(this.#waiters.map((waiter) => waiter.action)), decisionNeeded: this.#decisionNeeded, policy: this.#policy, ...this.#notice ? { notice: this.#notice } : {} });
|
|
5631
|
+
this.#store.updateOffline(slice);
|
|
5632
|
+
}
|
|
5633
|
+
};
|
|
5634
|
+
|
|
5635
|
+
// src/space-client/participants-controller.ts
|
|
5636
|
+
import { Context as Context8, Effect as Effect7, Layer as Layer8 } from "effect";
|
|
5637
|
+
|
|
3766
5638
|
// src/sync/v1-types.ts
|
|
3767
5639
|
var V1_CAPABILITIES = [
|
|
3768
5640
|
"publishAudio",
|
|
@@ -3793,19 +5665,21 @@ var V1_CAPABILITIES = [
|
|
|
3793
5665
|
// src/space-client/participants-controller.ts
|
|
3794
5666
|
var CAPABILITIES = new Set(V1_CAPABILITIES);
|
|
3795
5667
|
var EMPTY_PARTICIPANTS = Object.freeze({ roster: Object.freeze([]), admissionQueue: Object.freeze([]) });
|
|
3796
|
-
var ParticipantsControllerService = class extends
|
|
5668
|
+
var ParticipantsControllerService = class extends Context8.Service()("@chalk/client/ParticipantsController") {
|
|
3797
5669
|
};
|
|
3798
|
-
var makeParticipantsController = (connection, store, diagnostics) =>
|
|
3799
|
-
|
|
3800
|
-
(controller) =>
|
|
5670
|
+
var makeParticipantsController = (connection, store, diagnostics, offline) => Effect7.acquireRelease(
|
|
5671
|
+
Effect7.sync(() => new ParticipantsControllerRuntime(connection, store, diagnostics, offline)),
|
|
5672
|
+
(controller) => Effect7.sync(() => controller.dispose())
|
|
3801
5673
|
);
|
|
3802
5674
|
var ParticipantsControllerRuntime = class {
|
|
3803
5675
|
#connection;
|
|
3804
5676
|
#store;
|
|
3805
5677
|
#diagnostics;
|
|
5678
|
+
#offline;
|
|
3806
5679
|
#unsubscribePorts = null;
|
|
3807
5680
|
#unsubscribe = null;
|
|
3808
|
-
constructor(connection, store, diagnostics) {
|
|
5681
|
+
constructor(connection, store, diagnostics, offline) {
|
|
5682
|
+
this.#offline = offline;
|
|
3809
5683
|
this.#connection = connection;
|
|
3810
5684
|
this.#store = store;
|
|
3811
5685
|
this.#diagnostics = diagnostics;
|
|
@@ -3831,7 +5705,7 @@ var ParticipantsControllerRuntime = class {
|
|
|
3831
5705
|
requestMedia = (participantId, kind) => this.#command("media_request.request", ["capability_decision", "command_commit", "target_result"], () => {
|
|
3832
5706
|
assertIdentifier(participantId, "participant ID");
|
|
3833
5707
|
if (kind !== "microphone" && kind !== "camera") throw invalid("Media requests must target a microphone or camera");
|
|
3834
|
-
return ({ sync }) => (kind === "microphone" ? foreign3(() => sync.requestUnmute(participantId)) : foreign3(() => sync.requestStartCamera(participantId))).pipe(
|
|
5708
|
+
return ({ sync }) => (kind === "microphone" ? foreign3(() => sync.requestUnmute(participantId)) : foreign3(() => sync.requestStartCamera(participantId))).pipe(Effect7.map(directed));
|
|
3835
5709
|
});
|
|
3836
5710
|
remove = (participantId) => this.#command("moderation.remove", ["capability_decision", "command_commit", "target_delivery"], () => {
|
|
3837
5711
|
assertIdentifier(participantId, "participant ID");
|
|
@@ -3845,16 +5719,16 @@ var ParticipantsControllerRuntime = class {
|
|
|
3845
5719
|
assertIdentifier(requestId, "admission request ID");
|
|
3846
5720
|
return ({ sync }) => foreign3(() => sync.deny(requestId));
|
|
3847
5721
|
});
|
|
3848
|
-
raiseHand = () => this.#
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
5722
|
+
raiseHand = () => this.#setHandRaised(true);
|
|
5723
|
+
lowerHand = () => this.#setHandRaised(false);
|
|
5724
|
+
#setHandRaised(raised) {
|
|
5725
|
+
const send = () => this.#command(
|
|
5726
|
+
"participant.raised_hand.set",
|
|
5727
|
+
["membership_transition", "participant_result"],
|
|
5728
|
+
() => ({ sync }) => foreign3(() => sync.setHandRaised(raised))
|
|
5729
|
+
);
|
|
5730
|
+
return this.#offline?.hold({ kind: raised ? "hand_raise" : "hand_lower" }, send) ?? send();
|
|
5731
|
+
}
|
|
3858
5732
|
renameSelf = (displayName) => this.#command("participant.rename", ["membership_transition", "participant_result"], () => {
|
|
3859
5733
|
assertName(displayName, "display name");
|
|
3860
5734
|
return ({ sync }) => foreign3(() => sync.setDisplayName(displayName));
|
|
@@ -3867,18 +5741,18 @@ var ParticipantsControllerRuntime = class {
|
|
|
3867
5741
|
}
|
|
3868
5742
|
#command(name, checkpoints, operation) {
|
|
3869
5743
|
const diagnostic = this.#diagnostics?.startOperation(name);
|
|
3870
|
-
return
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
() =>
|
|
5744
|
+
return Effect7.try({ try: operation, catch: normalizeClientError }).pipe(
|
|
5745
|
+
Effect7.tap(() => Effect7.sync(() => diagnostic?.observe("observed", checkpoints[0]))),
|
|
5746
|
+
Effect7.flatMap((run) => this.#connection.runCommand(run)),
|
|
5747
|
+
Effect7.mapError(normalizeClientError),
|
|
5748
|
+
Effect7.tap(
|
|
5749
|
+
() => Effect7.sync(() => {
|
|
3876
5750
|
for (const checkpoint of checkpoints.slice(1)) diagnostic?.observe("observed", checkpoint);
|
|
3877
5751
|
if (name.startsWith("moderation.")) diagnostic?.notObservable("target_application", "target_projection_is_conditional");
|
|
3878
5752
|
diagnostic?.succeed();
|
|
3879
5753
|
})
|
|
3880
5754
|
),
|
|
3881
|
-
|
|
5755
|
+
Effect7.tapError(() => Effect7.sync(() => diagnostic?.fail("command_failed")))
|
|
3882
5756
|
);
|
|
3883
5757
|
}
|
|
3884
5758
|
#bind(ports) {
|
|
@@ -3900,7 +5774,7 @@ var ParticipantsControllerRuntime = class {
|
|
|
3900
5774
|
}
|
|
3901
5775
|
};
|
|
3902
5776
|
function foreign3(operation) {
|
|
3903
|
-
return
|
|
5777
|
+
return Effect7.tryPromise({ try: operation, catch: (cause) => cause });
|
|
3904
5778
|
}
|
|
3905
5779
|
function assertIdentifier(value, label) {
|
|
3906
5780
|
if (typeof value !== "string" || value.trim().length === 0) throw invalid(`A non-empty ${label} is required`);
|
|
@@ -4010,20 +5884,20 @@ function sameValues(left, right) {
|
|
|
4010
5884
|
}
|
|
4011
5885
|
|
|
4012
5886
|
// src/space-client/reactions-controller.ts
|
|
4013
|
-
import { Clock as Clock4, Context as
|
|
5887
|
+
import { Clock as Clock4, Context as Context9, Effect as Effect8, Layer as Layer9 } from "effect";
|
|
4014
5888
|
var MAX_VISIBLE_REACTIONS = 24;
|
|
4015
5889
|
var REACTIONS = /* @__PURE__ */ new Set(["\u{1F44D}", "\u2764\uFE0F", "\u{1F602}", "\u{1F62E}", "\u{1F622}", "\u{1F389}"]);
|
|
4016
|
-
var ReactionsControllerService = class extends
|
|
5890
|
+
var ReactionsControllerService = class extends Context9.Service()("@chalk/client/ReactionsController") {
|
|
4017
5891
|
};
|
|
4018
|
-
var makeReactionsController = (connection, store, diagnostics) =>
|
|
5892
|
+
var makeReactionsController = (connection, store, diagnostics) => Effect8.gen(function* () {
|
|
4019
5893
|
const clock = yield* Clock4.Clock;
|
|
4020
|
-
const scope = yield*
|
|
4021
|
-
const context = yield*
|
|
5894
|
+
const scope = yield* Effect8.scope;
|
|
5895
|
+
const context = yield* Effect8.context();
|
|
4022
5896
|
const fork = (effect) => {
|
|
4023
|
-
void
|
|
5897
|
+
void Effect8.runForkWith(context)(Effect8.forkIn(effect, scope).pipe(Effect8.asVoid));
|
|
4024
5898
|
};
|
|
4025
5899
|
const controller = new ReactionsControllerRuntime(connection, store, clock.currentTimeMillisUnsafe, fork, diagnostics);
|
|
4026
|
-
yield*
|
|
5900
|
+
yield* Effect8.addFinalizer(() => Effect8.sync(() => controller.dispose()));
|
|
4027
5901
|
return controller;
|
|
4028
5902
|
});
|
|
4029
5903
|
var ReactionsControllerRuntime = class {
|
|
@@ -4046,26 +5920,26 @@ var ReactionsControllerRuntime = class {
|
|
|
4046
5920
|
}
|
|
4047
5921
|
send = (emoji) => {
|
|
4048
5922
|
const operation = this.#diagnostics?.startOperation("reaction.send");
|
|
4049
|
-
return
|
|
5923
|
+
return Effect8.try({
|
|
4050
5924
|
try: () => {
|
|
4051
5925
|
if (!REACTIONS.has(emoji)) throw new SpaceClientError({ code: "reaction.invalid", recoverable: false, message: "The reaction is not supported" });
|
|
4052
5926
|
},
|
|
4053
5927
|
catch: normalizeClientError
|
|
4054
5928
|
}).pipe(
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
(reaction) =>
|
|
5929
|
+
Effect8.tap(() => Effect8.sync(() => operation?.observe("observed", "authorization"))),
|
|
5930
|
+
Effect8.andThen(this.#connection.runCommand(({ sync }) => foreign4(() => sync.sendReaction(emoji)))),
|
|
5931
|
+
Effect8.tap(() => Effect8.sync(() => operation?.observe("observed", "accepted_commit"))),
|
|
5932
|
+
Effect8.map(reactionFor),
|
|
5933
|
+
Effect8.tap(
|
|
5934
|
+
(reaction) => Effect8.sync(() => {
|
|
4061
5935
|
this.#observe(reaction);
|
|
4062
5936
|
operation?.observe("observed", "sender_result");
|
|
4063
5937
|
operation?.notObservable("recipient_projection", "recipient_projection_is_conditional");
|
|
4064
5938
|
operation?.succeed();
|
|
4065
5939
|
})
|
|
4066
5940
|
),
|
|
4067
|
-
|
|
4068
|
-
|
|
5941
|
+
Effect8.mapError(normalizeClientError),
|
|
5942
|
+
Effect8.tapError(() => Effect8.sync(() => operation?.fail("send_failed")))
|
|
4069
5943
|
);
|
|
4070
5944
|
};
|
|
4071
5945
|
dispose() {
|
|
@@ -4095,9 +5969,9 @@ var ReactionsControllerRuntime = class {
|
|
|
4095
5969
|
this.#expiryGenerations.set(reaction.eventId, generation);
|
|
4096
5970
|
const delay = Math.max(0, expiresAt2 - this.#now());
|
|
4097
5971
|
this.#fork(
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
5972
|
+
Effect8.sleep(delay).pipe(
|
|
5973
|
+
Effect8.andThen(
|
|
5974
|
+
Effect8.sync(() => {
|
|
4101
5975
|
if (this.#expiryGenerations.get(reaction.eventId) === generation) this.#remove(reaction.eventId);
|
|
4102
5976
|
})
|
|
4103
5977
|
)
|
|
@@ -4124,7 +5998,7 @@ var ReactionsControllerRuntime = class {
|
|
|
4124
5998
|
}
|
|
4125
5999
|
};
|
|
4126
6000
|
function foreign4(operation) {
|
|
4127
|
-
return
|
|
6001
|
+
return Effect8.tryPromise({ try: operation, catch: (cause) => cause });
|
|
4128
6002
|
}
|
|
4129
6003
|
function reactionFor(reaction) {
|
|
4130
6004
|
return Object.freeze({ ...reaction });
|
|
@@ -4139,23 +6013,122 @@ function sameReactions(left, right) {
|
|
|
4139
6013
|
});
|
|
4140
6014
|
}
|
|
4141
6015
|
|
|
6016
|
+
// src/space-client/recording-controller.ts
|
|
6017
|
+
import { Context as Context10, Effect as Effect9, Layer as Layer10 } from "effect";
|
|
6018
|
+
var RecordingControllerService = class extends Context10.Service()("@chalk/client/RecordingController") {
|
|
6019
|
+
};
|
|
6020
|
+
var makeRecordingController = (connection, store, diagnostics) => Effect9.acquireRelease(
|
|
6021
|
+
Effect9.sync(() => new RecordingControllerRuntime(connection, store, diagnostics)),
|
|
6022
|
+
(controller) => Effect9.sync(() => controller.dispose())
|
|
6023
|
+
);
|
|
6024
|
+
var RecordingControllerRuntime = class {
|
|
6025
|
+
#connection;
|
|
6026
|
+
#store;
|
|
6027
|
+
#diagnostics;
|
|
6028
|
+
#subscriptions = { ports: null, recording: null };
|
|
6029
|
+
constructor(connection, store, diagnostics) {
|
|
6030
|
+
this.#connection = connection;
|
|
6031
|
+
this.#store = store;
|
|
6032
|
+
this.#diagnostics = diagnostics;
|
|
6033
|
+
this.#subscriptions.ports = connection.subscribePorts((ports) => this.#bind(ports));
|
|
6034
|
+
}
|
|
6035
|
+
start = () => this.#command(
|
|
6036
|
+
"recording.start",
|
|
6037
|
+
"start_failed",
|
|
6038
|
+
() => this.#authorizeStart(),
|
|
6039
|
+
(_, { sync }) => foreign5(() => sync.startRecording()).pipe(Effect9.map(({ recordingId }) => Object.freeze({ recordingId })))
|
|
6040
|
+
);
|
|
6041
|
+
stop = () => this.#command(
|
|
6042
|
+
"recording.stop",
|
|
6043
|
+
"stop_failed",
|
|
6044
|
+
() => this.#authorizeStop(),
|
|
6045
|
+
(recordingId, { sync }) => foreign5(() => sync.stopRecording(recordingId)).pipe(Effect9.asVoid)
|
|
6046
|
+
);
|
|
6047
|
+
dispose() {
|
|
6048
|
+
this.#subscriptions.ports?.();
|
|
6049
|
+
this.#subscriptions.recording?.();
|
|
6050
|
+
this.#subscriptions.ports = null;
|
|
6051
|
+
this.#subscriptions.recording = null;
|
|
6052
|
+
this.#store.updateRecording(EMPTY_RECORDING);
|
|
6053
|
+
}
|
|
6054
|
+
#authorizeStart() {
|
|
6055
|
+
const snapshot = this.#store.getSnapshot();
|
|
6056
|
+
assertConnectedAndAllowed(snapshot);
|
|
6057
|
+
const current = snapshot.recording.current;
|
|
6058
|
+
if (current && current.status !== "stopped" && current.status !== "failed") throw invalidState("A Recording is already active");
|
|
6059
|
+
}
|
|
6060
|
+
#authorizeStop() {
|
|
6061
|
+
const snapshot = this.#store.getSnapshot();
|
|
6062
|
+
assertConnectedAndAllowed(snapshot);
|
|
6063
|
+
const current = snapshot.recording.current;
|
|
6064
|
+
if (!current || current.status !== "recording") throw invalidState("No active Recording can be stopped");
|
|
6065
|
+
return current.recordingId;
|
|
6066
|
+
}
|
|
6067
|
+
#command(name, failureCode, authorize, execute) {
|
|
6068
|
+
const operation = this.#diagnostics?.startOperation(name);
|
|
6069
|
+
const authorized = Effect9.try({ try: authorize, catch: normalizeClientError });
|
|
6070
|
+
return authorized.pipe(
|
|
6071
|
+
Effect9.tap(() => Effect9.sync(() => operation?.observe("observed", "authorization"))),
|
|
6072
|
+
Effect9.flatMap((authorization) => this.#connection.runCommand((ports) => execute(authorization, ports))),
|
|
6073
|
+
Effect9.tap(() => Effect9.sync(() => operation?.observe("observed", "accepted_commit"))),
|
|
6074
|
+
Effect9.tap(() => Effect9.sync(() => operation?.succeed())),
|
|
6075
|
+
Effect9.mapError(normalizeClientError),
|
|
6076
|
+
Effect9.tapError(() => Effect9.sync(() => operation?.fail(failureCode)))
|
|
6077
|
+
);
|
|
6078
|
+
}
|
|
6079
|
+
#bind(ports) {
|
|
6080
|
+
this.#subscriptions.recording?.();
|
|
6081
|
+
this.#subscriptions.recording = null;
|
|
6082
|
+
if (!ports) {
|
|
6083
|
+
this.#store.updateRecording(EMPTY_RECORDING);
|
|
6084
|
+
return;
|
|
6085
|
+
}
|
|
6086
|
+
this.#subscriptions.recording = ports.sync.subscribe((snapshot) => this.#observe(recordingFor(snapshot)));
|
|
6087
|
+
}
|
|
6088
|
+
#observe(next) {
|
|
6089
|
+
const current = this.#store.getSnapshot().recording;
|
|
6090
|
+
if (sameRecording(current, next)) return;
|
|
6091
|
+
this.#store.updateRecording(next);
|
|
6092
|
+
}
|
|
6093
|
+
};
|
|
6094
|
+
var EMPTY_RECORDING = Object.freeze({ current: null });
|
|
6095
|
+
function recordingFor(snapshot) {
|
|
6096
|
+
const recording = (snapshot.optimisticControl ?? snapshot.control)?.recording ?? null;
|
|
6097
|
+
return Object.freeze({ current: recording ? Object.freeze({ ...recording }) : null });
|
|
6098
|
+
}
|
|
6099
|
+
function sameRecording(left, right) {
|
|
6100
|
+
if (left.current === right.current) return true;
|
|
6101
|
+
if (!left.current || !right.current) return false;
|
|
6102
|
+
return left.current.recordingId === right.current.recordingId && left.current.status === right.current.status && left.current.failureCode === right.current.failureCode;
|
|
6103
|
+
}
|
|
6104
|
+
function assertConnectedAndAllowed(snapshot) {
|
|
6105
|
+
if (snapshot.connection.status !== "live" || !snapshot.connection.episode) throw invalidState("Recording commands require a live Episode");
|
|
6106
|
+
if (!snapshot.self.can("manageRecording")) throw new SpaceClientError({ code: "command.rejected", recoverable: false, message: "Recording is not allowed for this Participant" });
|
|
6107
|
+
}
|
|
6108
|
+
function invalidState(message) {
|
|
6109
|
+
return new SpaceClientError({ code: "connection.invalid_state", recoverable: false, message });
|
|
6110
|
+
}
|
|
6111
|
+
function foreign5(operation) {
|
|
6112
|
+
return Effect9.tryPromise({ try: operation, catch: (cause) => cause });
|
|
6113
|
+
}
|
|
6114
|
+
|
|
4142
6115
|
// src/space-client/whiteboard-controller.ts
|
|
4143
|
-
import { Context as
|
|
6116
|
+
import { Context as Context11, Effect as Effect10, Layer as Layer11 } from "effect";
|
|
4144
6117
|
var EMPTY_SUMMARY = Object.freeze({ status: "unsubscribed", sceneId: null, revision: null, capabilities: Object.freeze([]), canDraw: false, canClear: false, presenting: false, error: null });
|
|
4145
|
-
var WhiteboardControllerService = class extends
|
|
6118
|
+
var WhiteboardControllerService = class extends Context11.Service()("@chalk/client/WhiteboardController") {
|
|
4146
6119
|
};
|
|
4147
|
-
var makeWhiteboardController = (connection, store, create) =>
|
|
4148
|
-
const context = yield*
|
|
4149
|
-
const controller = yield*
|
|
6120
|
+
var makeWhiteboardController = (connection, store, create) => Effect10.gen(function* () {
|
|
6121
|
+
const context = yield* Effect10.context();
|
|
6122
|
+
const controller = yield* Effect10.sync(() => {
|
|
4150
6123
|
let instance = null;
|
|
4151
6124
|
const transport = create?.({
|
|
4152
|
-
token: () =>
|
|
6125
|
+
token: () => Effect10.runPromiseWith(context)(connection.getSyncToken()),
|
|
4153
6126
|
onSummary: (summary) => instance?.observe(summary)
|
|
4154
6127
|
}) ?? null;
|
|
4155
6128
|
instance = new WhiteboardControllerRuntime(connection, store, transport);
|
|
4156
6129
|
return instance;
|
|
4157
6130
|
});
|
|
4158
|
-
yield*
|
|
6131
|
+
yield* Effect10.addFinalizer(() => Effect10.sync(() => controller.dispose()));
|
|
4159
6132
|
return controller;
|
|
4160
6133
|
});
|
|
4161
6134
|
var WhiteboardControllerRuntime = class {
|
|
@@ -4217,15 +6190,17 @@ function sameError2(left, right) {
|
|
|
4217
6190
|
}
|
|
4218
6191
|
|
|
4219
6192
|
// src/space-client/controller-effects.ts
|
|
4220
|
-
var ControllerEffectsService = class extends
|
|
6193
|
+
var ControllerEffectsService = class extends Context12.Service()("@chalk/client/ControllerEffects") {
|
|
4221
6194
|
};
|
|
4222
|
-
var makeControllerEffects = (input) =>
|
|
6195
|
+
var makeControllerEffects = (input) => Effect11.gen(function* () {
|
|
6196
|
+
const offlineActions = yield* makeOfflineActions({ connection: input.connection, store: input.store, storage: input.offlineActionsStorage, storageKey: input.offlineActionsStorageKey });
|
|
4223
6197
|
const media = yield* makeMediaController(input.connection, input.store, input.mediaDeviceSelection, input.episodeDiagnostics);
|
|
4224
|
-
const chat = yield* makeChatController({ connection: input.connection, store: input.store, createTransport: input.featureFactories?.createChatFileTransport, apiBaseUrl: input.apiBaseUrl, fetch: input.fetch, episodeDiagnostics: input.episodeDiagnostics });
|
|
4225
|
-
const participants = yield* makeParticipantsController(input.connection, input.store, input.episodeDiagnostics);
|
|
6198
|
+
const chat = yield* makeChatController({ connection: input.connection, store: input.store, createTransport: input.featureFactories?.createChatFileTransport, apiBaseUrl: input.apiBaseUrl, fetch: input.fetch, episodeDiagnostics: input.episodeDiagnostics, offline: offlineActions });
|
|
6199
|
+
const participants = yield* makeParticipantsController(input.connection, input.store, input.episodeDiagnostics, offlineActions);
|
|
4226
6200
|
const reactions = yield* makeReactionsController(input.connection, input.store, input.episodeDiagnostics);
|
|
6201
|
+
const recording = yield* makeRecordingController(input.connection, input.store, input.episodeDiagnostics);
|
|
4227
6202
|
const whiteboard = yield* makeWhiteboardController(input.connection, input.store, input.featureFactories?.createWhiteboardClient);
|
|
4228
|
-
return { media, chat, participants, reactions, whiteboard };
|
|
6203
|
+
return { media, offline: offlineActions, chat, participants, reactions, recording, whiteboard };
|
|
4229
6204
|
});
|
|
4230
6205
|
|
|
4231
6206
|
// src/space-client/media-device-selection.ts
|
|
@@ -4280,7 +6255,7 @@ function requireDeviceId(deviceId) {
|
|
|
4280
6255
|
}
|
|
4281
6256
|
|
|
4282
6257
|
// src/space-client/episode-diagnostic-runtime.ts
|
|
4283
|
-
import { encodedEventSize, getActionContract, parseDiagnosticEventDraft, redactDiagnosticAttributes } from "@q9labsai/diagnostics-contracts";
|
|
6258
|
+
import { encodedEventSize, getActionContract, parseDiagnosticEventDraft as parseDiagnosticEventDraft2, redactDiagnosticAttributes as redactDiagnosticAttributes2 } from "@q9labsai/diagnostics-contracts";
|
|
4284
6259
|
var DEFAULT_RING_EVENTS = 256;
|
|
4285
6260
|
var DEFAULT_RING_BYTES = 256 * 1024;
|
|
4286
6261
|
var DEFAULT_RING_AGE_MS = 2 * 60 * 1e3;
|
|
@@ -4388,6 +6363,15 @@ var EpisodeDiagnosticRuntime = class {
|
|
|
4388
6363
|
activeContext() {
|
|
4389
6364
|
return this.#enabled() ? this.context() : void 0;
|
|
4390
6365
|
}
|
|
6366
|
+
/** Internal Feedback seam. It returns only a short-lived verified credential to the private transport. */
|
|
6367
|
+
feedbackCredentialUnsafe() {
|
|
6368
|
+
if (!this.#enabled() || !this.#credential) return null;
|
|
6369
|
+
return Object.freeze({ ...this.#credential });
|
|
6370
|
+
}
|
|
6371
|
+
feedbackAvailabilityUnsafe() {
|
|
6372
|
+
if (this.#disposed) return "disposed";
|
|
6373
|
+
return this.#enabled() ? "available" : "disabled";
|
|
6374
|
+
}
|
|
4391
6375
|
startOperation(name, attributes, parentOperationRef, producerOperationRef, correlationInput) {
|
|
4392
6376
|
if (!this.#enabled()) return void 0;
|
|
4393
6377
|
const credentialGeneration = this.#credential?.generation;
|
|
@@ -4453,7 +6437,7 @@ var EpisodeDiagnosticRuntime = class {
|
|
|
4453
6437
|
});
|
|
4454
6438
|
}
|
|
4455
6439
|
#draft(input) {
|
|
4456
|
-
return
|
|
6440
|
+
return parseDiagnosticEventDraft2(
|
|
4457
6441
|
buildDraftInput(input, {
|
|
4458
6442
|
eventId: safeId(this.#createId()),
|
|
4459
6443
|
producerSequence: ++this.#sequence,
|
|
@@ -4764,7 +6748,7 @@ function buildDraftInput(input, context) {
|
|
|
4764
6748
|
expectation: expectationFor(contract, checkpoint),
|
|
4765
6749
|
correlation: optionalCorrelation(correlation),
|
|
4766
6750
|
release: context.release,
|
|
4767
|
-
attributes:
|
|
6751
|
+
attributes: redactDiagnosticAttributes2(input.attributes).attributes
|
|
4768
6752
|
};
|
|
4769
6753
|
}
|
|
4770
6754
|
function eventReferences(input) {
|
|
@@ -4885,9 +6869,125 @@ function renderRegistry() {
|
|
|
4885
6869
|
return target[REGISTRY];
|
|
4886
6870
|
}
|
|
4887
6871
|
|
|
6872
|
+
// src/feedback/controller.ts
|
|
6873
|
+
var DEFAULT_SDK = Object.freeze({ client: "@q9labsai/chalk-client" });
|
|
6874
|
+
var DEFAULT_PLATFORM = Object.freeze({ kind: "web" });
|
|
6875
|
+
function createFeedbackController(context) {
|
|
6876
|
+
const transport = createFeedbackTransport({
|
|
6877
|
+
baseUrl: context.apiBaseUrl,
|
|
6878
|
+
fetch: context.fetch,
|
|
6879
|
+
credential: context.diagnosticCredential,
|
|
6880
|
+
telemetry: () => context.telemetry ?? context.diagnosticContext()
|
|
6881
|
+
});
|
|
6882
|
+
let disposed = false;
|
|
6883
|
+
const ensureActive = () => {
|
|
6884
|
+
if (disposed) throw new FeedbackTransportError({ code: "unavailable", recoverable: false, message: "Feedback has been disposed." });
|
|
6885
|
+
};
|
|
6886
|
+
const prepare = async (input = {}) => {
|
|
6887
|
+
ensureActive();
|
|
6888
|
+
const collectionInput = evidenceInput(input.evidence);
|
|
6889
|
+
const screenshot = await resolveScreenshot(input.screenshot_provider ?? input.screenshotProvider);
|
|
6890
|
+
ensureActive();
|
|
6891
|
+
const initialInput = { ...collectionInput, screenshot };
|
|
6892
|
+
const idempotencyKey = idempotencyKeyFor(context.createId);
|
|
6893
|
+
const preparedEvidence = collectFeedbackEvidenceFromContext(initialInput, context);
|
|
6894
|
+
const preparedScreenshot = capturedScreenshot(screenshot) ? screenshotPayload(screenshot).screenshot : void 0;
|
|
6895
|
+
let cachedRequest;
|
|
6896
|
+
const prepared = {
|
|
6897
|
+
idempotency_key: idempotencyKey,
|
|
6898
|
+
evidence: preparedEvidence,
|
|
6899
|
+
...preparedScreenshot ? { screenshot: preparedScreenshot } : {},
|
|
6900
|
+
collect: (nextInput) => {
|
|
6901
|
+
ensureActive();
|
|
6902
|
+
return collectFeedbackEvidenceFromContext(nextInput ?? initialInput, context);
|
|
6903
|
+
},
|
|
6904
|
+
send: async ({ category, message }) => {
|
|
6905
|
+
ensureActive();
|
|
6906
|
+
const normalizedMessage = assertFeedbackMessage(message);
|
|
6907
|
+
const request = cachedRequest ?? parseFeedbackReportRequest({
|
|
6908
|
+
schema_version: FEEDBACK_REQUEST_SCHEMA_VERSION,
|
|
6909
|
+
category,
|
|
6910
|
+
message: normalizedMessage,
|
|
6911
|
+
source: input.source ?? context.source,
|
|
6912
|
+
evidence: preparedEvidence,
|
|
6913
|
+
...preparedScreenshot ? { screenshot: preparedScreenshot } : {}
|
|
6914
|
+
});
|
|
6915
|
+
cachedRequest ??= request;
|
|
6916
|
+
return transport.send({ request, idempotencyKey });
|
|
6917
|
+
}
|
|
6918
|
+
};
|
|
6919
|
+
return prepared;
|
|
6920
|
+
};
|
|
6921
|
+
const send = async (input) => {
|
|
6922
|
+
ensureActive();
|
|
6923
|
+
const source = input.source ?? context.source;
|
|
6924
|
+
const evidenceInput2 = input.evidence ?? defaultEvidenceInput();
|
|
6925
|
+
const screenshot = input.screenshot ?? evidenceInput2.screenshot;
|
|
6926
|
+
return sendRequest({ category: input.category, message: input.message, source, evidence: { ...evidenceInput2, ...screenshot ? { screenshot } : {} }, idempotencyKey: input.idempotency_key ?? input.idempotencyKey ?? idempotencyKeyFor(context.createId) });
|
|
6927
|
+
};
|
|
6928
|
+
const sendRequest = async (input) => {
|
|
6929
|
+
ensureActive();
|
|
6930
|
+
const evidence = collectFeedbackEvidenceFromContext(input.evidence, context);
|
|
6931
|
+
const request = parseFeedbackReportRequest({ schema_version: FEEDBACK_REQUEST_SCHEMA_VERSION, category: input.category, message: assertFeedbackMessage(input.message), source: input.source ?? context.source, evidence, ...screenshotPayload(input.evidence.screenshot) });
|
|
6932
|
+
return transport.send({ request, idempotencyKey: input.idempotencyKey });
|
|
6933
|
+
};
|
|
6934
|
+
return {
|
|
6935
|
+
prepare,
|
|
6936
|
+
send,
|
|
6937
|
+
dispose: () => {
|
|
6938
|
+
disposed = true;
|
|
6939
|
+
}
|
|
6940
|
+
};
|
|
6941
|
+
}
|
|
6942
|
+
function evidenceInput(input) {
|
|
6943
|
+
const base = { sdk: input?.sdk ?? DEFAULT_SDK, platform: input?.platform ?? DEFAULT_PLATFORM };
|
|
6944
|
+
if (!input) return base;
|
|
6945
|
+
return { ...base, ...optionalEvidenceInput(input) };
|
|
6946
|
+
}
|
|
6947
|
+
function optionalEvidenceInput(input) {
|
|
6948
|
+
return {
|
|
6949
|
+
...input.app ? { app: input.app } : {},
|
|
6950
|
+
...input.local_state ? { local_state: input.local_state } : {},
|
|
6951
|
+
...input.cookies ? { cookies: input.cookies } : {}
|
|
6952
|
+
};
|
|
6953
|
+
}
|
|
6954
|
+
function defaultEvidenceInput() {
|
|
6955
|
+
return { sdk: DEFAULT_SDK, platform: DEFAULT_PLATFORM };
|
|
6956
|
+
}
|
|
6957
|
+
async function resolveScreenshot(provider) {
|
|
6958
|
+
if (!provider) return { state: "unavailable", failure_code: "unsupported" };
|
|
6959
|
+
let timeout;
|
|
6960
|
+
try {
|
|
6961
|
+
return await Promise.race([
|
|
6962
|
+
provider(),
|
|
6963
|
+
new Promise((resolve) => {
|
|
6964
|
+
timeout = globalThis.setTimeout(() => resolve({ state: "unavailable", failure_code: "capture_failed" }), 5e3);
|
|
6965
|
+
})
|
|
6966
|
+
]);
|
|
6967
|
+
} catch {
|
|
6968
|
+
return { state: "unavailable", failure_code: "capture_failed" };
|
|
6969
|
+
} finally {
|
|
6970
|
+
globalThis.clearTimeout(timeout);
|
|
6971
|
+
}
|
|
6972
|
+
}
|
|
6973
|
+
function capturedScreenshot(input) {
|
|
6974
|
+
return input.state === "captured" || input.state === "partial" ? input : void 0;
|
|
6975
|
+
}
|
|
6976
|
+
function screenshotPayload(input) {
|
|
6977
|
+
if (!input) return {};
|
|
6978
|
+
const screenshot = capturedScreenshot(input);
|
|
6979
|
+
return screenshot ? { screenshot: { schema_version: "FeedbackScreenshot/v1", mime_type: screenshot.mime_type, width: screenshot.width, height: screenshot.height, captured_at: screenshot.captured_at, data_base64: screenshot.data_base64 } } : {};
|
|
6980
|
+
}
|
|
6981
|
+
function idempotencyKeyFor(createId) {
|
|
6982
|
+
const generated = createId();
|
|
6983
|
+
if (/^[A-Za-z0-9_-]{16,128}$/u.test(generated)) return generated;
|
|
6984
|
+
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
6985
|
+
return `feedback-${Date.now()}-${Math.random().toString(36).slice(2, 18)}`;
|
|
6986
|
+
}
|
|
6987
|
+
|
|
4888
6988
|
// src/space-client/core.ts
|
|
4889
6989
|
var syncClientsByDependencies = /* @__PURE__ */ new WeakMap();
|
|
4890
|
-
var SpaceClientCoreService = class extends
|
|
6990
|
+
var SpaceClientCoreService = class extends Context13.Service()("@chalk/client/SpaceClientCore") {
|
|
4891
6991
|
};
|
|
4892
6992
|
var makeSpaceClientCoreLayer = (options, platform = {}) => {
|
|
4893
6993
|
const apiBaseUrl = normalizedBaseUrl2(platform.apiBaseUrl ?? options.baseUrl ?? "https://api.chalkmeet.com");
|
|
@@ -4900,7 +7000,7 @@ var makeSpaceClientCoreLayer = (options, platform = {}) => {
|
|
|
4900
7000
|
now: baseDependencies.clock.now,
|
|
4901
7001
|
setTimeout: baseDependencies.clock.setTimeout,
|
|
4902
7002
|
clearTimeout: baseDependencies.clock.clearTimeout,
|
|
4903
|
-
release: { id: "chalk-client@4.1.
|
|
7003
|
+
release: { id: "chalk-client@4.1.17" }
|
|
4904
7004
|
});
|
|
4905
7005
|
const diagnosticSyncClients = /* @__PURE__ */ new Set();
|
|
4906
7006
|
const dependencies = {
|
|
@@ -4922,6 +7022,7 @@ var makeSpaceClientCoreLayer = (options, platform = {}) => {
|
|
|
4922
7022
|
initialCameraEnabled: platform.initialCameraEnabled,
|
|
4923
7023
|
telemetry: platform.telemetry,
|
|
4924
7024
|
recordRtcSummary: platform.recordRtcSummary,
|
|
7025
|
+
recordReconnect: platform.recordReconnect,
|
|
4925
7026
|
dependencies,
|
|
4926
7027
|
diagnostics: {
|
|
4927
7028
|
onEvent: (event) => {
|
|
@@ -4930,30 +7031,49 @@ var makeSpaceClientCoreLayer = (options, platform = {}) => {
|
|
|
4930
7031
|
}
|
|
4931
7032
|
}
|
|
4932
7033
|
});
|
|
4933
|
-
return makeSpaceClientCoreLayerFromServices(options, platform).pipe(
|
|
7034
|
+
return makeSpaceClientCoreLayerFromServices(options, platform).pipe(Layer13.provideMerge(Layer13.mergeAll(makeSpaceStoreLayer, lifecycle, makeConnectionPlatformLayer(dependencies), liveClock(dependencies))));
|
|
4934
7035
|
};
|
|
4935
|
-
var makeSpaceClientCoreLayerFromServices = (options, platform = {}) =>
|
|
7036
|
+
var makeSpaceClientCoreLayerFromServices = (options, platform = {}) => Layer13.effect(
|
|
4936
7037
|
SpaceClientCoreService,
|
|
4937
|
-
|
|
4938
|
-
const store = yield*
|
|
4939
|
-
const lifecycle = yield*
|
|
4940
|
-
const dependencies = yield*
|
|
7038
|
+
Effect12.gen(function* () {
|
|
7039
|
+
const store = yield* Effect12.service(SpaceStoreService);
|
|
7040
|
+
const lifecycle = yield* Effect12.service(ConnectionLifecycleService);
|
|
7041
|
+
const dependencies = yield* Effect12.service(ConnectionPlatformService);
|
|
4941
7042
|
const space = options.space.trim();
|
|
4942
|
-
if (space.length === 0) return yield*
|
|
4943
|
-
if (typeof options.getAccess !== "function") return yield*
|
|
7043
|
+
if (space.length === 0) return yield* Effect12.die(new TypeError("A Space slug is required"));
|
|
7044
|
+
if (typeof options.getAccess !== "function") return yield* Effect12.die(new TypeError("getAccess is required"));
|
|
4944
7045
|
const apiBaseUrl = normalizedBaseUrl2(platform.apiBaseUrl ?? options.baseUrl ?? "https://api.chalkmeet.com");
|
|
4945
7046
|
const selection = new MediaDeviceSelection(dependencies.mediaDevices);
|
|
4946
7047
|
const episodeDiagnostics = episodeDiagnosticsForDependencies(dependencies);
|
|
4947
7048
|
if (episodeDiagnostics) registerEpisodeDiagnosticConnection(lifecycle, episodeDiagnostics);
|
|
4948
|
-
const controllers = yield* makeControllerEffects({
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
7049
|
+
const controllers = yield* makeControllerEffects({
|
|
7050
|
+
offlineActionsStorage: options.offlineActionsStorage,
|
|
7051
|
+
offlineActionsStorageKey: `chalk:offline-actions:${JSON.stringify([apiBaseUrl, space])}`,
|
|
7052
|
+
apiBaseUrl,
|
|
7053
|
+
connection: lifecycle,
|
|
7054
|
+
store,
|
|
7055
|
+
mediaDeviceSelection: selection,
|
|
7056
|
+
featureFactories: dependencies,
|
|
7057
|
+
fetch: platform.fetch,
|
|
7058
|
+
episodeDiagnostics
|
|
7059
|
+
});
|
|
7060
|
+
return yield* Effect12.acquireRelease(
|
|
7061
|
+
Effect12.sync(
|
|
7062
|
+
() => new SpaceClientCore(lifecycle, selection, store, controllers, episodeDiagnostics, dependencies, syncClientsByDependencies.get(dependencies), {
|
|
7063
|
+
apiBaseUrl,
|
|
7064
|
+
fetch: platform.fetch,
|
|
7065
|
+
createId: platform.randomUUID ?? dependencies.createId ?? lifecycle.createId,
|
|
7066
|
+
source: platform.feedbackSource ?? "embedded",
|
|
7067
|
+
telemetry: platform.telemetry
|
|
7068
|
+
})
|
|
7069
|
+
),
|
|
7070
|
+
(core) => Effect12.sync(() => core.dispose())
|
|
4952
7071
|
);
|
|
4953
7072
|
})
|
|
4954
7073
|
);
|
|
4955
7074
|
var SpaceClientCore = class {
|
|
4956
7075
|
controllers;
|
|
7076
|
+
feedback;
|
|
4957
7077
|
#connection;
|
|
4958
7078
|
#store;
|
|
4959
7079
|
#episodeDiagnostics;
|
|
@@ -4962,24 +7082,46 @@ var SpaceClientCore = class {
|
|
|
4962
7082
|
#episodeDiagnosticTracks = /* @__PURE__ */ new Map();
|
|
4963
7083
|
#handlers = { participantJoined: /* @__PURE__ */ new Set(), participantLeft: /* @__PURE__ */ new Set(), episodeEnded: /* @__PURE__ */ new Set(), screenShareStarted: /* @__PURE__ */ new Set(), screenShareStopped: /* @__PURE__ */ new Set(), error: /* @__PURE__ */ new Set() };
|
|
4964
7084
|
#disposed = false;
|
|
7085
|
+
#episodeEnded = false;
|
|
4965
7086
|
#previous;
|
|
4966
7087
|
#unsubscribeConnection = null;
|
|
4967
7088
|
#unsubscribeStore = null;
|
|
4968
7089
|
#leaveOperation;
|
|
4969
7090
|
#participantJoinOperation;
|
|
4970
7091
|
#syncConnectOperation;
|
|
4971
|
-
constructor(connection, _selection, store, controllers, episodeDiagnostics, episodeDiagnosticDependencies, episodeDiagnosticSyncClients) {
|
|
7092
|
+
constructor(connection, _selection, store, controllers, episodeDiagnostics, episodeDiagnosticDependencies, episodeDiagnosticSyncClients, feedback) {
|
|
4972
7093
|
this.#connection = connection;
|
|
4973
7094
|
this.#store = store;
|
|
4974
7095
|
this.controllers = controllers;
|
|
4975
7096
|
this.#episodeDiagnostics = episodeDiagnostics;
|
|
4976
7097
|
this.#episodeDiagnosticDependencies = episodeDiagnosticDependencies;
|
|
4977
7098
|
this.#episodeDiagnosticSyncClients = episodeDiagnosticSyncClients;
|
|
7099
|
+
this.feedback = createFeedbackController({
|
|
7100
|
+
apiBaseUrl: feedback?.apiBaseUrl ?? "https://api.chalkmeet.com",
|
|
7101
|
+
fetch: feedback?.fetch,
|
|
7102
|
+
createId: feedback?.createId ?? connection.createId,
|
|
7103
|
+
now: connection.nowUnsafe,
|
|
7104
|
+
source: feedback?.source ?? "embedded",
|
|
7105
|
+
telemetry: feedback?.telemetry,
|
|
7106
|
+
diagnosticContext: () => episodeDiagnostics?.activeContext(),
|
|
7107
|
+
connection: connection.getSnapshot,
|
|
7108
|
+
diagnosticCredential: () => episodeDiagnostics?.feedbackCredentialUnsafe() ?? null,
|
|
7109
|
+
diagnosticAvailability: () => episodeDiagnostics?.feedbackAvailabilityUnsafe() ?? "unavailable",
|
|
7110
|
+
diagnosticSnapshot: () => {
|
|
7111
|
+
const inspected = episodeDiagnostics?.inspect();
|
|
7112
|
+
return { dropped: inspected?.dropped ?? 0, events: inspected?.ring ?? [] };
|
|
7113
|
+
}
|
|
7114
|
+
});
|
|
4978
7115
|
this.#store.updateConnection(connection.getSnapshot());
|
|
4979
7116
|
this.#previous = this.#store.getSnapshot();
|
|
4980
7117
|
this.#unsubscribeStore = this.#store.subscribe(() => this.#publishStore());
|
|
4981
7118
|
this.#unsubscribeConnection = connection.subscribe(() => {
|
|
4982
|
-
|
|
7119
|
+
const snapshot = connection.getSnapshot();
|
|
7120
|
+
const episode = snapshot.episode ?? this.#store.getSnapshot().connection.episode;
|
|
7121
|
+
this.#store.updateConnection(snapshot);
|
|
7122
|
+
const newlyEnded = snapshot.episodeEnded && !this.#episodeEnded;
|
|
7123
|
+
this.#episodeEnded = snapshot.episodeEnded ?? false;
|
|
7124
|
+
if (newlyEnded) this.#emit("episodeEnded", { episode });
|
|
4983
7125
|
this.#publishConnectionDiagnostics();
|
|
4984
7126
|
});
|
|
4985
7127
|
this.#publishConnectionDiagnostics();
|
|
@@ -4992,47 +7134,47 @@ var SpaceClientCore = class {
|
|
|
4992
7134
|
join(options = {}) {
|
|
4993
7135
|
this.#participantJoinOperation = this.#episodeDiagnostics?.startOperation("participant.join");
|
|
4994
7136
|
this.#syncConnectOperation = this.#episodeDiagnostics?.startOperation("sync.connect");
|
|
4995
|
-
return
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
() =>
|
|
7137
|
+
return Effect12.sync(() => this.#requireActive()).pipe(
|
|
7138
|
+
Effect12.tap(() => Effect12.sync(() => this.controllers.media.configure(options))),
|
|
7139
|
+
Effect12.andThen(this.#connection.configureJoin(options)),
|
|
7140
|
+
Effect12.andThen(this.#connection.join()),
|
|
7141
|
+
Effect12.andThen(options.displayName === void 0 ? Effect12.void : this.controllers.participants.renameSelf(options.displayName)),
|
|
7142
|
+
Effect12.tap(
|
|
7143
|
+
() => Effect12.sync(() => {
|
|
5002
7144
|
this.#participantJoinOperation?.succeed();
|
|
5003
7145
|
this.#syncConnectOperation?.succeed();
|
|
5004
7146
|
})
|
|
5005
7147
|
),
|
|
5006
|
-
|
|
5007
|
-
() =>
|
|
7148
|
+
Effect12.tapError(
|
|
7149
|
+
() => Effect12.sync(() => {
|
|
5008
7150
|
this.#participantJoinOperation?.fail("join_failed");
|
|
5009
7151
|
this.#syncConnectOperation?.fail("connect_failed");
|
|
5010
7152
|
})
|
|
5011
7153
|
),
|
|
5012
|
-
|
|
7154
|
+
Effect12.catchTag("ConnectionLifecycleFailure", (failure3) => Effect12.fail(failure3.code === "episode_ended" ? new SpaceClientError({ code: "episode.ended", recoverable: failure3.recoverable, message: failure3.message }) : failure3)),
|
|
7155
|
+
Effect12.mapError(normalizeClientError)
|
|
5013
7156
|
);
|
|
5014
7157
|
}
|
|
5015
7158
|
leave() {
|
|
5016
7159
|
this.#leaveOperation = this.#episodeDiagnostics?.startOperation("participant.leave");
|
|
5017
7160
|
return this.#connection.leave().pipe(
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
7161
|
+
Effect12.tap(() => Effect12.sync(() => this.#leaveOperation?.succeed())),
|
|
7162
|
+
Effect12.tapError(() => Effect12.sync(() => this.#leaveOperation?.fail("leave_failed"))),
|
|
7163
|
+
Effect12.mapError(normalizeClientError)
|
|
5021
7164
|
);
|
|
5022
7165
|
}
|
|
5023
7166
|
endEpisode() {
|
|
5024
|
-
return trackedEffect(
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
).pipe(Effect10.asVoid, Effect10.mapError(normalizeClientError));
|
|
7167
|
+
return trackedEffect(this.#episodeDiagnostics, "episode.end.authorized", this.#connection.runCommand(({ sync }) => foreign6(() => sync.endEpisode()).pipe(Effect12.tap(() => this.#connection.confirmEpisodeEnded))).pipe(Effect12.tap(() => this.#connection.leave()))).pipe(
|
|
7168
|
+
Effect12.asVoid,
|
|
7169
|
+
Effect12.mapError(normalizeClientError)
|
|
7170
|
+
);
|
|
5029
7171
|
}
|
|
5030
7172
|
extendEpisode(minutes) {
|
|
5031
7173
|
return trackedEffect(
|
|
5032
7174
|
this.#episodeDiagnostics,
|
|
5033
7175
|
"episode.deadline.extend",
|
|
5034
|
-
this.#connection.runCommand(({ sync }) =>
|
|
5035
|
-
).pipe(
|
|
7176
|
+
this.#connection.runCommand(({ sync }) => foreign6(() => sync.extendEpisode(minutes)))
|
|
7177
|
+
).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
|
|
5036
7178
|
}
|
|
5037
7179
|
on(event, handler) {
|
|
5038
7180
|
this.#handlers[event].add(handler);
|
|
@@ -5045,6 +7187,7 @@ var SpaceClientCore = class {
|
|
|
5045
7187
|
this.#unsubscribeStore?.();
|
|
5046
7188
|
this.#unsubscribeConnection = null;
|
|
5047
7189
|
this.#unsubscribeStore = null;
|
|
7190
|
+
this.feedback.dispose();
|
|
5048
7191
|
this.#disposeDiagnostics();
|
|
5049
7192
|
this.#clearHandlers();
|
|
5050
7193
|
}
|
|
@@ -5112,7 +7255,6 @@ var SpaceClientCore = class {
|
|
|
5112
7255
|
}
|
|
5113
7256
|
#emitChanges(previous, next) {
|
|
5114
7257
|
this.#emitParticipantChanges(previous, next);
|
|
5115
|
-
this.#emitEpisodeEnd(previous, next);
|
|
5116
7258
|
this.#emitScreenShareChanges(previous, next);
|
|
5117
7259
|
this.#emitErrorChange(previous, next);
|
|
5118
7260
|
}
|
|
@@ -5122,9 +7264,6 @@ var SpaceClientCore = class {
|
|
|
5122
7264
|
for (const participant of after.values()) if (!before.has(participant.participantId)) this.#emit("participantJoined", { participant });
|
|
5123
7265
|
for (const participant of before.values()) if (!after.has(participant.participantId)) this.#emit("participantLeft", { participant });
|
|
5124
7266
|
}
|
|
5125
|
-
#emitEpisodeEnd(previous, next) {
|
|
5126
|
-
if (previous.connection.lastError?.code !== "episode.ended" && next.connection.lastError?.code === "episode.ended") this.#emit("episodeEnded", { episode: previous.connection.episode ?? next.connection.episode });
|
|
5127
|
-
}
|
|
5128
7267
|
#emitScreenShareChanges(previous, next) {
|
|
5129
7268
|
const previousShares = screenShares(previous);
|
|
5130
7269
|
const nextShares = screenShares(next);
|
|
@@ -5146,25 +7285,25 @@ var SpaceClientCore = class {
|
|
|
5146
7285
|
}
|
|
5147
7286
|
};
|
|
5148
7287
|
function liveClock(dependencies) {
|
|
5149
|
-
return
|
|
7288
|
+
return Layer13.succeed(Clock5.Clock, {
|
|
5150
7289
|
currentTimeMillisUnsafe: dependencies.clock.now,
|
|
5151
|
-
currentTimeMillis:
|
|
7290
|
+
currentTimeMillis: Effect12.sync(dependencies.clock.now),
|
|
5152
7291
|
currentTimeNanosUnsafe: () => BigInt(dependencies.clock.now()) * 1000000n,
|
|
5153
|
-
currentTimeNanos:
|
|
5154
|
-
sleep: (duration) =>
|
|
5155
|
-
const handle = dependencies.clock.setTimeout(() => resume(
|
|
5156
|
-
return
|
|
7292
|
+
currentTimeNanos: Effect12.sync(() => BigInt(dependencies.clock.now()) * 1000000n),
|
|
7293
|
+
sleep: (duration) => Effect12.callback((resume) => {
|
|
7294
|
+
const handle = dependencies.clock.setTimeout(() => resume(Effect12.void), Duration2.toMillis(duration));
|
|
7295
|
+
return Effect12.sync(() => dependencies.clock.clearTimeout(handle));
|
|
5157
7296
|
})
|
|
5158
7297
|
});
|
|
5159
7298
|
}
|
|
5160
|
-
function
|
|
5161
|
-
return
|
|
7299
|
+
function foreign6(operation) {
|
|
7300
|
+
return Effect12.tryPromise({ try: operation, catch: (cause) => cause });
|
|
5162
7301
|
}
|
|
5163
7302
|
function trackedEffect(diagnostics, name, effect) {
|
|
5164
7303
|
const operation = diagnostics?.startOperation(name);
|
|
5165
7304
|
return effect.pipe(
|
|
5166
|
-
|
|
5167
|
-
|
|
7305
|
+
Effect12.tap(() => Effect12.sync(() => operation?.succeed())),
|
|
7306
|
+
Effect12.tapError(() => Effect12.sync(() => operation?.fail("operation_failed")))
|
|
5168
7307
|
);
|
|
5169
7308
|
}
|
|
5170
7309
|
async function resolveAccessGrant(input) {
|
|
@@ -5186,7 +7325,7 @@ function diagnosticAccessOperation(request) {
|
|
|
5186
7325
|
}
|
|
5187
7326
|
async function fetchAccessGrant2(input) {
|
|
5188
7327
|
if (input.platform.connectionAccess) return input.platform.connectionAccess(input.request);
|
|
5189
|
-
return input.options.getAccess({ space: input.options.space.trim(), reason: accessReason(input.request?.reason) });
|
|
7328
|
+
return input.options.getAccess({ space: input.options.space.trim(), reason: accessReason(input.request?.reason), replaceMediaConnection: input.request?.replaceMediaConnection ?? false, signal: input.request?.signal });
|
|
5190
7329
|
}
|
|
5191
7330
|
function accessReason(reason) {
|
|
5192
7331
|
if (reason === void 0 || reason === "join") return "join";
|
|
@@ -5225,17 +7364,17 @@ function sameDiagnosticTrack(left, right) {
|
|
|
5225
7364
|
}
|
|
5226
7365
|
|
|
5227
7366
|
// src/space-client/space-client.ts
|
|
5228
|
-
import { Effect as
|
|
7367
|
+
import { Effect as Effect14, ManagedRuntime } from "effect";
|
|
5229
7368
|
|
|
5230
7369
|
// src/space-client/promise-facade.ts
|
|
5231
|
-
import { Effect as
|
|
7370
|
+
import { Effect as Effect13 } from "effect";
|
|
5232
7371
|
function toPromiseController(runtime, controller) {
|
|
5233
7372
|
const projected = {};
|
|
5234
7373
|
for (const [name, command] of Object.entries(controller)) {
|
|
5235
7374
|
if (name === "configure" || name === "dispose") continue;
|
|
5236
7375
|
projected[name] = (...args) => {
|
|
5237
7376
|
const result = command(...args);
|
|
5238
|
-
return
|
|
7377
|
+
return Effect13.isEffect(result) ? runtime.runPromise(result) : result;
|
|
5239
7378
|
};
|
|
5240
7379
|
}
|
|
5241
7380
|
return projected;
|
|
@@ -5243,24 +7382,30 @@ function toPromiseController(runtime, controller) {
|
|
|
5243
7382
|
|
|
5244
7383
|
// src/space-client/space-client.ts
|
|
5245
7384
|
var PromiseSpaceClient = class {
|
|
7385
|
+
feedback;
|
|
5246
7386
|
media;
|
|
5247
7387
|
chat;
|
|
5248
7388
|
participants;
|
|
7389
|
+
offline;
|
|
5249
7390
|
reactions;
|
|
7391
|
+
recording;
|
|
5250
7392
|
whiteboard;
|
|
5251
7393
|
#core;
|
|
5252
7394
|
#runtime;
|
|
5253
7395
|
#disposed = false;
|
|
5254
7396
|
constructor(options, platform) {
|
|
5255
7397
|
this.#runtime = ManagedRuntime.make(makeSpaceClientCoreLayer(options, platform));
|
|
5256
|
-
this.#core = this.#runtime.runSync(
|
|
7398
|
+
this.#core = this.#runtime.runSync(Effect14.service(SpaceClientCoreService));
|
|
5257
7399
|
const controllers = this.#core.controllers;
|
|
7400
|
+
this.feedback = this.#core.feedback;
|
|
5258
7401
|
this.media = toPromiseController(this.#runtime, controllers.media);
|
|
5259
7402
|
const chat = toPromiseController(this.#runtime, controllers.chat);
|
|
5260
|
-
const { upload, url, ...chatCommands } = chat;
|
|
5261
|
-
this.chat = { ...chatCommands, files: { upload, url } };
|
|
7403
|
+
const { upload, url, resolveUrl, ...chatCommands } = chat;
|
|
7404
|
+
this.chat = { ...chatCommands, files: { upload, url, resolveUrl } };
|
|
7405
|
+
this.offline = toPromiseController(this.#runtime, controllers.offline);
|
|
5262
7406
|
this.participants = toPromiseController(this.#runtime, controllers.participants);
|
|
5263
7407
|
this.reactions = toPromiseController(this.#runtime, controllers.reactions);
|
|
7408
|
+
this.recording = toPromiseController(this.#runtime, controllers.recording);
|
|
5264
7409
|
this.whiteboard = toPromiseController(this.#runtime, controllers.whiteboard);
|
|
5265
7410
|
}
|
|
5266
7411
|
join = (options) => this.#runtime.runPromise(this.#core.join(options));
|
|
@@ -5293,11 +7438,11 @@ export {
|
|
|
5293
7438
|
ConnectionPlatformService,
|
|
5294
7439
|
makeConnectionPlatformLayer,
|
|
5295
7440
|
makeFakeConnectionPlatformLayer,
|
|
7441
|
+
ChalkWhiteboardV1Error,
|
|
5296
7442
|
CHALK_CHAT_ATTACHMENT_LIMITS,
|
|
5297
7443
|
CHALK_CHAT_ATTACHMENT_MIME_TYPES,
|
|
5298
7444
|
ChalkChatFileError,
|
|
5299
7445
|
createChalkChatFileHttpTransport,
|
|
5300
|
-
ChalkWhiteboardV1Error,
|
|
5301
7446
|
createChalkWhiteboardV1FileHttpTransport,
|
|
5302
7447
|
encodeWhiteboardV1ClientFrame,
|
|
5303
7448
|
decodeWhiteboardV1ServerFrame,
|
|
@@ -5312,6 +7457,35 @@ export {
|
|
|
5312
7457
|
SpaceStoreService,
|
|
5313
7458
|
makeSpaceStoreLayer,
|
|
5314
7459
|
makeFakeSpaceStoreLayer,
|
|
7460
|
+
FEEDBACK_EVIDENCE_SCHEMA_VERSION,
|
|
7461
|
+
FEEDBACK_REQUEST_SCHEMA_VERSION,
|
|
7462
|
+
FEEDBACK_RECEIPT_SCHEMA_VERSION,
|
|
7463
|
+
FEEDBACK_SCREENSHOT_SCHEMA_VERSION,
|
|
7464
|
+
FEEDBACK_LOCAL_STATE_REGISTRY_VERSION,
|
|
7465
|
+
FEEDBACK_COOKIE_REGISTRY_VERSION,
|
|
7466
|
+
FEEDBACK_MAX_MESSAGE_BYTES,
|
|
7467
|
+
FEEDBACK_MAX_EVIDENCE_BYTES,
|
|
7468
|
+
FEEDBACK_MAX_REQUEST_BYTES,
|
|
7469
|
+
FEEDBACK_MAX_SCREENSHOT_BYTES,
|
|
7470
|
+
FEEDBACK_MAX_SCREENSHOT_WIDTH,
|
|
7471
|
+
FEEDBACK_MAX_SCREENSHOT_HEIGHT,
|
|
7472
|
+
FEEDBACK_MAX_TELEMETRY_EVENTS,
|
|
7473
|
+
FEEDBACK_MAX_DIAGNOSTIC_EVENTS,
|
|
7474
|
+
FEEDBACK_MAX_LOCAL_STATE_ENTRIES,
|
|
7475
|
+
FEEDBACK_MAX_COOKIE_ENTRIES,
|
|
7476
|
+
FeedbackValidationError,
|
|
7477
|
+
validateFeedbackReportRequest,
|
|
7478
|
+
parseFeedbackReportRequest,
|
|
7479
|
+
validateFeedbackEvidence,
|
|
7480
|
+
parseFeedbackEvidence,
|
|
7481
|
+
validateFeedbackScreenshot,
|
|
7482
|
+
parseFeedbackScreenshot,
|
|
7483
|
+
assertFeedbackReceipt,
|
|
7484
|
+
assertFeedbackMessage,
|
|
7485
|
+
collectFeedbackEvidence,
|
|
7486
|
+
collectLocalState,
|
|
7487
|
+
collectCookies,
|
|
7488
|
+
FeedbackTransportError,
|
|
5315
7489
|
SpaceClientCoreService,
|
|
5316
7490
|
makeSpaceClientCoreLayer,
|
|
5317
7491
|
makeSpaceClientCoreLayerFromServices,
|