@bota.dev/react-native-app-sdk 2.0.0-beta.13 → 2.0.0-beta.15
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 +49 -7
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +2 -2
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +3 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +1 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +37 -3
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/ByoBridgeDriver.kt +79 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +30 -2
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +5 -2
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadStreamingStatus.kt +67 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +23 -0
- package/generated/codegen-contract.json +110 -8
- package/ios/BotaDeviceSDK.mm +1 -0
- package/ios/BotaDeviceSDKAppleBridge.swift +4 -2
- package/ios/BotaDeviceSDKAppleRecordingUploads.swift +3 -3
- package/ios/BotaDeviceSDKAppleRecordings.swift +1 -0
- package/ios/BotaUploadV2Backend.mm +21 -0
- package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +52 -2
- package/ios/UploadBackend/ByoBridgeDriver.swift +94 -0
- package/ios/UploadBackend/UploadModels.swift +6 -1
- package/ios/UploadBackend/UploadOperation.swift +41 -3
- package/ios/UploadBackend/UploadStreamingStatus.swift +43 -0
- package/lib/commonjs/byoStorageBackend.js +144 -0
- package/lib/commonjs/byoStorageBackend.js.map +1 -0
- package/lib/commonjs/client.js +2 -1
- package/lib/commonjs/client.js.map +1 -1
- package/lib/commonjs/encryptedUploadV2Backend.js +4 -1
- package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -1
- package/lib/commonjs/index.js +7 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/managers/DeviceManager.js +6 -0
- package/lib/commonjs/managers/DeviceManager.js.map +1 -1
- package/lib/commonjs/managers/NativeUploadQueue.js +22 -8
- package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -1
- package/lib/commonjs/managers/uploadRecoveryMetadata.js +4 -0
- package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -1
- package/lib/commonjs/managers/uploadVerificationPending.js +20 -0
- package/lib/commonjs/managers/uploadVerificationPending.js.map +1 -0
- package/lib/commonjs/protectedStreamingStatus.js +120 -0
- package/lib/commonjs/protectedStreamingStatus.js.map +1 -0
- package/lib/commonjs/sdkIdentity.js +1 -1
- package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -1
- package/lib/commonjs/uploadRecovery.js +50 -7
- package/lib/commonjs/uploadRecovery.js.map +1 -1
- package/lib/module/byoStorageBackend.js +140 -0
- package/lib/module/byoStorageBackend.js.map +1 -0
- package/lib/module/client.js +2 -1
- package/lib/module/client.js.map +1 -1
- package/lib/module/encryptedUploadV2Backend.js +4 -1
- package/lib/module/encryptedUploadV2Backend.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/managers/DeviceManager.js +6 -0
- package/lib/module/managers/DeviceManager.js.map +1 -1
- package/lib/module/managers/NativeUploadQueue.js +22 -8
- package/lib/module/managers/NativeUploadQueue.js.map +1 -1
- package/lib/module/managers/uploadRecoveryMetadata.js +4 -0
- package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -1
- package/lib/module/managers/uploadVerificationPending.js +14 -0
- package/lib/module/managers/uploadVerificationPending.js.map +1 -0
- package/lib/module/protectedStreamingStatus.js +116 -0
- package/lib/module/protectedStreamingStatus.js.map +1 -0
- package/lib/module/sdkIdentity.js +1 -1
- package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -1
- package/lib/module/uploadRecovery.js +50 -7
- package/lib/module/uploadRecovery.js.map +1 -1
- package/lib/typescript/byoStorageBackend.d.ts +36 -0
- package/lib/typescript/byoStorageBackend.d.ts.map +1 -0
- package/lib/typescript/client.d.ts.map +1 -1
- package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +6 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/managers/DeviceManager.d.ts +2 -0
- package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
- package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -1
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -1
- package/lib/typescript/managers/uploadVerificationPending.d.ts +6 -0
- package/lib/typescript/managers/uploadVerificationPending.d.ts.map +1 -0
- package/lib/typescript/models/Recording.d.ts +6 -0
- package/lib/typescript/models/Recording.d.ts.map +1 -1
- package/lib/typescript/protectedStreamingStatus.d.ts +37 -0
- package/lib/typescript/protectedStreamingStatus.d.ts.map +1 -0
- package/lib/typescript/sdkIdentity.d.ts +1 -1
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +1 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +4 -0
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -1
- package/lib/typescript/uploadRecovery.d.ts +1 -1
- package/lib/typescript/uploadRecovery.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/byoStorageBackend.ts +104 -0
- package/src/client.ts +2 -0
- package/src/encryptedUploadV2Backend.ts +2 -1
- package/src/index.ts +11 -0
- package/src/managers/DeviceManager.ts +6 -0
- package/src/managers/NativeUploadQueue.ts +18 -9
- package/src/managers/uploadRecoveryMetadata.ts +2 -0
- package/src/managers/uploadVerificationPending.ts +10 -0
- package/src/models/Recording.ts +6 -0
- package/src/protectedStreamingStatus.ts +137 -0
- package/src/sdkIdentity.ts +1 -1
- package/src/specs/NativeBotaDeviceSDK.ts +1 -0
- package/src/specs/NativeBotaUploadV2Backend.ts +4 -0
- package/src/uploadRecovery.ts +42 -8
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ConnectedDevice } from './models/Device';
|
|
2
|
+
import type { Spec } from './specs/NativeBotaUploadV2Backend';
|
|
3
|
+
|
|
4
|
+
export interface ByoStorageBackendOptions {
|
|
5
|
+
/** Customer-authenticated HTTPS proxy for Bota device BYO/context routes. */
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
scopeKey: string;
|
|
8
|
+
organizationId: string;
|
|
9
|
+
projectId: string;
|
|
10
|
+
environment: 'development' | 'gamma' | 'production';
|
|
11
|
+
/** Abort on logout, project change or device reassignment. */
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
getAccessToken(signal: AbortSignal): Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
export interface ByoRecording { uuid: string; generation: number }
|
|
16
|
+
export interface ByoSyncTarget { deviceId: string; bindingGeneration: number; signal?: AbortSignal }
|
|
17
|
+
export interface ByoUploadResult { recordingId: string; uploadSessionId: string }
|
|
18
|
+
export interface ByoStorageBackend {
|
|
19
|
+
/** Native owns BLE, HTTP, opaque documents, durable recovery and receipt cleanup. */
|
|
20
|
+
sync(device: ConnectedDevice, recording: ByoRecording, target: ByoSyncTarget): Promise<ByoUploadResult>;
|
|
21
|
+
stream(device: ConnectedDevice, recording: ByoRecording | null, target: ByoSyncTarget): Promise<ByoUploadResult>;
|
|
22
|
+
list(device: ConnectedDevice, target: ByoSyncTarget): Promise<ByoRecording[]>;
|
|
23
|
+
dispose(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/** @internal Shared implementation for the production factory and host tests. */
|
|
26
|
+
export function createManagedByoStorageBackend(native: Spec | null, options: ByoStorageBackendOptions): ByoStorageBackend {
|
|
27
|
+
const config = { ...options };
|
|
28
|
+
const url = new URL(config.baseUrl);
|
|
29
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
|
|
30
|
+
!config.scopeKey || !config.organizationId || !config.projectId || !config.signal ||
|
|
31
|
+
!['development', 'gamma', 'production'].includes(config.environment)) throw new Error('Invalid BYO configuration');
|
|
32
|
+
let disposed = false;
|
|
33
|
+
const attempts = new Set<{ cancel(): Promise<void>; settled: Promise<void> }>();
|
|
34
|
+
const perform = async (device: ConnectedDevice, recording: ByoRecording | null, target: ByoSyncTarget, live = false): Promise<ByoUploadResult | ByoRecording[]> => {
|
|
35
|
+
if (!native?.syncByo || !native.cancelByo) throw new Error('BYO requires the matching native SDK binary');
|
|
36
|
+
device = { ...device }; recording = recording && { ...recording }; target = { ...target };
|
|
37
|
+
if ((recording !== null && (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(recording.uuid) ||
|
|
38
|
+
!Number.isSafeInteger(recording.generation) || recording.generation < 0 || recording.generation > 0xffffffff)) ||
|
|
39
|
+
!Number.isSafeInteger(target.bindingGeneration) || target.bindingGeneration < 1 || target.bindingGeneration > 0xffffffff) {
|
|
40
|
+
throw new Error('Invalid BYO recording identity');
|
|
41
|
+
}
|
|
42
|
+
const operationId = 'byo-' + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, key => {
|
|
43
|
+
const value = Math.floor(Math.random() * 16); return (key === 'x' ? value : (value & 3) | 8).toString(16);
|
|
44
|
+
});
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
let cancelTask: Promise<void> | undefined;
|
|
47
|
+
let settle!: () => void;
|
|
48
|
+
const settled = new Promise<void>(resolve => { settle = resolve; });
|
|
49
|
+
const check = () => {
|
|
50
|
+
if (disposed || controller.signal.aborted || config.signal.aborted || target.signal?.aborted) throw new Error('BYO upload cancelled');
|
|
51
|
+
};
|
|
52
|
+
const cancel = () => {
|
|
53
|
+
controller.abort();
|
|
54
|
+
return cancelTask ??= Promise.resolve().then(() => native.cancelByo(operationId));
|
|
55
|
+
};
|
|
56
|
+
const abort = () => { void cancel().catch(() => {}); };
|
|
57
|
+
const attempt = { cancel, settled }; attempts.add(attempt);
|
|
58
|
+
let subscription: { remove(): void } | undefined;
|
|
59
|
+
let completed = false;
|
|
60
|
+
try {
|
|
61
|
+
config.signal.addEventListener('abort', abort, { once: true }); target.signal?.addEventListener('abort', abort, { once: true });
|
|
62
|
+
check();
|
|
63
|
+
subscription = native.onCredentialsRequested(event => {
|
|
64
|
+
if (event.operationId !== operationId) return;
|
|
65
|
+
void (async () => {
|
|
66
|
+
try { check(); const token = await config.getAccessToken(controller.signal); check(); await native.resolveCredentials(event.requestId, token); }
|
|
67
|
+
catch { await native.rejectCredentials(event.requestId).catch(() => {}); }
|
|
68
|
+
})();
|
|
69
|
+
});
|
|
70
|
+
const value: unknown = JSON.parse(await native.syncByo(JSON.stringify({operationId, mode: live ? 'stream' : recording === null ? 'list' : 'sync', device, recording,
|
|
71
|
+
scope: { baseUrl: url.href, scopeKey: config.scopeKey, organizationId: config.organizationId,
|
|
72
|
+
projectId: config.projectId, environment: config.environment, deviceId: target.deviceId, bindingGeneration: target.bindingGeneration } })));
|
|
73
|
+
check();
|
|
74
|
+
if (recording === null && !live) {
|
|
75
|
+
if (!Array.isArray(value) || value.length > 4096 || value.some(item => !item ||
|
|
76
|
+
!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(item.uuid) ||
|
|
77
|
+
!Number.isSafeInteger(item.generation) || item.generation < 0 || item.generation > 0xffffffff)) throw new Error('Invalid BYO native catalog');
|
|
78
|
+
completed = true; return value.map(item => ({ uuid: item.uuid as string, generation: item.generation as number }));
|
|
79
|
+
}
|
|
80
|
+
const result = value as ByoUploadResult;
|
|
81
|
+
if (!result || !/^rec_[A-Za-z0-9_-]+$/.test(result.recordingId) ||
|
|
82
|
+
!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(result.uploadSessionId)) throw new Error('Invalid BYO native result');
|
|
83
|
+
completed = true; return { recordingId: result.recordingId, uploadSessionId: result.uploadSessionId };
|
|
84
|
+
} finally {
|
|
85
|
+
try { if (!completed) await cancel(); }
|
|
86
|
+
finally {
|
|
87
|
+
attempts.delete(attempt); config.signal.removeEventListener('abort', abort); target.signal?.removeEventListener('abort', abort);
|
|
88
|
+
subscription?.remove(); settle();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
async sync(device, recording, target) { return await perform(device, recording, target) as ByoUploadResult; },
|
|
94
|
+
async stream(device, recording, target) { return await perform(device, recording, target, true) as ByoUploadResult; },
|
|
95
|
+
async list(device, target) { return await perform(device, null, target) as ByoRecording[]; },
|
|
96
|
+
async dispose() {
|
|
97
|
+
disposed = true;
|
|
98
|
+
const results = await Promise.allSettled([...attempts].map(async attempt => {
|
|
99
|
+
try { await attempt.cancel(); } finally { await attempt.settled; }
|
|
100
|
+
}));
|
|
101
|
+
if (results.some(result => result.status === 'rejected')) throw new Error('BYO cancellation failed');
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -550,6 +550,7 @@ const mapPendingRecording = (value: NativePendingRecording): DeviceRecording | B
|
|
|
550
550
|
const plaintextLength = recording.plaintextLength;
|
|
551
551
|
if (typeof recording.uuid !== 'string' || !uuidPattern.test(recording.uuid) ||
|
|
552
552
|
recording.storageFormat !== 3 || typeof plaintextLength !== 'string' ||
|
|
553
|
+
(recording.markersRequired !== undefined && typeof recording.markersRequired !== 'boolean') ||
|
|
553
554
|
!/^(0|[1-9][0-9]{0,19})$/.test(plaintextLength) || BigInt(plaintextLength) > 0xffffffffffffffffn ||
|
|
554
555
|
typeof recording.ciphertextLength !== 'string' || !/^[1-9][0-9]{0,19}$/.test(recording.ciphertextLength) || BigInt(recording.ciphertextLength) > 0xffffffffffffffffn ||
|
|
555
556
|
typeof recording.ciphertextSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(recording.ciphertextSha256) ||
|
|
@@ -560,6 +561,7 @@ const mapPendingRecording = (value: NativePendingRecording): DeviceRecording | B
|
|
|
560
561
|
uuid: recording.uuid, generation: recording.generation,
|
|
561
562
|
ciphertextLength: recording.ciphertextLength, ciphertextSha256: recording.ciphertextSha256,
|
|
562
563
|
storageFormat: 3, plaintextLength, startedAtMs: recording.startedAtMs,
|
|
564
|
+
markersRequired: recording.markersRequired ?? false,
|
|
563
565
|
startedAt: new Date(catalogInteger(recording.startedAtMs, 8640000000000000n)),
|
|
564
566
|
durationMs: catalogInteger(recording.durationMs),
|
|
565
567
|
};
|
|
@@ -112,7 +112,8 @@ export function createManagedEncryptedUploadV2Backend(
|
|
|
112
112
|
recording: { uuid: context.recording.uuid, generation: context.recording.generation,
|
|
113
113
|
ciphertextLength: context.recording.ciphertextLength, ciphertextSha256: context.recording.ciphertextSha256,
|
|
114
114
|
startedAtMs: context.recording.startedAtMs, durationMs: context.recording.durationMs,
|
|
115
|
-
plaintextLength: context.recording.plaintextLength, storageFormat: context.recording.storageFormat
|
|
115
|
+
plaintextLength: context.recording.plaintextLength, storageFormat: context.recording.storageFormat,
|
|
116
|
+
...(context.recording.markersRequired ? { markersRequired: true } : {}) },
|
|
116
117
|
capability: { rawValueHex: context.capability.rawValueHex, flags: context.capability.flags },
|
|
117
118
|
...(context.checkpoint === undefined ? {} : { checkpoint: {
|
|
118
119
|
version: context.checkpoint.version, uploadSessionId: context.checkpoint.uploadSessionId,
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createManagedProtectedStreamingStatusBackend } from './protectedStreamingStatus';
|
|
2
|
+
import type { ProtectedStreamingStatusBackendOptions } from './protectedStreamingStatus';
|
|
1
3
|
import NativeBotaDeviceSDK from './specs/NativeBotaDeviceSDK';
|
|
2
4
|
import { createBotaDeviceSDK } from './client';
|
|
3
5
|
import NativeBotaUploadV2Backend from './specs/NativeBotaUploadV2Backend';
|
|
@@ -58,6 +60,10 @@ export const BotaDeviceSDK = createBotaDeviceSDK(NativeBotaDeviceSDK);
|
|
|
58
60
|
export const createEncryptedUploadV2Backend = (options: EncryptedUploadV2BackendOptions) =>
|
|
59
61
|
createManagedEncryptedUploadV2Backend(NativeBotaUploadV2Backend, BotaDeviceSDK.recordings, options);
|
|
60
62
|
export type { EncryptedUploadV2BackendOptions, EncryptedUploadV2Backend, EncryptedUploadV2SyncTarget } from './encryptedUploadV2Backend';
|
|
63
|
+
export const createProtectedStreamingStatusBackend = (options: ProtectedStreamingStatusBackendOptions) =>
|
|
64
|
+
createManagedProtectedStreamingStatusBackend(NativeBotaUploadV2Backend, options);
|
|
65
|
+
export type { ProtectedStreamingIdentity, ProtectedStreamingStatus, ProtectedStreamingStatusBackend,
|
|
66
|
+
ProtectedStreamingStatusBackendOptions } from './protectedStreamingStatus';
|
|
61
67
|
setDefaultCompatibilityClient(BotaDeviceSDK);
|
|
62
68
|
|
|
63
69
|
export { BotaClient } from './BotaClient';
|
|
@@ -168,3 +174,8 @@ export type {
|
|
|
168
174
|
SyncStatus,
|
|
169
175
|
SyncStatusInputs,
|
|
170
176
|
} from './sync/syncStatus';
|
|
177
|
+
|
|
178
|
+
export type { ByoStorageBackendOptions, ByoStorageBackend, ByoRecording, ByoSyncTarget, ByoUploadResult } from './byoStorageBackend';
|
|
179
|
+
import { createManagedByoStorageBackend, type ByoStorageBackendOptions } from './byoStorageBackend';
|
|
180
|
+
export const createByoStorageBackend = (options: ByoStorageBackendOptions) =>
|
|
181
|
+
createManagedByoStorageBackend(NativeBotaUploadV2Backend, options);
|
|
@@ -631,6 +631,12 @@ export class DeviceManager extends EventEmitter<DeviceManagerEvents> {
|
|
|
631
631
|
}
|
|
632
632
|
}
|
|
633
633
|
|
|
634
|
+
/** Stop automatic retries and wait for reconnect attempts already submitted. */
|
|
635
|
+
pauseAutoReconnect(): Promise<void> {
|
|
636
|
+
this.disableAutoReconnect();
|
|
637
|
+
return this.reconnectChain.then(() => undefined);
|
|
638
|
+
}
|
|
639
|
+
|
|
634
640
|
onCachedDeviceStateChanged(
|
|
635
641
|
listener: (
|
|
636
642
|
serialNumber: string,
|
|
@@ -3,6 +3,7 @@ import type { BotaDeviceSDKRecordingClient, BotaRecordingTransferProgress } from
|
|
|
3
3
|
import type { UploadInfo, UploadRecoveryProvider, UploadTask } from '../models/Recording';
|
|
4
4
|
import type { RecordingManagerEvents } from '../models/Status';
|
|
5
5
|
import { persistedUploadQueue, restoreUploadQueue } from './uploadRecoveryMetadata';
|
|
6
|
+
import { UploadVerificationPendingError } from './uploadVerificationPending';
|
|
6
7
|
|
|
7
8
|
const journalOperations = new WeakMap<object, Promise<unknown>>();
|
|
8
9
|
const journalOwners = new WeakMap<object, NativeUploadQueue>();
|
|
@@ -149,10 +150,10 @@ export class NativeUploadQueue extends EventEmitter<RecordingManagerEvents> {
|
|
|
149
150
|
initialSignal?.addEventListener('abort', initialAbort, { once: true });
|
|
150
151
|
if (initialSignal?.aborted) initialAbort();
|
|
151
152
|
check();
|
|
152
|
-
if (info &&
|
|
153
|
+
if (info && this.provider && !info.signal?.aborted && (task.completionPending || (!info.alreadyUploaded && (
|
|
153
154
|
!(info.relay ? info.relay.url : info.uploadUrl) ||
|
|
154
155
|
(info.expiresAt && info.expiresAt.getTime() <= Date.now())
|
|
155
|
-
)) {
|
|
156
|
+
)))) {
|
|
156
157
|
dispose(info);
|
|
157
158
|
info = undefined;
|
|
158
159
|
}
|
|
@@ -165,6 +166,7 @@ export class NativeUploadQueue extends EventEmitter<RecordingManagerEvents> {
|
|
|
165
166
|
recordingUuid: task.recordingUuid!, recoveryScope: task.recoveryScope,
|
|
166
167
|
fileSizeBytes: task.fileSizeBytes, relayUpload: !!task.relayUpload,
|
|
167
168
|
contentType: task.contentType, contentSha256: task.contentSha256, signal,
|
|
169
|
+
completionPending: task.completionPending,
|
|
168
170
|
});
|
|
169
171
|
}), signal, dispose);
|
|
170
172
|
}
|
|
@@ -176,17 +178,21 @@ export class NativeUploadQueue extends EventEmitter<RecordingManagerEvents> {
|
|
|
176
178
|
if (info.recordingId !== task.recordingId || info.recoveryScope !== task.recoveryScope) {
|
|
177
179
|
throw new Error('Upload recovery identity changed');
|
|
178
180
|
}
|
|
179
|
-
|
|
181
|
+
const uploadRequired = !info.alreadyUploaded && (!task.completionPending || info.reuploadRequired === true);
|
|
182
|
+
if (uploadRequired && !!info.relay !== !!task.relayUpload) throw new Error('Upload recovery route changed');
|
|
183
|
+
if (task.completionPending && !info.complete) throw new Error('Pending upload requires durable host completion acknowledgement');
|
|
180
184
|
hostAbort = () => attempt.controller.abort();
|
|
181
185
|
info.signal?.addEventListener('abort', hostAbort, { once: true });
|
|
182
186
|
if (info.signal?.aborted) hostAbort();
|
|
183
187
|
check();
|
|
184
|
-
if (info.expiresAt && info.expiresAt.getTime() <= Date.now() &&
|
|
188
|
+
if (info.expiresAt && info.expiresAt.getTime() <= Date.now() && uploadRequired) throw new Error('Upload credentials expired');
|
|
185
189
|
await this.update(id, { status: 'uploading', errorMessage: undefined, nextAttemptAt: undefined }, check);
|
|
186
190
|
check();
|
|
187
191
|
this.emit('uploadStarted', id);
|
|
188
192
|
check();
|
|
189
|
-
if (
|
|
193
|
+
if (uploadRequired) {
|
|
194
|
+
if (task.completionPending) await this.update(id, { completionPending: false }, check);
|
|
195
|
+
check();
|
|
190
196
|
nativeStarted = true;
|
|
191
197
|
await this.client.uploadRecordingFile({
|
|
192
198
|
...task, id: attempt.nativeId, uploadUrl: info.uploadUrl,
|
|
@@ -203,6 +209,8 @@ export class NativeUploadQueue extends EventEmitter<RecordingManagerEvents> {
|
|
|
203
209
|
}
|
|
204
210
|
if (info.complete) {
|
|
205
211
|
if (task.fileSizeBytes === undefined) throw new Error('Upload completion requires known file size');
|
|
212
|
+
await this.update(id, { completionPending: true }, check);
|
|
213
|
+
check();
|
|
206
214
|
await abortable(Promise.resolve().then(() => {
|
|
207
215
|
check();
|
|
208
216
|
return info!.complete!({ fileSizeBytes: task.fileSizeBytes!, contentSha256: task.contentSha256, signal });
|
|
@@ -211,7 +219,7 @@ export class NativeUploadQueue extends EventEmitter<RecordingManagerEvents> {
|
|
|
211
219
|
throw new Error('Recoverable upload requires durable host completion acknowledgement');
|
|
212
220
|
}
|
|
213
221
|
check();
|
|
214
|
-
await this.update(id, { status: 'completed', errorMessage: undefined, nextAttemptAt: undefined }, check);
|
|
222
|
+
await this.update(id, { status: 'completed', completionPending: undefined, errorMessage: undefined, nextAttemptAt: undefined }, check);
|
|
215
223
|
check();
|
|
216
224
|
await this.release(this.get(id)!).catch(() => undefined);
|
|
217
225
|
check();
|
|
@@ -219,15 +227,16 @@ export class NativeUploadQueue extends EventEmitter<RecordingManagerEvents> {
|
|
|
219
227
|
} catch (error) {
|
|
220
228
|
if (!this.destroyed && !this.cancelledTasks.has(id) && this.get(id)?.status !== 'completed') {
|
|
221
229
|
const task = this.get(id)!;
|
|
222
|
-
const
|
|
230
|
+
const verificationPending = error instanceof UploadVerificationPendingError;
|
|
231
|
+
const parked = info === null || signal.aborted || verificationPending;
|
|
223
232
|
const retry = !!this.provider && task.retryCount < 6 && Date.now() - task.createdAt.getTime() < 86_400_000;
|
|
224
233
|
await this.update(id, {
|
|
225
234
|
status: parked || retry ? 'pending' : 'failed',
|
|
226
235
|
retryCount: task.retryCount + (parked ? 0 : 1),
|
|
227
236
|
nextAttemptAt: parked ? Date.now() + 30_000 : retry ? Date.now() + retryDelays[Math.min(task.retryCount, 4)] : undefined,
|
|
228
|
-
errorMessage: parked ? 'Upload waiting for its original account' : 'Upload attempt failed',
|
|
237
|
+
errorMessage: verificationPending ? 'Upload verification pending' : parked ? 'Upload waiting for its original account' : 'Upload attempt failed',
|
|
229
238
|
}).catch(() => { this.pause(); });
|
|
230
|
-
if (!this.destroyed) this.emit('uploadFailed', id, new Error(parked ? 'Upload paused' : 'Upload attempt failed'));
|
|
239
|
+
if (!this.destroyed) this.emit('uploadFailed', id, new Error(verificationPending ? 'Upload verification pending' : parked ? 'Upload paused' : 'Upload attempt failed'));
|
|
231
240
|
}
|
|
232
241
|
throw error;
|
|
233
242
|
} finally {
|
|
@@ -20,6 +20,7 @@ export function persistedUploadQueue(tasks: UploadTask[]): UploadTask[] {
|
|
|
20
20
|
...(task.nextAttemptAt === undefined ? {} : { nextAttemptAt: task.nextAttemptAt }),
|
|
21
21
|
...(task.contentType === undefined ? {} : { contentType: task.contentType }),
|
|
22
22
|
...(task.contentSha256 === undefined ? {} : { contentSha256: task.contentSha256 }),
|
|
23
|
+
...(task.completionPending === undefined ? {} : { completionPending: task.completionPending }),
|
|
23
24
|
relayUpload: task.relayUpload ?? !!task.relay,
|
|
24
25
|
}));
|
|
25
26
|
}
|
|
@@ -44,6 +45,7 @@ export function restoreUploadQueue(value: unknown): UploadTask[] {
|
|
|
44
45
|
if (task[field] !== undefined && (!Number.isSafeInteger(task[field]) || task[field]! < 0)) return invalid();
|
|
45
46
|
}
|
|
46
47
|
if (task.relayUpload !== undefined && typeof task.relayUpload !== 'boolean') return invalid();
|
|
48
|
+
if (task.completionPending !== undefined && typeof task.completionPending !== 'boolean') return invalid();
|
|
47
49
|
const createdAt = new Date(task.createdAt);
|
|
48
50
|
const updatedAt = new Date(task.updatedAt);
|
|
49
51
|
if (!Number.isFinite(createdAt.getTime()) || !Number.isFinite(updatedAt.getTime())) return invalid();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Internal completion-budget signal; it is never acknowledgement or repair. */
|
|
2
|
+
export class UploadVerificationPendingError extends Error {
|
|
3
|
+
constructor() { super('Upload verification pending'); }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isUploadVerificationPending(error: unknown): boolean {
|
|
7
|
+
if (!error || typeof error !== 'object') return false;
|
|
8
|
+
const value = error as { status?: unknown; data?: { error?: { code?: unknown } } };
|
|
9
|
+
return value.status === 425 && value.data?.error?.code === 'upload_verification_pending';
|
|
10
|
+
}
|
package/src/models/Recording.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface UploadInfo {
|
|
|
34
34
|
recoveryScope?: string;
|
|
35
35
|
/** Skip sending the object; complete must still acknowledge host durability. */
|
|
36
36
|
alreadyUploaded?: boolean;
|
|
37
|
+
/** Explicit authenticated integrity failure permits repair of a completion-pending object. */
|
|
38
|
+
reuploadRequired?: boolean;
|
|
37
39
|
/** Resolve only after the backend durably acknowledges this exact recording. */
|
|
38
40
|
complete?: (context: {
|
|
39
41
|
fileSizeBytes: number;
|
|
@@ -92,6 +94,8 @@ export interface UploadRecoveryContext {
|
|
|
92
94
|
relayUpload: boolean;
|
|
93
95
|
contentType?: string;
|
|
94
96
|
contentSha256?: string;
|
|
97
|
+
/** Bytes were sent; recover completion before considering another upload. Not verification proof. */
|
|
98
|
+
completionPending?: boolean;
|
|
95
99
|
signal: AbortSignal;
|
|
96
100
|
}
|
|
97
101
|
|
|
@@ -152,6 +156,8 @@ export interface UploadTask {
|
|
|
152
156
|
fileSizeBytes?: number;
|
|
153
157
|
relayUpload?: boolean;
|
|
154
158
|
nextAttemptAt?: number;
|
|
159
|
+
/** Durable byte-upload completion; backend acknowledgement and cleanup are still required. */
|
|
160
|
+
completionPending?: boolean;
|
|
155
161
|
/** Volatile compatibility field; never included in queue persistence. */
|
|
156
162
|
complete?: UploadInfo['complete'];
|
|
157
163
|
/** Unique task identifier */
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Spec } from './specs/NativeBotaUploadV2Backend';
|
|
2
|
+
|
|
3
|
+
/** Read-only protected STREAMING metadata. None of these values authorize cleanup. */
|
|
4
|
+
export interface ProtectedStreamingIdentity {
|
|
5
|
+
recordingId: string;
|
|
6
|
+
sessionId: string;
|
|
7
|
+
recordingGeneration: number;
|
|
8
|
+
/** Canonical positive int64 decimal; never convert through a JS number. */
|
|
9
|
+
writerEpoch: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ProtectedStreamingStatus extends ProtectedStreamingIdentity {
|
|
13
|
+
profile: 'recording_markers_stream_v1';
|
|
14
|
+
/** Canonical nonnegative int64 decimal; a newly admitted stream starts at zero. */
|
|
15
|
+
revision: string;
|
|
16
|
+
receivedCount: number;
|
|
17
|
+
contiguousSequence: number;
|
|
18
|
+
/** Marker metadata state, independent from audio completion or publication. */
|
|
19
|
+
state: 'open' | 'sealing' | 'sealed' | 'aborted';
|
|
20
|
+
expectedCount: number | null;
|
|
21
|
+
authorizationExpired: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProtectedStreamingStatusBackendOptions {
|
|
25
|
+
/** Scoped read proxy, e.g. https://api.example/dashboard/projects/proj_123. */
|
|
26
|
+
baseUrl: string;
|
|
27
|
+
organizationId?: string;
|
|
28
|
+
/** Permanently abort on logout, account/project/environment or binding change. */
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
/** Fresh application read credential. Never embed a secret API key in an App. */
|
|
31
|
+
getAccessToken(signal: AbortSignal): Promise<string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProtectedStreamingStatusBackend {
|
|
35
|
+
/** Requires an exact known session/owner; does not select a newer owner implicitly. */
|
|
36
|
+
getStatus(identity: ProtectedStreamingIdentity, signal?: AbortSignal): Promise<ProtectedStreamingStatus>;
|
|
37
|
+
dispose(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const decimal = (value: unknown): value is string => typeof value === 'string' &&
|
|
41
|
+
/^(0|[1-9][0-9]{0,18})$/.test(value) && BigInt(value) <= 9223372036854775807n;
|
|
42
|
+
const count = (value: unknown): value is number => typeof value === 'number' &&
|
|
43
|
+
Number.isInteger(value) && value >= 0 && value <= 65535;
|
|
44
|
+
const generation = (value: unknown): value is number => typeof value === 'number' &&
|
|
45
|
+
Number.isInteger(value) && value >= 1 && value <= 4294967295;
|
|
46
|
+
const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
|
|
47
|
+
const keys = ['profile', 'recording_id', 'session_id', 'recording_generation', 'writer_epoch', 'revision',
|
|
48
|
+
'received_count', 'contiguous_sequence', 'state', 'expected_count', 'authorization_expired'];
|
|
49
|
+
|
|
50
|
+
function decode(value: unknown, identity: ProtectedStreamingIdentity): ProtectedStreamingStatus {
|
|
51
|
+
const invalid = () => new Error('Invalid protected streaming status response');
|
|
52
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid();
|
|
53
|
+
const v = value as Record<string, unknown>;
|
|
54
|
+
if (Object.keys(v).length !== keys.length || keys.some(key => !Object.prototype.hasOwnProperty.call(v, key)) ||
|
|
55
|
+
v.profile !== 'recording_markers_stream_v1' || v.recording_id !== identity.recordingId ||
|
|
56
|
+
v.session_id !== identity.sessionId || v.recording_generation !== identity.recordingGeneration ||
|
|
57
|
+
v.writer_epoch !== identity.writerEpoch || !decimal(v.revision) || !count(v.received_count) ||
|
|
58
|
+
!count(v.contiguous_sequence) || v.contiguous_sequence > v.received_count ||
|
|
59
|
+
!['open', 'sealing', 'sealed', 'aborted'].includes(v.state as string) ||
|
|
60
|
+
(v.expected_count !== null && (!count(v.expected_count) || v.expected_count < v.received_count)) ||
|
|
61
|
+
(v.state === 'open' && v.expected_count !== null) ||
|
|
62
|
+
((v.state === 'sealing' || v.state === 'sealed') && v.expected_count === null) ||
|
|
63
|
+
(v.state === 'sealed' && (v.expected_count !== v.received_count || v.contiguous_sequence !== v.received_count)) ||
|
|
64
|
+
typeof v.authorization_expired !== 'boolean') throw invalid();
|
|
65
|
+
return { ...identity, profile: v.profile, revision: v.revision, receivedCount: v.received_count,
|
|
66
|
+
contiguousSequence: v.contiguous_sequence, state: v.state as ProtectedStreamingStatus['state'],
|
|
67
|
+
expectedCount: v.expected_count as number | null, authorizationExpired: v.authorization_expired };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Public read provider. Protected START/relay and signed ACKs remain native-only. */
|
|
71
|
+
export function createManagedProtectedStreamingStatusBackend(native: Pick<Spec, 'readProtectedStreamingStatus' | 'cancelProtectedStreamingStatus'> | null, options: ProtectedStreamingStatusBackendOptions): ProtectedStreamingStatusBackend {
|
|
72
|
+
const config = { ...options };
|
|
73
|
+
const url = new URL(config.baseUrl);
|
|
74
|
+
const path = url.pathname.replace(/\/$/, '');
|
|
75
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
|
|
76
|
+
!/^(\/[A-Za-z0-9_-]+)+$/.test(path) || !config.signal || typeof config.getAccessToken !== 'function' ||
|
|
77
|
+
(config.organizationId !== undefined && !/^[A-Za-z0-9_-]{1,128}$/.test(config.organizationId))) {
|
|
78
|
+
throw new Error('Invalid protected streaming status configuration');
|
|
79
|
+
}
|
|
80
|
+
const baseUrl = `${url.origin}${path}`;
|
|
81
|
+
const active = new Set<AbortController>();
|
|
82
|
+
let requestSequence = 0;
|
|
83
|
+
const instance = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
84
|
+
let disposed = false;
|
|
85
|
+
return {
|
|
86
|
+
async getStatus(request, callerSignal) {
|
|
87
|
+
const identity = { recordingId: request.recordingId, sessionId: request.sessionId,
|
|
88
|
+
recordingGeneration: request.recordingGeneration, writerEpoch: request.writerEpoch };
|
|
89
|
+
if (!/^rec_[A-Za-z0-9_-]{1,128}$/.test(identity.recordingId) || !uuid.test(identity.sessionId) ||
|
|
90
|
+
!generation(identity.recordingGeneration) || !decimal(identity.writerEpoch) || identity.writerEpoch === '0') {
|
|
91
|
+
throw new Error('Invalid protected streaming identity');
|
|
92
|
+
}
|
|
93
|
+
if (!native?.readProtectedStreamingStatus) throw new Error('Protected streaming status requires the matching native SDK binary');
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const requestId = `${instance}-${++requestSequence}`;
|
|
96
|
+
let dispatched = false;
|
|
97
|
+
const cancel = () => controller.abort();
|
|
98
|
+
controller.signal.addEventListener('abort', () => { if (dispatched) void native.cancelProtectedStreamingStatus(requestId).catch(() => {}); }, { once: true });
|
|
99
|
+
const check = () => {
|
|
100
|
+
if (disposed || config.signal.aborted || callerSignal?.aborted || controller.signal.aborted) {
|
|
101
|
+
throw new Error('Protected streaming status cancelled');
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
active.add(controller);
|
|
105
|
+
config.signal.addEventListener('abort', cancel, { once: true });
|
|
106
|
+
callerSignal?.addEventListener('abort', cancel, { once: true });
|
|
107
|
+
try {
|
|
108
|
+
check();
|
|
109
|
+
const token = await config.getAccessToken(controller.signal);
|
|
110
|
+
check();
|
|
111
|
+
if (typeof token !== 'string' || !/^[!-~]{1,16384}$/.test(token)) {
|
|
112
|
+
throw new Error('Invalid protected streaming credentials');
|
|
113
|
+
}
|
|
114
|
+
const requestUrl = `${baseUrl}/recordings/${identity.recordingId}/streaming-status?session_id=${identity.sessionId}`;
|
|
115
|
+
let body: string;
|
|
116
|
+
try {
|
|
117
|
+
dispatched = true;
|
|
118
|
+
body = await native.readProtectedStreamingStatus(requestId, JSON.stringify({ url: requestUrl,
|
|
119
|
+
token, organizationId: config.organizationId ?? '' }));
|
|
120
|
+
} catch {
|
|
121
|
+
check();
|
|
122
|
+
throw new Error('Protected streaming status request failed');
|
|
123
|
+
}
|
|
124
|
+
check();
|
|
125
|
+
if (body.length > 16384) throw new Error('Invalid protected streaming status response');
|
|
126
|
+
let value: unknown;
|
|
127
|
+
try { value = JSON.parse(body); } catch { throw new Error('Invalid protected streaming status response'); }
|
|
128
|
+
return decode(value, identity);
|
|
129
|
+
} finally {
|
|
130
|
+
active.delete(controller);
|
|
131
|
+
config.signal.removeEventListener('abort', cancel);
|
|
132
|
+
callerSignal?.removeEventListener('abort', cancel);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
dispose() { disposed = true; for (const controller of active) controller.abort(); },
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/sdkIdentity.ts
CHANGED
|
@@ -9,6 +9,10 @@ export interface Spec extends TurboModule {
|
|
|
9
9
|
requestId: string;
|
|
10
10
|
operationId: string;
|
|
11
11
|
}>;
|
|
12
|
+
syncByo: (inputJSON: string) => Promise<string>;
|
|
13
|
+
cancelByo: (operationId: string) => Promise<void>;
|
|
14
|
+
readProtectedStreamingStatus: (requestId: string, inputJSON: string) => Promise<string>;
|
|
15
|
+
cancelProtectedStreamingStatus: (requestId: string) => Promise<void>;
|
|
12
16
|
prepare: (inputJSON: string) => Promise<string>;
|
|
13
17
|
cancel: (operationId: string) => Promise<void>;
|
|
14
18
|
complete: (operationId: string) => Promise<void>;
|
package/src/uploadRecovery.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { UploadInfo, UploadRecoveryContext, UploadRecoveryProvider } from './models/Recording';
|
|
2
|
+
import { isUploadVerificationPending, UploadVerificationPendingError } from './managers/uploadVerificationPending';
|
|
3
|
+
|
|
4
|
+
const waitForCompletion = (milliseconds: number, signals: AbortSignal[]): Promise<void> =>
|
|
5
|
+
new Promise((resolve, reject) => {
|
|
6
|
+
const unique = [...new Set(signals)];
|
|
7
|
+
const clean = () => { clearTimeout(timer); unique.forEach(signal => signal.removeEventListener('abort', abort)); };
|
|
8
|
+
const abort = () => { clean(); reject(new Error('Upload recovery cancelled')); };
|
|
9
|
+
const timer = setTimeout(() => { clean(); resolve(); }, milliseconds);
|
|
10
|
+
unique.forEach(signal => signal.addEventListener('abort', abort, { once: true }));
|
|
11
|
+
if (unique.some(signal => signal.aborted)) abort();
|
|
12
|
+
});
|
|
2
13
|
|
|
3
14
|
/** A host-owned, authenticated lease. It must permanently reject changed identities. */
|
|
4
15
|
export interface UploadRecoveryBackend {
|
|
@@ -8,7 +19,7 @@ export interface UploadRecoveryBackend {
|
|
|
8
19
|
dispose(): void;
|
|
9
20
|
getRecording(id: string, signal: AbortSignal): Promise<{
|
|
10
21
|
id: string; status: string; metadata?: Record<string, unknown>;
|
|
11
|
-
content_sha256?: string | null; file_size_bytes?: number | null;
|
|
22
|
+
content_sha256?: string | null; file_size_bytes?: number | string | null;
|
|
12
23
|
}>;
|
|
13
24
|
getUploadInfo(id: string, relay: boolean, contentType: string | undefined, signal: AbortSignal): Promise<Pick<UploadInfo, 'uploadUrl' | 'relay' | 'contentType'>>;
|
|
14
25
|
completeRecording(id: string, data: { duration_seconds?: number; file_size_bytes: number; content_sha256?: string }, signal: AbortSignal): Promise<unknown>;
|
|
@@ -36,26 +47,49 @@ export function createUploadRecoveryProvider(
|
|
|
36
47
|
check();
|
|
37
48
|
const recording = await api.getRecording(task.recordingId, task.signal);
|
|
38
49
|
check();
|
|
50
|
+
const wireSize = recording.file_size_bytes;
|
|
51
|
+
if (typeof wireSize === 'string' && !/^(0|[1-9][0-9]*)$/.test(wireSize)) throw new Error('Invalid upload recovery file size');
|
|
52
|
+
const fileSize = typeof wireSize === 'string' ? Number(wireSize) : wireSize;
|
|
53
|
+
if (fileSize != null && (typeof fileSize !== 'number' || !Number.isSafeInteger(fileSize) || fileSize < 0)) {
|
|
54
|
+
throw new Error('Invalid upload recovery file size');
|
|
55
|
+
}
|
|
39
56
|
if (recording.id !== task.recordingId || (!task.relayUpload && (
|
|
40
57
|
(task.contentSha256 && recording.content_sha256 && task.contentSha256 !== recording.content_sha256) ||
|
|
41
|
-
(task.fileSizeBytes !== undefined &&
|
|
58
|
+
(task.fileSizeBytes !== undefined && fileSize != null && task.fileSizeBytes !== fileSize)
|
|
42
59
|
))) throw new Error('Upload recovery identity or evidence changed');
|
|
43
60
|
if (!['pending', 'uploaded', 'integrity_failure'].includes(recording.status)) throw new Error('Recording unavailable for recovery');
|
|
44
61
|
const start = Date.parse(String(recording.metadata?.started_at ?? ''));
|
|
45
62
|
const end = Date.parse(String(recording.metadata?.ended_at ?? ''));
|
|
46
63
|
const duration = Number.isFinite(start) && Number.isFinite(end) && end >= start ? Math.floor((end - start) / 1000) : undefined;
|
|
47
|
-
const
|
|
64
|
+
const alreadyUploaded = recording.status === 'uploaded' || (task.completionPending === true && recording.status === 'pending');
|
|
65
|
+
const target = alreadyUploaded ? { uploadUrl: '' }
|
|
48
66
|
: await api.getUploadInfo(task.recordingId, task.relayUpload, task.contentType, task.signal);
|
|
49
67
|
check();
|
|
50
68
|
return {
|
|
51
69
|
...target, recordingId: task.recordingId, recoveryScope: task.recoveryScope,
|
|
52
|
-
alreadyUploaded: recording.status === '
|
|
70
|
+
alreadyUploaded, reuploadRequired: recording.status === 'integrity_failure',
|
|
71
|
+
signal: api.signal, dispose: () => api.dispose(),
|
|
53
72
|
complete: async ({ fileSizeBytes, contentSha256, signal }) => {
|
|
54
|
-
|
|
55
|
-
await api.completeRecording(task.recordingId, {
|
|
73
|
+
const data = {
|
|
56
74
|
duration_seconds: duration, file_size_bytes: fileSizeBytes, content_sha256: contentSha256,
|
|
57
|
-
}
|
|
58
|
-
|
|
75
|
+
};
|
|
76
|
+
const deadline = Date.now() + 120_000;
|
|
77
|
+
for (let poll = 0; ; poll++) {
|
|
78
|
+
check(signal);
|
|
79
|
+
if (poll > 0 && (Date.now() >= deadline || poll >= 120)) throw new UploadVerificationPendingError();
|
|
80
|
+
try {
|
|
81
|
+
await api.completeRecording(task.recordingId, data, signal);
|
|
82
|
+
check(signal);
|
|
83
|
+
return;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
check(signal);
|
|
86
|
+
if (!isUploadVerificationPending(error)) throw error;
|
|
87
|
+
const remaining = deadline - Date.now();
|
|
88
|
+
if (remaining <= 0) throw new UploadVerificationPendingError();
|
|
89
|
+
await waitForCompletion(Math.min(1_000 + Math.floor(Math.random() * 1_000), remaining),
|
|
90
|
+
[signal, task.signal, api.signal]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
59
93
|
},
|
|
60
94
|
};
|
|
61
95
|
} catch {
|