@a4anthony/proctorkit-sdk 0.1.1 → 0.2.1
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
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
import { DeliveryReadinessError } from "./delivery-readiness.js";
|
|
2
2
|
|
|
3
3
|
const RECORDING_READINESS_TIMEOUT_MS = 10_000;
|
|
4
|
-
const
|
|
4
|
+
const DEFAULT_CANARY_BYTES = 1_024;
|
|
5
|
+
const MAX_CANARY_BYTES = 10_000_000;
|
|
6
|
+
const DEFAULT_MEASUREMENT_SAMPLES = 3;
|
|
5
7
|
|
|
6
|
-
interface RecordingReadinessConfig {
|
|
8
|
+
export interface RecordingReadinessConfig {
|
|
7
9
|
sessionId: string;
|
|
8
10
|
ingestUrl: string;
|
|
9
11
|
appId?: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
export interface RecordingDeliveryMeasurementConfig extends RecordingReadinessConfig {
|
|
15
|
+
/** Representative bytes produced by the enabled recorders in one workload window. */
|
|
16
|
+
payloadBytes: number;
|
|
17
|
+
/** Maximum acceptable upload completion time for the representative payload. */
|
|
18
|
+
completionWindowMs: number;
|
|
19
|
+
/** Number of scored uploads. Default: 3; bounded to 1..5. */
|
|
20
|
+
samples?: number;
|
|
21
|
+
/** Injectable monotonic clock for deterministic tests. */
|
|
22
|
+
now?: () => number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RecordingDeliveryMeasurement {
|
|
26
|
+
mode: "direct" | "segments" | "post";
|
|
27
|
+
payloadBytes: number;
|
|
28
|
+
completionWindowMs: number;
|
|
29
|
+
sampleDurationsMs: number[];
|
|
30
|
+
medianCompletionMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
interface DirectCanary {
|
|
13
34
|
canaryId: string;
|
|
14
35
|
uploadId: string;
|
|
15
36
|
url: string;
|
|
37
|
+
expectedBytes: number;
|
|
16
38
|
}
|
|
17
39
|
|
|
18
40
|
/** Verify the server-selected recording path. No host-facing controls exist. */
|
|
@@ -40,9 +62,9 @@ export async function verifyRecordingDelivery(
|
|
|
40
62
|
const mode = await resolveUploadMode(baseUrl, controller.signal);
|
|
41
63
|
phase = "media";
|
|
42
64
|
if (mode === "direct" || mode === "segments") {
|
|
43
|
-
await verifyDirectPath(baseUrl, config, controller.signal);
|
|
65
|
+
await verifyDirectPath(baseUrl, config, DEFAULT_CANARY_BYTES, controller.signal, defaultNow);
|
|
44
66
|
} else {
|
|
45
|
-
await verifyPostPath(baseUrl, config, controller.signal);
|
|
67
|
+
await verifyPostPath(baseUrl, config, DEFAULT_CANARY_BYTES, controller.signal, defaultNow);
|
|
46
68
|
}
|
|
47
69
|
return mode;
|
|
48
70
|
} catch (error) {
|
|
@@ -68,6 +90,91 @@ export async function verifyRecordingDelivery(
|
|
|
68
90
|
}
|
|
69
91
|
}
|
|
70
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Measure whether the real recording destination can absorb the configured
|
|
95
|
+
* media workload. A disposable warm-up is excluded, then the median of the
|
|
96
|
+
* scored uploads is compared with the workload completion window.
|
|
97
|
+
*/
|
|
98
|
+
export async function measureRecordingDeliveryReadiness(
|
|
99
|
+
config: RecordingDeliveryMeasurementConfig,
|
|
100
|
+
): Promise<RecordingDeliveryMeasurement> {
|
|
101
|
+
const payloadBytes = normaliseCanaryBytes(config.payloadBytes);
|
|
102
|
+
const completionWindowMs = normaliseCompletionWindow(config.completionWindowMs);
|
|
103
|
+
const sampleCount = Math.min(
|
|
104
|
+
5,
|
|
105
|
+
Math.max(1, Math.floor(config.samples ?? DEFAULT_MEASUREMENT_SAMPLES)),
|
|
106
|
+
);
|
|
107
|
+
const now = config.now ?? defaultNow;
|
|
108
|
+
const baseUrl = originOf(config.ingestUrl);
|
|
109
|
+
if (!baseUrl) {
|
|
110
|
+
throw new DeliveryReadinessError(
|
|
111
|
+
"upload-config-invalid",
|
|
112
|
+
"The ingest URL does not provide a valid recording upload origin.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const mode = await withReadinessTimeout(RECORDING_READINESS_TIMEOUT_MS, "config", (signal) =>
|
|
117
|
+
resolveUploadMode(baseUrl, signal),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Warm the exact storage/CORS path, but never use a cold request to decide
|
|
121
|
+
// candidate eligibility. A failed warm-up is also excluded; the scored
|
|
122
|
+
// attempts below provide the repeated evidence used for the decision.
|
|
123
|
+
await runPathProbe(baseUrl, config, mode, DEFAULT_CANARY_BYTES, now).catch(() => undefined);
|
|
124
|
+
|
|
125
|
+
const durations: number[] = [];
|
|
126
|
+
let lastError: unknown;
|
|
127
|
+
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
|
128
|
+
try {
|
|
129
|
+
durations.push(await runPathProbe(baseUrl, config, mode, payloadBytes, now));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
lastError = error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const requiredSuccesses = Math.ceil(sampleCount / 2);
|
|
136
|
+
if (durations.length < requiredSuccesses) {
|
|
137
|
+
const details = measurementDetails(mode, payloadBytes, completionWindowMs, durations.length);
|
|
138
|
+
if (lastError instanceof DeliveryReadinessError) {
|
|
139
|
+
throw new DeliveryReadinessError(lastError.code, lastError.message, {
|
|
140
|
+
cause: lastError,
|
|
141
|
+
...(lastError.status !== undefined ? { status: lastError.status } : {}),
|
|
142
|
+
details,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
throw new DeliveryReadinessError(
|
|
146
|
+
"media-storage-unreachable",
|
|
147
|
+
"The recording storage path could not complete enough readiness uploads.",
|
|
148
|
+
{ cause: lastError, details },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const medianCompletionMs = median(durations);
|
|
153
|
+
if (medianCompletionMs > completionWindowMs) {
|
|
154
|
+
throw new DeliveryReadinessError(
|
|
155
|
+
"media-storage-too-slow",
|
|
156
|
+
`The representative recording payload took ${Math.round(medianCompletionMs)} ms; the workload window is ${Math.round(completionWindowMs)} ms.`,
|
|
157
|
+
{
|
|
158
|
+
details: measurementDetails(
|
|
159
|
+
mode,
|
|
160
|
+
payloadBytes,
|
|
161
|
+
completionWindowMs,
|
|
162
|
+
durations.length,
|
|
163
|
+
medianCompletionMs,
|
|
164
|
+
),
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
mode,
|
|
171
|
+
payloadBytes,
|
|
172
|
+
completionWindowMs,
|
|
173
|
+
sampleDurationsMs: durations.map((duration) => Math.round(duration)),
|
|
174
|
+
medianCompletionMs: Math.round(medianCompletionMs),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
71
178
|
async function resolveUploadMode(
|
|
72
179
|
baseUrl: string,
|
|
73
180
|
signal: AbortSignal,
|
|
@@ -111,31 +218,40 @@ async function resolveUploadMode(
|
|
|
111
218
|
async function verifyDirectPath(
|
|
112
219
|
baseUrl: string,
|
|
113
220
|
config: RecordingReadinessConfig,
|
|
221
|
+
canaryBytes: number,
|
|
114
222
|
signal: AbortSignal,
|
|
115
|
-
|
|
223
|
+
now: () => number,
|
|
224
|
+
): Promise<number> {
|
|
116
225
|
let canary: DirectCanary | null = null;
|
|
117
226
|
try {
|
|
118
227
|
canary = await postJson<DirectCanary>(
|
|
119
228
|
`${baseUrl}/uploads/readiness/direct/start`,
|
|
120
|
-
{ sessionId: config.sessionId },
|
|
229
|
+
{ sessionId: config.sessionId, bytes: canaryBytes },
|
|
121
230
|
config.appId,
|
|
122
231
|
signal,
|
|
123
232
|
);
|
|
124
|
-
if (
|
|
233
|
+
if (
|
|
234
|
+
!canary.canaryId ||
|
|
235
|
+
!canary.uploadId ||
|
|
236
|
+
!canary.url ||
|
|
237
|
+
canary.expectedBytes !== canaryBytes
|
|
238
|
+
) {
|
|
125
239
|
throw new DeliveryReadinessError(
|
|
126
240
|
"media-storage-invalid-response",
|
|
127
241
|
"The recording storage canary did not return an upload target.",
|
|
128
242
|
);
|
|
129
243
|
}
|
|
130
244
|
|
|
245
|
+
const uploadStartedAt = now();
|
|
131
246
|
const put = await fetch(canary.url, {
|
|
132
247
|
method: "PUT",
|
|
133
|
-
body: new Blob([new Uint8Array(
|
|
248
|
+
body: new Blob([new Uint8Array(canaryBytes)], {
|
|
134
249
|
type: "application/octet-stream",
|
|
135
250
|
}),
|
|
136
251
|
credentials: "omit",
|
|
137
252
|
signal,
|
|
138
253
|
});
|
|
254
|
+
const uploadDurationMs = Math.max(0.1, now() - uploadStartedAt);
|
|
139
255
|
if (!put.ok) {
|
|
140
256
|
throw mediaHttpError(put.status, "The recording storage rejected the canary upload.");
|
|
141
257
|
}
|
|
@@ -146,17 +262,19 @@ async function verifyDirectPath(
|
|
|
146
262
|
sessionId: config.sessionId,
|
|
147
263
|
canaryId: canary.canaryId,
|
|
148
264
|
uploadId: canary.uploadId,
|
|
265
|
+
expectedBytes: canaryBytes,
|
|
149
266
|
},
|
|
150
267
|
config.appId,
|
|
151
268
|
signal,
|
|
152
269
|
);
|
|
153
|
-
if (completed.ok !== true || Number(completed.receivedBytes) !==
|
|
270
|
+
if (completed.ok !== true || Number(completed.receivedBytes) !== canaryBytes) {
|
|
154
271
|
throw new DeliveryReadinessError(
|
|
155
272
|
"media-storage-invalid-response",
|
|
156
273
|
"The recording storage did not acknowledge the complete canary.",
|
|
157
274
|
);
|
|
158
275
|
}
|
|
159
276
|
canary = null;
|
|
277
|
+
return uploadDurationMs;
|
|
160
278
|
} finally {
|
|
161
279
|
if (canary) void abortDirectCanary(baseUrl, config, canary);
|
|
162
280
|
}
|
|
@@ -165,24 +283,28 @@ async function verifyDirectPath(
|
|
|
165
283
|
async function verifyPostPath(
|
|
166
284
|
baseUrl: string,
|
|
167
285
|
config: RecordingReadinessConfig,
|
|
286
|
+
canaryBytes: number,
|
|
168
287
|
signal: AbortSignal,
|
|
169
|
-
|
|
288
|
+
now: () => number,
|
|
289
|
+
): Promise<number> {
|
|
170
290
|
const headers: Record<string, string> = {
|
|
171
291
|
"content-type": "application/octet-stream",
|
|
172
292
|
};
|
|
173
293
|
if (config.appId) headers["x-app-id"] = config.appId;
|
|
294
|
+
const uploadStartedAt = now();
|
|
174
295
|
const response = await fetch(
|
|
175
296
|
`${baseUrl}/uploads/readiness/post/${encodeURIComponent(config.sessionId)}`,
|
|
176
297
|
{
|
|
177
298
|
method: "POST",
|
|
178
299
|
headers,
|
|
179
|
-
body: new Blob([new Uint8Array(
|
|
300
|
+
body: new Blob([new Uint8Array(canaryBytes)], {
|
|
180
301
|
type: "application/octet-stream",
|
|
181
302
|
}),
|
|
182
303
|
credentials: "omit",
|
|
183
304
|
signal,
|
|
184
305
|
},
|
|
185
306
|
);
|
|
307
|
+
const uploadDurationMs = Math.max(0.1, now() - uploadStartedAt);
|
|
186
308
|
if (!response.ok) {
|
|
187
309
|
throw mediaHttpError(response.status, "The recording relay rejected the canary upload.");
|
|
188
310
|
}
|
|
@@ -196,12 +318,13 @@ async function verifyPostPath(
|
|
|
196
318
|
{ cause: error },
|
|
197
319
|
);
|
|
198
320
|
}
|
|
199
|
-
if (body.ok !== true || Number(body.receivedBytes) !==
|
|
321
|
+
if (body.ok !== true || Number(body.receivedBytes) !== canaryBytes) {
|
|
200
322
|
throw new DeliveryReadinessError(
|
|
201
323
|
"media-storage-invalid-response",
|
|
202
324
|
"The recording relay did not acknowledge the complete canary.",
|
|
203
325
|
);
|
|
204
326
|
}
|
|
327
|
+
return uploadDurationMs;
|
|
205
328
|
}
|
|
206
329
|
|
|
207
330
|
async function postJson<T>(
|
|
@@ -247,6 +370,7 @@ async function abortDirectCanary(
|
|
|
247
370
|
sessionId: config.sessionId,
|
|
248
371
|
canaryId: canary.canaryId,
|
|
249
372
|
uploadId: canary.uploadId,
|
|
373
|
+
expectedBytes: canary.expectedBytes,
|
|
250
374
|
},
|
|
251
375
|
config.appId,
|
|
252
376
|
controller.signal,
|
|
@@ -258,6 +382,101 @@ async function abortDirectCanary(
|
|
|
258
382
|
}
|
|
259
383
|
}
|
|
260
384
|
|
|
385
|
+
async function runPathProbe(
|
|
386
|
+
baseUrl: string,
|
|
387
|
+
config: RecordingReadinessConfig,
|
|
388
|
+
mode: "direct" | "segments" | "post",
|
|
389
|
+
bytes: number,
|
|
390
|
+
now: () => number,
|
|
391
|
+
): Promise<number> {
|
|
392
|
+
return withReadinessTimeout(RECORDING_READINESS_TIMEOUT_MS, "media", (signal) =>
|
|
393
|
+
mode === "direct" || mode === "segments"
|
|
394
|
+
? verifyDirectPath(baseUrl, config, bytes, signal, now)
|
|
395
|
+
: verifyPostPath(baseUrl, config, bytes, signal, now),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function withReadinessTimeout<T>(
|
|
400
|
+
timeoutMs: number,
|
|
401
|
+
phase: "config" | "media",
|
|
402
|
+
run: (signal: AbortSignal) => Promise<T>,
|
|
403
|
+
): Promise<T> {
|
|
404
|
+
const controller = new AbortController();
|
|
405
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
406
|
+
try {
|
|
407
|
+
return await run(controller.signal);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error instanceof DeliveryReadinessError) throw error;
|
|
410
|
+
if (controller.signal.aborted) {
|
|
411
|
+
throw new DeliveryReadinessError(
|
|
412
|
+
phase === "config" ? "upload-config-timeout" : "media-storage-timeout",
|
|
413
|
+
phase === "config"
|
|
414
|
+
? "Timed out while resolving the recording upload path."
|
|
415
|
+
: "Timed out while verifying recording storage.",
|
|
416
|
+
{ cause: error },
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
throw new DeliveryReadinessError(
|
|
420
|
+
phase === "config" ? "upload-config-unreachable" : "media-storage-unreachable",
|
|
421
|
+
phase === "config"
|
|
422
|
+
? "The recording upload configuration could not be reached."
|
|
423
|
+
: "The recording storage path could not be reached.",
|
|
424
|
+
{ cause: error },
|
|
425
|
+
);
|
|
426
|
+
} finally {
|
|
427
|
+
clearTimeout(timeout);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function normaliseCanaryBytes(value: number): number {
|
|
432
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
433
|
+
throw new DeliveryReadinessError(
|
|
434
|
+
"media-storage-invalid-response",
|
|
435
|
+
"The recording readiness payload size must be a positive number.",
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return Math.min(MAX_CANARY_BYTES, Math.max(1, Math.round(value)));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function normaliseCompletionWindow(value: number): number {
|
|
442
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
443
|
+
throw new DeliveryReadinessError(
|
|
444
|
+
"media-storage-invalid-response",
|
|
445
|
+
"The recording readiness completion window must be positive.",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return Math.max(100, Math.round(value));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function median(values: number[]): number {
|
|
452
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
453
|
+
const middle = Math.floor(sorted.length / 2);
|
|
454
|
+
if (sorted.length % 2 === 1) return sorted[middle]!;
|
|
455
|
+
return (sorted[middle - 1]! + sorted[middle]!) / 2;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function measurementDetails(
|
|
459
|
+
mode: "direct" | "segments" | "post",
|
|
460
|
+
payloadBytes: number,
|
|
461
|
+
completionWindowMs: number,
|
|
462
|
+
successfulSamples: number,
|
|
463
|
+
medianCompletionMs?: number,
|
|
464
|
+
): Readonly<Record<string, string | number>> {
|
|
465
|
+
return {
|
|
466
|
+
recordingUploadMode: mode,
|
|
467
|
+
readinessPayloadBytes: payloadBytes,
|
|
468
|
+
readinessWindowMs: Math.round(completionWindowMs),
|
|
469
|
+
readinessSampleCount: successfulSamples,
|
|
470
|
+
...(medianCompletionMs !== undefined
|
|
471
|
+
? { readinessMedianMs: Math.round(medianCompletionMs) }
|
|
472
|
+
: {}),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function defaultNow(): number {
|
|
477
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
478
|
+
}
|
|
479
|
+
|
|
261
480
|
function mediaHttpError(status: number, message: string): DeliveryReadinessError {
|
|
262
481
|
return new DeliveryReadinessError(
|
|
263
482
|
status >= 400 && status < 500 ? "media-storage-rejected" : "media-storage-unreachable",
|