@voltkit-rtc/client 0.7.1 → 0.7.3

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/src/recording.ts CHANGED
@@ -53,25 +53,26 @@ export class RecordingError extends Error {
53
53
  }
54
54
  const delay = (ms: number) =>
55
55
  new Promise<void>((resolve) => setTimeout(resolve, ms));
56
+ /** One POST to the recording API. */
57
+ function recordingFetch(base: string, path: string, body: unknown, token?: string): Promise<Response> {
58
+ return fetch(`${base.replace(/\/$/, "")}/recording/upload${path}`, {
59
+ method: "POST",
60
+ credentials: "omit",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
64
+ },
65
+ body: JSON.stringify(body),
66
+ signal: AbortSignal.timeout(path.endsWith("/complete") ? 180000 : 15000),
67
+ });
68
+ }
56
69
  export async function recordingRequest<T>(
57
70
  base: string,
58
71
  path: string,
59
72
  body: unknown,
60
73
  token?: string,
61
74
  ): Promise<T> {
62
- const response = await fetch(
63
- `${base.replace(/\/$/, "")}/recording/upload${path}`,
64
- {
65
- method: "POST",
66
- credentials: "omit",
67
- headers: {
68
- "Content-Type": "application/json",
69
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
70
- },
71
- body: JSON.stringify(body),
72
- signal: AbortSignal.timeout(path.endsWith("/complete") ? 180000 : 15000),
73
- },
74
- );
75
+ const response = await recordingFetch(base, path, body, token);
75
76
  const result = (await response.json()) as {
76
77
  success: boolean;
77
78
  data?: T;
@@ -304,16 +305,53 @@ export class RecordingUploader {
304
305
  }
305
306
  this.sealed = true;
306
307
  }
308
+ /**
309
+ * How long complete() waits before asking `/complete` again when the
310
+ * answer was lost or unclear: one wait per extra attempt. Exposed for tests.
311
+ * @internal
312
+ */
313
+ static completeRetryDelaysMs = [1000, 3000];
314
+
315
+ /**
316
+ * Finalise. An accepted `/complete` whose reply cannot be read rejects
317
+ * saying the recording completed. A lost or unclear answer (no reply, an
318
+ * unreadable one, a 5xx, 408/409/425/429) is asked again -- the service's
319
+ * completion is idempotent -- and if it is still unclear, rejects saying the
320
+ * recording may already be saved. Neither is aborted; only a readable
321
+ * refusal is final (V072-04).
322
+ */
307
323
  async complete(): Promise<RecordingFile> {
308
324
  await this.seal();
309
- const file = await recordingRequest<RecordingFile>(
310
- this.options.apiUrl,
311
- `/sessions/${this.session.recording.id}/complete`,
312
- {},
313
- this.session.token,
314
- );
315
- this.emit("ready");
316
- return file;
325
+ const path = `/sessions/${this.session.recording.id}/complete`;
326
+ for (let attempt = 0; ; attempt++) {
327
+ type Reply = { success?: unknown; data?: unknown; error?: { message?: string } };
328
+ let status = 0;
329
+ let body: Reply | null = null;
330
+ try {
331
+ const response = await recordingFetch(this.options.apiUrl, path, {}, this.session.token);
332
+ status = response.status;
333
+ const parsed: unknown = await response.json();
334
+ if (parsed && typeof parsed === "object") body = parsed as Reply;
335
+ } catch {
336
+ // Lost, cut short or unreadable: the service may have saved it.
337
+ }
338
+ if (body && status > 0 && status < 400 && body.success === true) {
339
+ const file = body.data as RecordingFile | null | undefined;
340
+ if (!file || typeof file !== "object" || typeof file.id !== "string") {
341
+ throw new RecordingError("The recording was completed but its reply could not be read", status);
342
+ }
343
+ this.emit("ready");
344
+ return file;
345
+ }
346
+ if (body && status > 0 && status < 500 && ![408, 409, 425, 429].includes(status)) {
347
+ throw new RecordingError(body.error?.message ?? "Recording request failed", status);
348
+ }
349
+ const delays = RecordingUploader.completeRetryDelaysMs;
350
+ if (attempt >= delays.length) {
351
+ throw new RecordingError("The service did not confirm the recording, which may already be saved; it was not aborted", status);
352
+ }
353
+ await delay(delays[attempt]);
354
+ }
317
355
  }
318
356
  async abort(): Promise<void> {
319
357
  this.canceled = true;