@a4anthony/proctorkit-sdk 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/dist/delivery-readiness.d.ts +3 -0
- package/dist/delivery-readiness.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +258 -95
- package/dist/preflight/checks.d.ts +22 -17
- package/dist/preflight/checks.d.ts.map +1 -1
- package/dist/preflight/system-check.d.ts +16 -5
- package/dist/preflight/system-check.d.ts.map +1 -1
- package/dist/recording-delivery-readiness.d.ts +24 -2
- package/dist/recording-delivery-readiness.d.ts.map +1 -1
- package/dist/worker.js +5 -2
- package/package.json +2 -2
- package/src/delivery-readiness.ts +8 -1
- package/src/index.ts +7 -0
- package/src/preflight/checks.ts +36 -87
- package/src/preflight/system-check.ts +149 -85
- package/src/recording-delivery-readiness.ts +231 -12
|
@@ -49,10 +49,7 @@ import {
|
|
|
49
49
|
type ConnectionQualityOptions,
|
|
50
50
|
type HttpConnectionProbeOptions,
|
|
51
51
|
} from "./connection-quality.js";
|
|
52
|
-
import {
|
|
53
|
-
runDeepCameraCheck,
|
|
54
|
-
type DeepCameraCheckOptions,
|
|
55
|
-
} from "./deep-camera-check.js";
|
|
52
|
+
import { runDeepCameraCheck, type DeepCameraCheckOptions } from "./deep-camera-check.js";
|
|
56
53
|
|
|
57
54
|
export type { CheckKind, CheckOverrides, CheckRow, CheckState, FailCode };
|
|
58
55
|
|
|
@@ -122,14 +119,13 @@ export interface RequiredCapabilitiesConfig {
|
|
|
122
119
|
*/
|
|
123
120
|
export interface ThresholdsConfig {
|
|
124
121
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* Default: 2.
|
|
122
|
+
* Download-bandwidth reference used for internal quality telemetry. Browser
|
|
123
|
+
* Mbps estimates do not decide candidate eligibility. Default: 2.
|
|
128
124
|
*/
|
|
129
125
|
minBandwidthMbps?: number;
|
|
130
126
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
127
|
+
* Upload-bandwidth reference used for internal quality telemetry. Recording
|
|
128
|
+
* readiness is derived from the enabled media workload instead. Default: 2.
|
|
133
129
|
*/
|
|
134
130
|
minUploadBandwidthMbps?: number;
|
|
135
131
|
/**
|
|
@@ -163,6 +159,52 @@ export interface ThresholdsConfig {
|
|
|
163
159
|
allowMobile?: boolean;
|
|
164
160
|
}
|
|
165
161
|
|
|
162
|
+
export interface ConnectionReadinessCheckResult {
|
|
163
|
+
mode: "direct" | "segments" | "post";
|
|
164
|
+
payloadBytes: number;
|
|
165
|
+
completionWindowMs: number;
|
|
166
|
+
sampleDurationsMs: number[];
|
|
167
|
+
medianCompletionMs: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type ReadinessTelemetry = Partial<
|
|
171
|
+
Pick<
|
|
172
|
+
CheckRow,
|
|
173
|
+
| "recordingUploadMode"
|
|
174
|
+
| "readinessPayloadBytes"
|
|
175
|
+
| "readinessMedianMs"
|
|
176
|
+
| "readinessWindowMs"
|
|
177
|
+
| "readinessSampleCount"
|
|
178
|
+
>
|
|
179
|
+
>;
|
|
180
|
+
|
|
181
|
+
function readinessErrorDetails(error: unknown): ReadinessTelemetry {
|
|
182
|
+
if (!error || typeof error !== "object" || !("details" in error)) return {};
|
|
183
|
+
const details = (error as { details?: unknown }).details;
|
|
184
|
+
if (!details || typeof details !== "object") return {};
|
|
185
|
+
const values = details as Record<string, unknown>;
|
|
186
|
+
const mode = values["recordingUploadMode"];
|
|
187
|
+
const payloadBytes = finiteNumber(values["readinessPayloadBytes"]);
|
|
188
|
+
const medianMs = finiteNumber(values["readinessMedianMs"]);
|
|
189
|
+
const windowMs = finiteNumber(values["readinessWindowMs"]);
|
|
190
|
+
const sampleCount = finiteNumber(values["readinessSampleCount"]);
|
|
191
|
+
return {
|
|
192
|
+
...(mode === "direct" || mode === "segments" || mode === "post"
|
|
193
|
+
? { recordingUploadMode: mode }
|
|
194
|
+
: {}),
|
|
195
|
+
...(payloadBytes !== undefined ? { readinessPayloadBytes: payloadBytes } : {}),
|
|
196
|
+
...(medianMs !== undefined ? { readinessMedianMs: medianMs } : {}),
|
|
197
|
+
...(windowMs !== undefined ? { readinessWindowMs: windowMs } : {}),
|
|
198
|
+
...(sampleCount !== undefined
|
|
199
|
+
? { readinessSampleCount: Math.max(0, Math.floor(sampleCount)) }
|
|
200
|
+
: {}),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
205
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
166
208
|
export interface SystemCheckOptions {
|
|
167
209
|
/**
|
|
168
210
|
* Mode controls the row list and the short-circuit behaviour:
|
|
@@ -221,6 +263,11 @@ export interface SystemCheckOptions {
|
|
|
221
263
|
* SDK consumers.
|
|
222
264
|
*/
|
|
223
265
|
connectionTest?: HttpConnectionProbeOptions & ConnectionQualityOptions;
|
|
266
|
+
/**
|
|
267
|
+
* Optional workload-aware recording-path check. When supplied, this is the
|
|
268
|
+
* candidate eligibility signal; numeric speed tests remain telemetry only.
|
|
269
|
+
*/
|
|
270
|
+
connectionReadiness?: () => Promise<ConnectionReadinessCheckResult>;
|
|
224
271
|
}
|
|
225
272
|
|
|
226
273
|
/** JSON-serialisable final report. No class instances, safe to log + persist. */
|
|
@@ -247,20 +294,9 @@ export interface SimulatedCheckResult {
|
|
|
247
294
|
*/
|
|
248
295
|
export type SystemCheckListener = (rows: ReadonlyArray<CheckRow>) => void;
|
|
249
296
|
|
|
250
|
-
const BROWSER_CLASS_KINDS: CheckKind[] = [
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
"layout",
|
|
254
|
-
"monitor",
|
|
255
|
-
"connection",
|
|
256
|
-
];
|
|
257
|
-
|
|
258
|
-
const MEDIA_KINDS: CheckKind[] = [
|
|
259
|
-
"microphone",
|
|
260
|
-
"speaker",
|
|
261
|
-
"camera",
|
|
262
|
-
"screen-share",
|
|
263
|
-
];
|
|
297
|
+
const BROWSER_CLASS_KINDS: CheckKind[] = ["browser", "device", "layout", "monitor", "connection"];
|
|
298
|
+
|
|
299
|
+
const MEDIA_KINDS: CheckKind[] = ["microphone", "speaker", "camera", "screen-share"];
|
|
264
300
|
|
|
265
301
|
/**
|
|
266
302
|
* The non-retryable front-runners. Browser and device failures are
|
|
@@ -310,6 +346,7 @@ interface ResolvedOptions {
|
|
|
310
346
|
overrides: CheckOverrides | undefined;
|
|
311
347
|
deepCamera: SystemCheckOptions["deepCamera"];
|
|
312
348
|
connectionTest: SystemCheckOptions["connectionTest"];
|
|
349
|
+
connectionReadiness: SystemCheckOptions["connectionReadiness"];
|
|
313
350
|
}
|
|
314
351
|
|
|
315
352
|
export class SystemCheck {
|
|
@@ -336,6 +373,7 @@ export class SystemCheck {
|
|
|
336
373
|
overrides: options.overrides,
|
|
337
374
|
deepCamera: options.deepCamera,
|
|
338
375
|
connectionTest: options.connectionTest,
|
|
376
|
+
connectionReadiness: options.connectionReadiness,
|
|
339
377
|
};
|
|
340
378
|
this.rows = this.buildInitialRows();
|
|
341
379
|
}
|
|
@@ -426,11 +464,9 @@ export class SystemCheck {
|
|
|
426
464
|
continue;
|
|
427
465
|
}
|
|
428
466
|
if (done) break;
|
|
429
|
-
const next = await new Promise<ReadonlyArray<CheckRow> | null>(
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
},
|
|
433
|
-
);
|
|
467
|
+
const next = await new Promise<ReadonlyArray<CheckRow> | null>((resolve) => {
|
|
468
|
+
resolveWaiter = resolve;
|
|
469
|
+
});
|
|
434
470
|
if (next === null) break;
|
|
435
471
|
yield next;
|
|
436
472
|
}
|
|
@@ -495,10 +531,7 @@ export class SystemCheck {
|
|
|
495
531
|
row.state = { kind: "pass", detail: "Ready" };
|
|
496
532
|
}
|
|
497
533
|
this.notify();
|
|
498
|
-
if (
|
|
499
|
-
row.state.kind === "fail" &&
|
|
500
|
-
BROWSER_CLASS_KINDS.includes(row.kind)
|
|
501
|
-
) {
|
|
534
|
+
if (row.state.kind === "fail" && BROWSER_CLASS_KINDS.includes(row.kind)) {
|
|
502
535
|
browserClassFailed = true;
|
|
503
536
|
}
|
|
504
537
|
}
|
|
@@ -529,8 +562,7 @@ export class SystemCheck {
|
|
|
529
562
|
|
|
530
563
|
// Short-circuit media when any system-class check failed.
|
|
531
564
|
const browserClassFailed = this.rows.some(
|
|
532
|
-
(r) =>
|
|
533
|
-
r.state.kind === "fail" && BROWSER_CLASS_KINDS.includes(r.kind),
|
|
565
|
+
(r) => r.state.kind === "fail" && BROWSER_CLASS_KINDS.includes(r.kind),
|
|
534
566
|
);
|
|
535
567
|
if (browserClassFailed) {
|
|
536
568
|
this.skipPending(MEDIA_KINDS);
|
|
@@ -548,10 +580,7 @@ export class SystemCheck {
|
|
|
548
580
|
return this.buildReport();
|
|
549
581
|
}
|
|
550
582
|
|
|
551
|
-
private async runRow(
|
|
552
|
-
kind: CheckKind,
|
|
553
|
-
compute: () => CheckState,
|
|
554
|
-
): Promise<void> {
|
|
583
|
+
private async runRow(kind: CheckKind, compute: () => CheckState): Promise<void> {
|
|
555
584
|
if (!this.hasRow(kind)) return;
|
|
556
585
|
this.setState(kind, { kind: "checking", message: "Checking..." });
|
|
557
586
|
await this.delay(this.options.stepDelayMs);
|
|
@@ -580,19 +609,61 @@ export class SystemCheck {
|
|
|
580
609
|
message: "Testing connection...",
|
|
581
610
|
});
|
|
582
611
|
const start = performance.now();
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
612
|
+
let result: CheckRow;
|
|
613
|
+
if (this.options.connectionReadiness) {
|
|
614
|
+
try {
|
|
615
|
+
const readiness = await this.options.connectionReadiness();
|
|
616
|
+
result = {
|
|
617
|
+
kind: "connection",
|
|
618
|
+
state: { kind: "pass", detail: "Connection available" },
|
|
619
|
+
recordingUploadMode: readiness.mode,
|
|
620
|
+
readinessPayloadBytes: readiness.payloadBytes,
|
|
621
|
+
readinessMedianMs: readiness.medianCompletionMs,
|
|
622
|
+
readinessWindowMs: readiness.completionWindowMs,
|
|
623
|
+
readinessSampleCount: readiness.sampleDurationsMs.length,
|
|
624
|
+
};
|
|
625
|
+
} catch (error) {
|
|
626
|
+
const diagnostic =
|
|
627
|
+
error && typeof error === "object" && "code" in error
|
|
628
|
+
? String((error as { code: unknown }).code)
|
|
629
|
+
: "unavailable";
|
|
630
|
+
const details = readinessErrorDetails(error);
|
|
631
|
+
result = {
|
|
632
|
+
kind: "connection",
|
|
633
|
+
state: {
|
|
634
|
+
kind: "fail",
|
|
635
|
+
code: "speed-test-failed",
|
|
636
|
+
detail: `Connection readiness failed: ${diagnostic}`,
|
|
637
|
+
},
|
|
638
|
+
...(details.recordingUploadMode !== undefined
|
|
639
|
+
? { recordingUploadMode: details.recordingUploadMode }
|
|
640
|
+
: {}),
|
|
641
|
+
...(details.readinessPayloadBytes !== undefined
|
|
642
|
+
? { readinessPayloadBytes: details.readinessPayloadBytes }
|
|
643
|
+
: {}),
|
|
644
|
+
...(details.readinessMedianMs !== undefined
|
|
645
|
+
? { readinessMedianMs: details.readinessMedianMs }
|
|
646
|
+
: {}),
|
|
647
|
+
...(details.readinessWindowMs !== undefined
|
|
648
|
+
? { readinessWindowMs: details.readinessWindowMs }
|
|
649
|
+
: {}),
|
|
650
|
+
...(details.readinessSampleCount !== undefined
|
|
651
|
+
? { readinessSampleCount: details.readinessSampleCount }
|
|
652
|
+
: {}),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
result = this.options.connectionTest
|
|
657
|
+
? applyConnectionQualityToResult(
|
|
658
|
+
await measureConnectionQuality(
|
|
659
|
+
createHttpConnectionProbe(this.options.connectionTest),
|
|
660
|
+
this.options.connectionTest,
|
|
661
|
+
),
|
|
662
|
+
this.options.thresholds.minBandwidthMbps,
|
|
663
|
+
this.options.thresholds.minUploadBandwidthMbps,
|
|
664
|
+
)
|
|
665
|
+
: applySpeedToResult(await measureSpeed(), this.options.thresholds.minBandwidthMbps);
|
|
666
|
+
}
|
|
596
667
|
const elapsed = performance.now() - start;
|
|
597
668
|
if (elapsed < this.options.minSpeedTestMs) {
|
|
598
669
|
await this.delay(this.options.minSpeedTestMs - elapsed);
|
|
@@ -613,6 +684,21 @@ export class SystemCheck {
|
|
|
613
684
|
if (result.band !== undefined) {
|
|
614
685
|
this.setRowField("connection", "band", result.band);
|
|
615
686
|
}
|
|
687
|
+
if (result.recordingUploadMode !== undefined) {
|
|
688
|
+
this.setRowField("connection", "recordingUploadMode", result.recordingUploadMode);
|
|
689
|
+
}
|
|
690
|
+
if (result.readinessPayloadBytes !== undefined) {
|
|
691
|
+
this.setRowField("connection", "readinessPayloadBytes", result.readinessPayloadBytes);
|
|
692
|
+
}
|
|
693
|
+
if (result.readinessMedianMs !== undefined) {
|
|
694
|
+
this.setRowField("connection", "readinessMedianMs", result.readinessMedianMs);
|
|
695
|
+
}
|
|
696
|
+
if (result.readinessWindowMs !== undefined) {
|
|
697
|
+
this.setRowField("connection", "readinessWindowMs", result.readinessWindowMs);
|
|
698
|
+
}
|
|
699
|
+
if (result.readinessSampleCount !== undefined) {
|
|
700
|
+
this.setRowField("connection", "readinessSampleCount", result.readinessSampleCount);
|
|
701
|
+
}
|
|
616
702
|
}
|
|
617
703
|
|
|
618
704
|
/**
|
|
@@ -636,9 +722,7 @@ export class SystemCheck {
|
|
|
636
722
|
* candidate, so mark any still-pending ones skipped and stop. Returns
|
|
637
723
|
* true if the gate tripped. See {@link GATE_KINDS}.
|
|
638
724
|
*/
|
|
639
|
-
private async runSystemClassSequence(
|
|
640
|
-
overrides: CheckOverrides,
|
|
641
|
-
): Promise<boolean> {
|
|
725
|
+
private async runSystemClassSequence(overrides: CheckOverrides): Promise<boolean> {
|
|
642
726
|
await this.runRow("browser", () => checkBrowser(overrides).state);
|
|
643
727
|
await this.runRow(
|
|
644
728
|
"device",
|
|
@@ -671,9 +755,7 @@ export class SystemCheck {
|
|
|
671
755
|
|
|
672
756
|
/** True once a non-retryable gate check (browser/device) has failed. */
|
|
673
757
|
private gateTripped(): boolean {
|
|
674
|
-
return this.rows.some(
|
|
675
|
-
(r) => GATE_KINDS.includes(r.kind) && r.state.kind === "fail",
|
|
676
|
-
);
|
|
758
|
+
return this.rows.some((r) => GATE_KINDS.includes(r.kind) && r.state.kind === "fail");
|
|
677
759
|
}
|
|
678
760
|
|
|
679
761
|
/** Mark every still-pending row of the given kinds as skipped. */
|
|
@@ -742,11 +824,7 @@ export class SystemCheck {
|
|
|
742
824
|
message: "Checking permission...",
|
|
743
825
|
});
|
|
744
826
|
const perms = await queryPermissions({ mic: false, camera: true });
|
|
745
|
-
const camState = await this.resolveCameraState(
|
|
746
|
-
perms.camera,
|
|
747
|
-
deviceId,
|
|
748
|
-
existingStream,
|
|
749
|
-
);
|
|
827
|
+
const camState = await this.resolveCameraState(perms.camera, deviceId, existingStream);
|
|
750
828
|
this.setState("camera", camState.state);
|
|
751
829
|
if (camState.photo) this.setRowField("camera", "photo", camState.photo);
|
|
752
830
|
return { attempts: camState.attempts ?? [] };
|
|
@@ -788,10 +866,7 @@ export class SystemCheck {
|
|
|
788
866
|
// a browser without `getDisplayMedia` will fail at session
|
|
789
867
|
// start anyway, but the candidate deserves to know now while
|
|
790
868
|
// they can still switch to a supported browser.
|
|
791
|
-
if (
|
|
792
|
-
typeof navigator === "undefined" ||
|
|
793
|
-
!navigator.mediaDevices?.getDisplayMedia
|
|
794
|
-
) {
|
|
869
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
|
|
795
870
|
this.setState("screen-share", {
|
|
796
871
|
kind: "fail",
|
|
797
872
|
code: "screen-share-not-supported",
|
|
@@ -1059,9 +1134,8 @@ export class SystemCheck {
|
|
|
1059
1134
|
* Convenience wrapper for the common case ("just run it once and give
|
|
1060
1135
|
* me the report"). Equivalent to `new SystemCheck(options).runOnce()`.
|
|
1061
1136
|
*/
|
|
1062
|
-
export const runSystemCheck = async (
|
|
1063
|
-
options
|
|
1064
|
-
): Promise<PreflightReport> => new SystemCheck(options).runOnce();
|
|
1137
|
+
export const runSystemCheck = async (options: SystemCheckOptions = {}): Promise<PreflightReport> =>
|
|
1138
|
+
new SystemCheck(options).runOnce();
|
|
1065
1139
|
|
|
1066
1140
|
// ============================================================================
|
|
1067
1141
|
// Permissions + media-device probing
|
|
@@ -1079,9 +1153,7 @@ interface PermissionResults {
|
|
|
1079
1153
|
camera: PermissionResult;
|
|
1080
1154
|
}
|
|
1081
1155
|
|
|
1082
|
-
const queryPermissions = async (
|
|
1083
|
-
q: PermissionQuery,
|
|
1084
|
-
): Promise<PermissionResults> => {
|
|
1156
|
+
const queryPermissions = async (q: PermissionQuery): Promise<PermissionResults> => {
|
|
1085
1157
|
const probe = async (name: PermissionName): Promise<PermissionResult> => {
|
|
1086
1158
|
if (typeof navigator === "undefined" || !navigator.permissions) {
|
|
1087
1159
|
return "unknown";
|
|
@@ -1147,8 +1219,7 @@ const resolveAudioInputState = async (
|
|
|
1147
1219
|
detail: "Permission required",
|
|
1148
1220
|
};
|
|
1149
1221
|
}
|
|
1150
|
-
const firstLabel =
|
|
1151
|
-
pickDeviceLabel(devices, deviceId) || "Ready";
|
|
1222
|
+
const firstLabel = pickDeviceLabel(devices, deviceId) || "Ready";
|
|
1152
1223
|
return { kind: "pass", detail: firstLabel };
|
|
1153
1224
|
}
|
|
1154
1225
|
const devices = await enumerateAudioInputs();
|
|
@@ -1163,11 +1234,8 @@ const resolveAudioInputState = async (
|
|
|
1163
1234
|
return { kind: "pass", detail: label };
|
|
1164
1235
|
};
|
|
1165
1236
|
|
|
1166
|
-
const hasLiveVideoStream = (
|
|
1167
|
-
video
|
|
1168
|
-
): boolean => {
|
|
1169
|
-
const stream =
|
|
1170
|
-
video?.srcObject instanceof MediaStream ? video.srcObject : null;
|
|
1237
|
+
const hasLiveVideoStream = (video: HTMLVideoElement | undefined): boolean => {
|
|
1238
|
+
const stream = video?.srcObject instanceof MediaStream ? video.srcObject : null;
|
|
1171
1239
|
return stream?.getVideoTracks().some((track) => track.readyState === "live") ?? false;
|
|
1172
1240
|
};
|
|
1173
1241
|
|
|
@@ -1176,10 +1244,7 @@ const hasLiveVideoStream = (
|
|
|
1176
1244
|
* candidate's specific deviceId resolves, use its label; otherwise
|
|
1177
1245
|
* fall back to the first labelled device.
|
|
1178
1246
|
*/
|
|
1179
|
-
const pickDeviceLabel = (
|
|
1180
|
-
devices: ReadonlyArray<MediaDeviceInfo>,
|
|
1181
|
-
deviceId?: string,
|
|
1182
|
-
): string => {
|
|
1247
|
+
const pickDeviceLabel = (devices: ReadonlyArray<MediaDeviceInfo>, deviceId?: string): string => {
|
|
1183
1248
|
if (deviceId) {
|
|
1184
1249
|
const exact = devices.find((d) => d.deviceId === deviceId);
|
|
1185
1250
|
if (exact?.label) return exact.label;
|
|
@@ -1206,7 +1271,6 @@ const resolveSpeakerState = async (): Promise<CheckState> => {
|
|
|
1206
1271
|
detail: "Grant microphone permission to list audio outputs",
|
|
1207
1272
|
};
|
|
1208
1273
|
}
|
|
1209
|
-
const label =
|
|
1210
|
-
devices.find((d) => d.label)?.label ?? "Default output";
|
|
1274
|
+
const label = devices.find((d) => d.label)?.label ?? "Default output";
|
|
1211
1275
|
return { kind: "pass", detail: label };
|
|
1212
1276
|
};
|