@sparkvault/sdk-mobile 5.2.1 → 5.2.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/dist/config.d.ts +20 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +56 -3
- package/dist/errors.js +56 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/ingots.d.ts +101 -1
- package/dist/ingots.js +83 -2
- package/dist/ingots.js.map +1 -1
- package/dist/notify.d.ts +17 -4
- package/dist/notify.js.map +1 -1
- package/dist/notify.test-d.d.ts +31 -0
- package/dist/notify.test-d.js +18 -0
- package/dist/notify.test-d.js.map +1 -0
- package/dist/tus.d.ts +132 -2
- package/dist/tus.js +546 -25
- package/dist/tus.js.map +1 -1
- package/dist/types.d.ts +124 -0
- package/package.json +1 -1
- package/src/config.ts +23 -0
- package/src/errors.ts +86 -3
- package/src/index.ts +6 -0
- package/src/ingots.ts +155 -2
- package/src/notify.test-d.ts +42 -0
- package/src/notify.ts +17 -4
- package/src/tus.ts +717 -48
- package/src/types.ts +128 -0
package/src/tus.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import type { ResolvedMobileConfig } from './config.js';
|
|
2
2
|
import { base64EncodeUtf8 } from './encoding.js';
|
|
3
3
|
import {
|
|
4
|
+
BackgroundTransferError,
|
|
4
5
|
gateErrorFromBody,
|
|
5
6
|
SparkVaultMobileError,
|
|
6
7
|
SparkVaultValidationError,
|
|
7
8
|
TusUploadError,
|
|
8
9
|
} from './errors.js';
|
|
9
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
ApiErrorBody,
|
|
12
|
+
BackgroundTransferJob,
|
|
13
|
+
DebugLogger,
|
|
14
|
+
MobileBackgroundTransfer,
|
|
15
|
+
MobileBackgroundUploader,
|
|
16
|
+
MobileFileReader,
|
|
17
|
+
TusSessionInfo,
|
|
18
|
+
UploadAbortBehavior,
|
|
19
|
+
UploadProgressCallback,
|
|
20
|
+
UploadTransferPolicy,
|
|
21
|
+
UploadTransport,
|
|
22
|
+
} from './types.js';
|
|
10
23
|
|
|
11
24
|
export interface ParsedForgeUrl {
|
|
12
25
|
baseUrl: string;
|
|
@@ -14,14 +27,75 @@ export interface ParsedForgeUrl {
|
|
|
14
27
|
istk: string;
|
|
15
28
|
}
|
|
16
29
|
|
|
30
|
+
/**
|
|
31
|
+
* A previously created tus session to continue instead of creating a new
|
|
32
|
+
* one. The uploader asks Forge where the session stands (HEAD) and PATCHes
|
|
33
|
+
* from there, so bytes the server already holds are never sent twice.
|
|
34
|
+
*
|
|
35
|
+
* A resume that cannot use the session throws a `TusUploadError` whose
|
|
36
|
+
* `sessionLost` is true exactly when the session is worthless for this file
|
|
37
|
+
* and the caller should drop it and start fresh: Forge no longer has it
|
|
38
|
+
* (404/410), or its `Upload-Length` is not this file's size (it belongs to a
|
|
39
|
+
* different file). A transient HEAD failure (5xx, timeout) is NOT
|
|
40
|
+
* `sessionLost`; the session is intact and the same resume can be retried.
|
|
41
|
+
*/
|
|
42
|
+
export interface TusResumeSession {
|
|
43
|
+
uploadUrl: string;
|
|
44
|
+
/** The session's ISTK. Falls back to the one in `forgeUrl` when omitted. */
|
|
45
|
+
istk?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where a tus session stands server-side, as reported by HEAD. */
|
|
49
|
+
export interface TusSessionProbe {
|
|
50
|
+
offset: number;
|
|
51
|
+
length: number;
|
|
52
|
+
chunkSize: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
17
55
|
export interface TusUploadFromUriOptions {
|
|
18
56
|
fileUri: string;
|
|
19
57
|
fileSize: number;
|
|
20
58
|
filename: string;
|
|
21
59
|
contentType: string;
|
|
22
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Forge tus endpoint plus ISTK (`forge_url` from the ingot create).
|
|
62
|
+
* Required to create a session; on resume it only supplies the ISTK when
|
|
63
|
+
* `resume.istk` is omitted.
|
|
64
|
+
*/
|
|
65
|
+
forgeUrl?: string;
|
|
66
|
+
/** Continue this session (HEAD for its offset) instead of POSTing a new one. */
|
|
67
|
+
resume?: TusResumeSession;
|
|
68
|
+
/**
|
|
69
|
+
* Invoked once the session exists server-side and before the first PATCH,
|
|
70
|
+
* so the caller can persist it and resume after a failed attempt or a
|
|
71
|
+
* relaunch. Not invoked on resume. A throw here fails the upload.
|
|
72
|
+
*/
|
|
73
|
+
onSessionCreated?: (session: TusSessionInfo) => void;
|
|
74
|
+
/**
|
|
75
|
+
* `background` rides the best suspension-proof engine the app provides:
|
|
76
|
+
* the OS transfer engine (`backgroundTransfer`) for the whole remaining
|
|
77
|
+
* range of any file, else the single-chunk `backgroundUploader` (no
|
|
78
|
+
* progress, no abort) for a file that fits one chunk, else XHR. Default
|
|
79
|
+
* `foreground`: every chunk goes over XHR.
|
|
80
|
+
*/
|
|
81
|
+
transport?: UploadTransport;
|
|
82
|
+
/**
|
|
83
|
+
* Network policy carried into the OS transfer engine on every request it
|
|
84
|
+
* sends (see `UploadTransferPolicy`). The XHR and single-chunk paths ignore
|
|
85
|
+
* it: JS gates connectivity for them. Defaults: both allowed.
|
|
86
|
+
*/
|
|
87
|
+
transferPolicy?: UploadTransferPolicy;
|
|
23
88
|
onProgress?: UploadProgressCallback;
|
|
24
89
|
abortSignal?: AbortSignal;
|
|
90
|
+
/**
|
|
91
|
+
* What an abort does to the Forge session (see `UploadAbortBehavior`).
|
|
92
|
+
* Default `terminate`: a tus DELETE so Forge drops the chunks (a user
|
|
93
|
+
* cancel). `keep` leaves the session on Forge for a later `resume` from
|
|
94
|
+
* the server offset (a pause); the caller must hold the session it was
|
|
95
|
+
* handed via `onSessionCreated` (or passed in `resume`) and is expected to
|
|
96
|
+
* resume or terminate it. The cancellation error is the same either way.
|
|
97
|
+
*/
|
|
98
|
+
abortBehavior?: UploadAbortBehavior;
|
|
25
99
|
debug?: DebugLogger;
|
|
26
100
|
}
|
|
27
101
|
|
|
@@ -109,11 +183,52 @@ interface TusChunk {
|
|
|
109
183
|
byteLength: number;
|
|
110
184
|
}
|
|
111
185
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Strict integer parse of a tus byte-count header (`Upload-Offset`,
|
|
188
|
+
* `Upload-Length`, `X-Chunk-Size`); anything malformed, unsafe, or below
|
|
189
|
+
* `minimum` is `null` so a corrupt header can never become an offset.
|
|
190
|
+
*/
|
|
191
|
+
function parseIntegerHeader(raw: string | null, minimum: number): number | null {
|
|
114
192
|
if (raw === null || !/^\d+$/.test(raw.trim())) return null;
|
|
115
193
|
const value = Number(raw);
|
|
116
|
-
return Number.isSafeInteger(value) && value
|
|
194
|
+
return Number.isSafeInteger(value) && value >= minimum ? value : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Case-insensitive header lookup for adapter results. Native HTTP stacks
|
|
199
|
+
* hand headers back in whatever case they please (iOS lower-cases them),
|
|
200
|
+
* while Forge writes `Upload-Offset`.
|
|
201
|
+
*/
|
|
202
|
+
function getHeaderIgnoreCase(headers: Record<string, string> | undefined, name: string): string | null {
|
|
203
|
+
if (!headers) return null;
|
|
204
|
+
const wanted = name.toLowerCase();
|
|
205
|
+
for (const key of Object.keys(headers)) {
|
|
206
|
+
if (key.toLowerCase() === wanted) return headers[key];
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Where a tus upload starts: a fresh session at the Forge endpoint, or a
|
|
213
|
+
* persisted one resumed by URL. Resolved up front so a misconfigured call
|
|
214
|
+
* fails as a validation error before any request leaves the device.
|
|
215
|
+
*/
|
|
216
|
+
type TusSessionSource =
|
|
217
|
+
| { mode: 'create'; parsed: ParsedForgeUrl }
|
|
218
|
+
| { mode: 'resume'; uploadUrl: string; istk: string };
|
|
219
|
+
|
|
220
|
+
function resolveSessionSource(options: TusUploadFromUriOptions): TusSessionSource {
|
|
221
|
+
if (options.resume) {
|
|
222
|
+
const istk = options.resume.istk ?? (options.forgeUrl ? parseForgeUrl(options.forgeUrl).istk : null);
|
|
223
|
+
if (!istk) {
|
|
224
|
+
throw new SparkVaultValidationError('resume.istk or forgeUrl is required to resume a TUS upload session');
|
|
225
|
+
}
|
|
226
|
+
return { mode: 'resume', uploadUrl: options.resume.uploadUrl, istk };
|
|
227
|
+
}
|
|
228
|
+
if (!options.forgeUrl) {
|
|
229
|
+
throw new SparkVaultValidationError('forgeUrl is required to create a TUS upload session');
|
|
230
|
+
}
|
|
231
|
+
return { mode: 'create', parsed: parseForgeUrl(options.forgeUrl) };
|
|
117
232
|
}
|
|
118
233
|
|
|
119
234
|
async function createTusUpload(
|
|
@@ -184,7 +299,7 @@ async function createTusUpload(
|
|
|
184
299
|
// to dictate via `X-Chunk-Size` (mirrors sdk-js). A client-side guess could
|
|
185
300
|
// only ever be rejected, so a missing header is a contract violation.
|
|
186
301
|
const chunkSizeHeader = response.headers.get('X-Chunk-Size');
|
|
187
|
-
const chunkSize =
|
|
302
|
+
const chunkSize = parseIntegerHeader(chunkSizeHeader, 1);
|
|
188
303
|
if (chunkSize === null) {
|
|
189
304
|
throw new TusUploadError(`TUS create response missing or invalid X-Chunk-Size header (received ${JSON.stringify(chunkSizeHeader)})`, {
|
|
190
305
|
filename,
|
|
@@ -196,12 +311,176 @@ async function createTusUpload(
|
|
|
196
311
|
return { uploadUrl, chunkSize };
|
|
197
312
|
}
|
|
198
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Ask Forge where a session stands (tus HEAD). This is the only way to learn
|
|
316
|
+
* what a background URLSession delivered after the process was killed, and
|
|
317
|
+
* what a failed attempt left behind — so resume never re-sends bytes the
|
|
318
|
+
* server holds. A 404/410 means the session expired or was terminated: the
|
|
319
|
+
* error carries `sessionLost` so the caller drops its persisted session and
|
|
320
|
+
* creates a fresh one rather than retrying a dead URL. Any other failure
|
|
321
|
+
* (a 5xx, an auth rejection, a timeout) leaves `sessionLost` false: the
|
|
322
|
+
* session may well be intact, and treating it as dead would re-upload a
|
|
323
|
+
* whole file over a blip.
|
|
324
|
+
*/
|
|
325
|
+
async function headTusSession(
|
|
326
|
+
config: ResolvedMobileConfig,
|
|
327
|
+
uploadUrl: string,
|
|
328
|
+
istk: string,
|
|
329
|
+
signal?: AbortSignal,
|
|
330
|
+
context: { filename?: string; fileSize?: number } = {}
|
|
331
|
+
): Promise<TusSessionProbe> {
|
|
332
|
+
ensureNotAborted(signal);
|
|
333
|
+
|
|
334
|
+
const response = await config.fetch(uploadUrl, {
|
|
335
|
+
method: 'HEAD',
|
|
336
|
+
headers: {
|
|
337
|
+
'Tus-Resumable': TUS_VERSION,
|
|
338
|
+
'X-ISTK': istk,
|
|
339
|
+
},
|
|
340
|
+
signal,
|
|
341
|
+
timeoutMs: config.tusPostTimeoutMs,
|
|
342
|
+
}).catch(err => {
|
|
343
|
+
if (getErrorName(err) === 'AbortError') {
|
|
344
|
+
// The same AbortError ends a user cancel and the request timeout; the
|
|
345
|
+
// caller's signal tells them apart.
|
|
346
|
+
throw new TusUploadError(signal?.aborted ? 'Upload cancelled' : 'TUS HEAD request timed out', {
|
|
347
|
+
cause: err instanceof Error ? err : null,
|
|
348
|
+
...context,
|
|
349
|
+
phase: signal?.aborted ? 'cancelled' : 'timeout',
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
throw err;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (response.status === 404 || response.status === 410) {
|
|
356
|
+
throw new TusUploadError(`TUS session no longer exists on the server (${response.status})`, {
|
|
357
|
+
httpStatus: response.status,
|
|
358
|
+
...context,
|
|
359
|
+
phase: 'create',
|
|
360
|
+
sessionLost: true,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!response.ok) {
|
|
365
|
+
const errorText = await response.text().catch(() => '');
|
|
366
|
+
const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
|
|
367
|
+
if (gateError) {
|
|
368
|
+
throw gateError;
|
|
369
|
+
}
|
|
370
|
+
throw new TusUploadError(`TUS HEAD failed: ${response.status} - ${errorText}`, {
|
|
371
|
+
httpStatus: response.status,
|
|
372
|
+
...context,
|
|
373
|
+
phase: 'create',
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const offset = parseIntegerHeader(response.headers.get('Upload-Offset'), 0);
|
|
378
|
+
const length = parseIntegerHeader(response.headers.get('Upload-Length'), 0);
|
|
379
|
+
const chunkSize = parseIntegerHeader(response.headers.get('X-Chunk-Size'), 1);
|
|
380
|
+
if (offset === null || length === null || chunkSize === null || offset > length) {
|
|
381
|
+
throw new TusUploadError(
|
|
382
|
+
'TUS HEAD response missing or invalid Upload-Offset / Upload-Length / X-Chunk-Size headers',
|
|
383
|
+
{ ...context, phase: 'create' }
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return { offset, length, chunkSize };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Confirm finalization of a session Forge already holds every byte of: a
|
|
392
|
+
* zero-length PATCH at `Upload-Offset: fileSize`. Forge treats any PATCH at
|
|
393
|
+
* or past the declared size as "finalize (again) and acknowledge" - it
|
|
394
|
+
* re-runs a finalization that failed transiently after the last chunk
|
|
395
|
+
* (Core/DynamoDB/billing), and answers 204 for one that already completed.
|
|
396
|
+
* Without this a resume that finds the transfer complete would skip straight
|
|
397
|
+
* to the status poll and could report a never-activated ingot as done.
|
|
398
|
+
*
|
|
399
|
+
* 409 means another finalizer holds the lease right now: retry later, the
|
|
400
|
+
* session is intact. 404/410 mean the session is gone (`sessionLost`).
|
|
401
|
+
*/
|
|
402
|
+
async function confirmTusFinalization(
|
|
403
|
+
config: ResolvedMobileConfig,
|
|
404
|
+
uploadUrl: string,
|
|
405
|
+
istk: string,
|
|
406
|
+
fileSize: number,
|
|
407
|
+
signal?: AbortSignal,
|
|
408
|
+
context: { filename?: string; fileSize?: number } = {}
|
|
409
|
+
): Promise<void> {
|
|
410
|
+
ensureNotAborted(signal);
|
|
411
|
+
|
|
412
|
+
const response = await config.fetch(uploadUrl, {
|
|
413
|
+
method: 'PATCH',
|
|
414
|
+
headers: {
|
|
415
|
+
'Tus-Resumable': TUS_VERSION,
|
|
416
|
+
'Upload-Offset': String(fileSize),
|
|
417
|
+
'Content-Type': 'application/offset+octet-stream',
|
|
418
|
+
'X-ISTK': istk,
|
|
419
|
+
},
|
|
420
|
+
signal,
|
|
421
|
+
timeoutMs: config.tusChunkTimeoutMs,
|
|
422
|
+
}).catch(err => {
|
|
423
|
+
if (getErrorName(err) === 'AbortError') {
|
|
424
|
+
throw new TusUploadError(signal?.aborted ? 'Upload cancelled' : 'TUS finalization request timed out', {
|
|
425
|
+
cause: err instanceof Error ? err : null,
|
|
426
|
+
...context,
|
|
427
|
+
phase: signal?.aborted ? 'cancelled' : 'timeout',
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
throw err;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
if (response.status === 404 || response.status === 410) {
|
|
434
|
+
throw new TusUploadError(`TUS session no longer exists on the server (${response.status})`, {
|
|
435
|
+
httpStatus: response.status,
|
|
436
|
+
...context,
|
|
437
|
+
phase: 'create',
|
|
438
|
+
sessionLost: true,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
if (response.status === 409) {
|
|
442
|
+
throw new TusUploadError('TUS finalization is already in progress on the server; retry shortly', {
|
|
443
|
+
httpStatus: 409,
|
|
444
|
+
...context,
|
|
445
|
+
phase: 'upload',
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
if (!response.ok) {
|
|
449
|
+
const errorText = await response.text().catch(() => '');
|
|
450
|
+
const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
|
|
451
|
+
if (gateError) {
|
|
452
|
+
throw gateError;
|
|
453
|
+
}
|
|
454
|
+
throw new TusUploadError(`TUS finalization failed: ${response.status} - ${errorText.substring(0, 200)}`, {
|
|
455
|
+
httpStatus: response.status,
|
|
456
|
+
...context,
|
|
457
|
+
phase: 'upload',
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Hard ceiling on a single chunk PATCH regardless of progress. The stall
|
|
464
|
+
* watchdog is the real limit; this only ends a transfer whose progress events
|
|
465
|
+
* keep trickling in without ever finishing.
|
|
466
|
+
*/
|
|
467
|
+
const CHUNK_ABSOLUTE_CAP_MS = 60 * 60_000;
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* PATCH one chunk. `stallTimeoutMs` is a STALL watchdog, not a deadline: it
|
|
471
|
+
* fires only when no upload progress (and no response) arrives for that long.
|
|
472
|
+
* Forge fixes non-final chunks at 50 MB and the client cannot shrink them, so
|
|
473
|
+
* an absolute per-request timeout would kill every legitimately slow uplink
|
|
474
|
+
* (a 50 MB chunk needs ~3.5 Mbps sustained to beat 120 s) and restart the
|
|
475
|
+
* file from byte 0. The final PATCH also waits for Forge to finalize after the
|
|
476
|
+
* last byte is sent; a progress-based watchdog gives that the same patience.
|
|
477
|
+
*/
|
|
199
478
|
function uploadChunk(
|
|
200
479
|
uploadUrl: string,
|
|
201
480
|
istk: string,
|
|
202
481
|
chunk: TusChunk,
|
|
203
482
|
offset: number,
|
|
204
|
-
|
|
483
|
+
stallTimeoutMs: number,
|
|
205
484
|
signal?: AbortSignal,
|
|
206
485
|
onChunkProgress?: (chunkBytesUploaded: number) => void
|
|
207
486
|
): Promise<number> {
|
|
@@ -212,9 +491,13 @@ function uploadChunk(
|
|
|
212
491
|
return new Promise((resolve, reject) => {
|
|
213
492
|
const xhr = new XMLHttpRequest();
|
|
214
493
|
let settled = false;
|
|
494
|
+
let watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
495
|
+
let cap: ReturnType<typeof setTimeout> | null = null;
|
|
215
496
|
|
|
216
497
|
const cleanup = () => {
|
|
217
498
|
signal?.removeEventListener('abort', abort);
|
|
499
|
+
if (watchdog) clearTimeout(watchdog);
|
|
500
|
+
if (cap) clearTimeout(cap);
|
|
218
501
|
};
|
|
219
502
|
|
|
220
503
|
const finish = (fn: () => void) => {
|
|
@@ -235,7 +518,22 @@ function uploadChunk(
|
|
|
235
518
|
}
|
|
236
519
|
signal?.addEventListener('abort', abort, { once: true });
|
|
237
520
|
|
|
521
|
+
// Settle BEFORE aborting: the abort handler below would otherwise report
|
|
522
|
+
// the stall as a user cancellation.
|
|
523
|
+
const stall = (message: string) => {
|
|
524
|
+
finish(() => reject(new TusUploadError(message, { phase: 'timeout' })));
|
|
525
|
+
xhr.abort();
|
|
526
|
+
};
|
|
527
|
+
const armWatchdog = () => {
|
|
528
|
+
if (watchdog) clearTimeout(watchdog);
|
|
529
|
+
watchdog = setTimeout(
|
|
530
|
+
() => stall(`Chunk upload stalled: no progress for ${stallTimeoutMs / 1000}s`),
|
|
531
|
+
stallTimeoutMs
|
|
532
|
+
);
|
|
533
|
+
};
|
|
534
|
+
|
|
238
535
|
xhr.upload.onprogress = event => {
|
|
536
|
+
armWatchdog();
|
|
239
537
|
if (event.lengthComputable) {
|
|
240
538
|
onChunkProgress?.(event.loaded);
|
|
241
539
|
}
|
|
@@ -243,8 +541,14 @@ function uploadChunk(
|
|
|
243
541
|
|
|
244
542
|
xhr.onload = () => {
|
|
245
543
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
246
|
-
|
|
247
|
-
|
|
544
|
+
// Forge answers every PATCH with Upload-Offset, so a 2xx without a
|
|
545
|
+
// readable one is not Forge (a captive portal's 200 page is the
|
|
546
|
+
// classic case): report no advance and let the stall guard fail the
|
|
547
|
+
// attempt, rather than crediting bytes that may never have arrived.
|
|
548
|
+
// parseInt would also turn a garbage header into NaN, which slips
|
|
549
|
+
// through that guard (every comparison with NaN is false) and ends
|
|
550
|
+
// the upload loop as a success.
|
|
551
|
+
const newOffset = parseIntegerHeader(xhr.getResponseHeader('Upload-Offset'), 0) ?? offset;
|
|
248
552
|
finish(() => resolve(newOffset));
|
|
249
553
|
} else {
|
|
250
554
|
const gateError = gateErrorFromBody(xhr.status, parseErrorBody(xhr.responseText));
|
|
@@ -261,20 +565,171 @@ function uploadChunk(
|
|
|
261
565
|
|
|
262
566
|
xhr.onerror = () => finish(() => reject(new TusUploadError('Chunk upload network error', { phase: 'network' })));
|
|
263
567
|
xhr.onabort = () => finish(() => reject(new TusUploadError('Chunk upload aborted', { phase: 'cancelled' })));
|
|
264
|
-
xhr.ontimeout = () => finish(() => reject(new TusUploadError(
|
|
568
|
+
xhr.ontimeout = () => finish(() => reject(new TusUploadError('Chunk upload timed out', { phase: 'timeout' })));
|
|
265
569
|
|
|
266
570
|
xhr.open('PATCH', uploadUrl);
|
|
267
571
|
xhr.setRequestHeader('Tus-Resumable', TUS_VERSION);
|
|
268
572
|
xhr.setRequestHeader('Upload-Offset', String(offset));
|
|
269
573
|
xhr.setRequestHeader('Content-Type', 'application/offset+octet-stream');
|
|
270
574
|
xhr.setRequestHeader('X-ISTK', istk);
|
|
271
|
-
|
|
575
|
+
// No absolute XHR deadline (see the function comment); the watchdog below
|
|
576
|
+
// is armed before send and re-armed on every progress event.
|
|
577
|
+
armWatchdog();
|
|
578
|
+
cap = setTimeout(
|
|
579
|
+
() => stall(`Chunk upload exceeded ${CHUNK_ABSOLUTE_CAP_MS / 60_000} minutes`),
|
|
580
|
+
CHUNK_ABSOLUTE_CAP_MS
|
|
581
|
+
);
|
|
272
582
|
// React Native-only body shape (see TusChunk): the native layer decodes
|
|
273
583
|
// `base64` itself, so the chunk bytes never touch the JS thread.
|
|
274
584
|
xhr.send({ base64: chunk.base64 } as unknown as XMLHttpRequestBodyInit);
|
|
275
585
|
});
|
|
276
586
|
}
|
|
277
587
|
|
|
588
|
+
/**
|
|
589
|
+
* PATCH a whole file as the session's only chunk on the app's background
|
|
590
|
+
* URLSession. The OS owns the transfer, so it finishes even if iOS suspends
|
|
591
|
+
* the app; the trade is no progress events and no abort. The result is mapped
|
|
592
|
+
* exactly like the XHR path so callers see one error taxonomy.
|
|
593
|
+
*/
|
|
594
|
+
async function uploadWholeFileInBackground(
|
|
595
|
+
uploader: MobileBackgroundUploader,
|
|
596
|
+
uploadUrl: string,
|
|
597
|
+
istk: string,
|
|
598
|
+
fileUri: string,
|
|
599
|
+
offset: number
|
|
600
|
+
): Promise<number> {
|
|
601
|
+
let result: Awaited<ReturnType<MobileBackgroundUploader['uploadFile']>>;
|
|
602
|
+
try {
|
|
603
|
+
result = await uploader.uploadFile(uploadUrl, fileUri, {
|
|
604
|
+
method: 'PATCH',
|
|
605
|
+
headers: {
|
|
606
|
+
'Tus-Resumable': TUS_VERSION,
|
|
607
|
+
'Upload-Offset': String(offset),
|
|
608
|
+
'Content-Type': 'application/offset+octet-stream',
|
|
609
|
+
'X-ISTK': istk,
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
} catch (err) {
|
|
613
|
+
// The adapter only throws when no HTTP response came back (offline, DNS,
|
|
614
|
+
// the OS cancelled the task); an HTTP failure is a status below.
|
|
615
|
+
throw new TusUploadError(`Background chunk upload failed: ${getErrorMessage(err)}`, {
|
|
616
|
+
cause: err instanceof Error ? err : null,
|
|
617
|
+
phase: 'network',
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const body = typeof result.body === 'string' ? result.body : '';
|
|
622
|
+
if (result.status >= 200 && result.status < 300) {
|
|
623
|
+
const offsetHeader = getHeaderIgnoreCase(result.headers, 'Upload-Offset');
|
|
624
|
+
// Forge always answers a PATCH with Upload-Offset. A 2xx without it is not
|
|
625
|
+
// Forge (a captive portal's 200 page is the classic case), so report no
|
|
626
|
+
// advance: the stall guard fails the attempt and the next one asks Forge
|
|
627
|
+
// (HEAD) instead of marking bytes uploaded that may never have arrived.
|
|
628
|
+
return offsetHeader ? parseInt(offsetHeader, 10) : offset;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const gateError = gateErrorFromBody(result.status, parseErrorBody(body));
|
|
632
|
+
if (gateError) {
|
|
633
|
+
throw gateError;
|
|
634
|
+
}
|
|
635
|
+
throw new TusUploadError(`Chunk upload failed: ${result.status} - ${body.substring(0, 200)}`, {
|
|
636
|
+
httpStatus: result.status,
|
|
637
|
+
phase: 'upload',
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Translate what the OS transfer adapter reports into the SDK's one error
|
|
643
|
+
* taxonomy, so a caller sees the same `TusUploadError` phases and typed gate
|
|
644
|
+
* errors whichever engine carried the bytes. Forge's status and body ride
|
|
645
|
+
* through untouched: a 402 becomes the gate error the XHR path throws, a
|
|
646
|
+
* 404/410 marks the session lost exactly as HEAD would, and any other status
|
|
647
|
+
* keeps its number for the caller's own policy (a 409 during finalize is a
|
|
648
|
+
* retry-later, not a lost session). The remaining kinds are transport
|
|
649
|
+
* outcomes: the caller's own abort is `cancelled`; an OS-ended task
|
|
650
|
+
* (`interrupted`) is a network failure, because the right response is the
|
|
651
|
+
* same as for a dropped connection: resume from the server offset. Anything
|
|
652
|
+
* that is not a `BackgroundTransferError` is an adapter defect and is
|
|
653
|
+
* reported as a network failure with the cause attached, never swallowed.
|
|
654
|
+
*/
|
|
655
|
+
export function mapBackgroundTransferError(
|
|
656
|
+
err: unknown,
|
|
657
|
+
context: { filename?: string; fileSize?: number } = {}
|
|
658
|
+
): SparkVaultMobileError {
|
|
659
|
+
if (!(err instanceof BackgroundTransferError)) {
|
|
660
|
+
return new TusUploadError(`Background transfer failed: ${getErrorMessage(err)}`, {
|
|
661
|
+
cause: err instanceof Error ? err : null,
|
|
662
|
+
...context,
|
|
663
|
+
phase: 'network',
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
switch (err.kind) {
|
|
668
|
+
case 'http': {
|
|
669
|
+
const status = err.httpStatus;
|
|
670
|
+
const body = err.body ?? '';
|
|
671
|
+
if (status !== undefined) {
|
|
672
|
+
const gateError = gateErrorFromBody(status, parseErrorBody(body));
|
|
673
|
+
if (gateError) return gateError;
|
|
674
|
+
}
|
|
675
|
+
const sessionLost = status === 404 || status === 410;
|
|
676
|
+
return new TusUploadError(
|
|
677
|
+
sessionLost
|
|
678
|
+
? `TUS session no longer exists on the server (${status})`
|
|
679
|
+
: `Background chunk upload failed: ${status ?? 'unknown status'} - ${body.substring(0, 200)}`,
|
|
680
|
+
{ cause: err, httpStatus: status ?? null, ...context, phase: 'upload', sessionLost }
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
case 'cancelled':
|
|
684
|
+
return new TusUploadError('Upload cancelled', { cause: err, ...context, phase: 'cancelled' });
|
|
685
|
+
case 'stalled':
|
|
686
|
+
return new TusUploadError(`Upload stalled: ${err.message}`, { cause: err, ...context, phase: 'stalled' });
|
|
687
|
+
case 'file':
|
|
688
|
+
return new TusUploadError(`Background transfer could not read the file: ${err.message}`, {
|
|
689
|
+
cause: err,
|
|
690
|
+
...context,
|
|
691
|
+
phase: 'upload',
|
|
692
|
+
});
|
|
693
|
+
case 'interrupted':
|
|
694
|
+
return new TusUploadError(`Background transfer interrupted: ${err.message}`, {
|
|
695
|
+
cause: err,
|
|
696
|
+
...context,
|
|
697
|
+
phase: 'network',
|
|
698
|
+
});
|
|
699
|
+
case 'network':
|
|
700
|
+
return new TusUploadError(`Background transfer network error: ${err.message}`, {
|
|
701
|
+
cause: err,
|
|
702
|
+
...context,
|
|
703
|
+
phase: 'network',
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Hand the whole remaining range of a session to the OS transfer engine and
|
|
710
|
+
* wait for its verdict. The engine chains every chunk itself, so this is one
|
|
711
|
+
* await for the entire file however large it is, and the transfer keeps
|
|
712
|
+
* going while the app is suspended or killed (the adapter re-attaches to
|
|
713
|
+
* the job by session URL). Progress arrives as absolute server offsets and
|
|
714
|
+
* is forwarded as (uploaded, total) like every other path; failures are
|
|
715
|
+
* mapped onto the XHR taxonomy so callers see one set of errors.
|
|
716
|
+
*/
|
|
717
|
+
async function transferRemainderInBackground(
|
|
718
|
+
engine: MobileBackgroundTransfer,
|
|
719
|
+
job: BackgroundTransferJob,
|
|
720
|
+
options: { filename: string; abortSignal?: AbortSignal; onProgress?: UploadProgressCallback }
|
|
721
|
+
): Promise<number> {
|
|
722
|
+
try {
|
|
723
|
+
const result = await engine.transfer(job, {
|
|
724
|
+
abortSignal: options.abortSignal,
|
|
725
|
+
onProgress: offset => options.onProgress?.(offset, job.fileSize),
|
|
726
|
+
});
|
|
727
|
+
return result.offset;
|
|
728
|
+
} catch (err) {
|
|
729
|
+
throw mapBackgroundTransferError(err, { filename: options.filename, fileSize: job.fileSize });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
278
733
|
export class MobileTusUploader {
|
|
279
734
|
private readonly config: ResolvedMobileConfig;
|
|
280
735
|
|
|
@@ -288,40 +743,142 @@ export class MobileTusUploader {
|
|
|
288
743
|
throw new SparkVaultValidationError('fileReader adapter is required for URI uploads');
|
|
289
744
|
}
|
|
290
745
|
|
|
291
|
-
const
|
|
746
|
+
const source = resolveSessionSource(options);
|
|
747
|
+
const istk = source.mode === 'create' ? source.parsed.istk : source.istk;
|
|
292
748
|
const debug = options.debug;
|
|
293
749
|
const cleanUri = options.fileUri.split('#')[0];
|
|
294
750
|
let bytesUploaded = 0;
|
|
295
|
-
|
|
751
|
+
// Known up front on resume so a cancel during the HEAD still terminates
|
|
752
|
+
// the (existing) session.
|
|
753
|
+
let uploadUrl: string | null = source.mode === 'resume' ? source.uploadUrl : null;
|
|
296
754
|
|
|
297
755
|
try {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
options.
|
|
303
|
-
options.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
756
|
+
let chunkSize: number;
|
|
757
|
+
let sessionUrl: string;
|
|
758
|
+
if (source.mode === 'resume') {
|
|
759
|
+
sessionUrl = source.uploadUrl;
|
|
760
|
+
debug?.log(`Resuming TUS upload for ${options.filename}`);
|
|
761
|
+
const probe = await headTusSession(this.config, source.uploadUrl, istk, options.abortSignal, {
|
|
762
|
+
filename: options.filename,
|
|
763
|
+
fileSize: options.fileSize,
|
|
764
|
+
});
|
|
765
|
+
// A session is bound to one file: a different length means the
|
|
766
|
+
// persisted session belongs to another file (or this file changed),
|
|
767
|
+
// and PATCHing into it would corrupt the ingot. That makes the
|
|
768
|
+
// session worthless for this file, which is exactly what
|
|
769
|
+
// `sessionLost` means: the caller drops it and starts fresh, the same
|
|
770
|
+
// as for a 404/410, instead of retrying a resume that can never work.
|
|
771
|
+
if (probe.length !== options.fileSize) {
|
|
772
|
+
throw new TusUploadError(
|
|
773
|
+
`TUS session Upload-Length (${probe.length}) does not match the file size (${options.fileSize}); the session belongs to a different file`,
|
|
774
|
+
{ filename: options.filename, fileSize: options.fileSize, phase: 'create', sessionLost: true }
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
chunkSize = probe.chunkSize;
|
|
778
|
+
bytesUploaded = probe.offset;
|
|
779
|
+
debug?.log(`TUS session for ${options.filename} is at offset ${bytesUploaded}/${options.fileSize}`);
|
|
780
|
+
if (bytesUploaded >= options.fileSize) {
|
|
781
|
+
// Every byte is there, but the finalization the last chunk should
|
|
782
|
+
// have triggered may not have happened (that is often why the
|
|
783
|
+
// previous attempt failed): ask Forge to finalize and acknowledge.
|
|
784
|
+
await confirmTusFinalization(this.config, sessionUrl, istk, options.fileSize, options.abortSignal, {
|
|
785
|
+
filename: options.filename,
|
|
786
|
+
fileSize: options.fileSize,
|
|
787
|
+
});
|
|
788
|
+
options.onProgress?.(bytesUploaded, options.fileSize);
|
|
789
|
+
}
|
|
790
|
+
} else {
|
|
791
|
+
debug?.log(`Starting TUS upload for ${options.filename}`);
|
|
792
|
+
const created = await createTusUpload(
|
|
793
|
+
this.config,
|
|
794
|
+
source.parsed,
|
|
795
|
+
options.fileSize,
|
|
796
|
+
options.filename,
|
|
797
|
+
options.contentType,
|
|
798
|
+
options.abortSignal
|
|
799
|
+
);
|
|
800
|
+
sessionUrl = created.uploadUrl;
|
|
801
|
+
uploadUrl = sessionUrl;
|
|
802
|
+
chunkSize = created.chunkSize;
|
|
803
|
+
// Before the first PATCH: once bytes are in flight the session is
|
|
804
|
+
// worth resuming, so the caller must already hold it.
|
|
805
|
+
options.onSessionCreated?.({ uploadUrl: sessionUrl, istk, chunkSize });
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// The OS transfer engine, when the app provides one, takes the whole
|
|
809
|
+
// remaining range in a single hand-off: it chains the chunks itself and
|
|
810
|
+
// keeps going while the app is suspended or killed, which no JS loop
|
|
811
|
+
// can. Any size and any offset qualify, so a resumed multi-GB video
|
|
812
|
+
// rides it too; the single-chunk adapter and XHR in the loop below are
|
|
813
|
+
// the fallbacks. Start-or-attach on the session URL means a relaunch
|
|
814
|
+
// that resumes the same session re-joins the job the OS is still
|
|
815
|
+
// running instead of PATCHing over it.
|
|
816
|
+
const backgroundTransfer = options.transport === 'background' ? this.config.backgroundTransfer : undefined;
|
|
817
|
+
if (backgroundTransfer && bytesUploaded < options.fileSize) {
|
|
818
|
+
ensureNotAborted(options.abortSignal);
|
|
819
|
+
const policy = options.transferPolicy ?? {};
|
|
820
|
+
const finalOffset = await transferRemainderInBackground(
|
|
821
|
+
backgroundTransfer,
|
|
822
|
+
{
|
|
823
|
+
uploadUrl: sessionUrl,
|
|
824
|
+
istk,
|
|
825
|
+
fileUri: cleanUri,
|
|
826
|
+
fileSize: options.fileSize,
|
|
827
|
+
chunkSize,
|
|
828
|
+
offset: bytesUploaded,
|
|
829
|
+
allowsCellularAccess: policy.allowsCellularAccess ?? true,
|
|
830
|
+
allowsConstrainedNetworkAccess: policy.allowsConstrainedNetworkAccess ?? true,
|
|
831
|
+
},
|
|
832
|
+
{ filename: options.filename, abortSignal: options.abortSignal, onProgress: options.onProgress }
|
|
833
|
+
);
|
|
834
|
+
// The engine only resolves at the end of the file; anything else is
|
|
835
|
+
// an engine that gave up quietly, and an offset past the file is as
|
|
836
|
+
// untrustworthy as one short of it. Same treatment as a non-advancing
|
|
837
|
+
// PATCH: fail, and let the next attempt ask Forge (HEAD) where the
|
|
838
|
+
// session really stands.
|
|
839
|
+
if (finalOffset !== options.fileSize) {
|
|
840
|
+
throw new TusUploadError(
|
|
841
|
+
`Upload stalled: background transfer ended at offset ${finalOffset} of ${options.fileSize}`,
|
|
842
|
+
{ phase: 'stalled', filename: options.filename, fileSize: options.fileSize }
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
// An abort that lands after the OS delivered the last byte changes
|
|
846
|
+
// nothing: the transfer is complete, and the loop below (with its
|
|
847
|
+
// abort check) has nothing left to do. Same rule as the single-chunk
|
|
848
|
+
// adapter: a finished upload is never reported as cancelled.
|
|
849
|
+
bytesUploaded = finalOffset;
|
|
850
|
+
options.onProgress?.(bytesUploaded, options.fileSize);
|
|
851
|
+
}
|
|
309
852
|
|
|
310
853
|
while (bytesUploaded < options.fileSize) {
|
|
311
854
|
ensureNotAborted(options.abortSignal);
|
|
312
|
-
const chunkLength = Math.min(chunkSize, options.fileSize - bytesUploaded);
|
|
313
|
-
const chunk = await this.readChunk(fileReader, cleanUri, bytesUploaded, chunkLength);
|
|
314
855
|
const chunkStart = bytesUploaded;
|
|
856
|
+
const chunkLength = Math.min(chunkSize, options.fileSize - chunkStart);
|
|
315
857
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
858
|
+
let newOffset: number;
|
|
859
|
+
const backgroundUploader = options.transport === 'background' ? this.config.backgroundUploader : undefined;
|
|
860
|
+
if (backgroundUploader && this.fitsOneBackgroundRequest(chunkStart, options.fileSize, chunkSize)) {
|
|
861
|
+
// The OS owns this request, so an abort that fires while it is in
|
|
862
|
+
// flight cannot stop it. It only ever carries a whole single-chunk
|
|
863
|
+
// file, so a 2xx here means the transfer is complete: the loop
|
|
864
|
+
// ends and the upload finishes normally (the caller verifies the
|
|
865
|
+
// ingot), because a finished upload must never be reported as
|
|
866
|
+
// cancelled, and a DELETE now would drop bytes Forge has already
|
|
867
|
+
// finalized. Only a response that leaves bytes missing reaches
|
|
868
|
+
// the `ensureNotAborted` at the top of the next iteration.
|
|
869
|
+
newOffset = await uploadWholeFileInBackground(backgroundUploader, sessionUrl, istk, cleanUri, chunkStart);
|
|
870
|
+
} else {
|
|
871
|
+
const chunk = await this.readChunk(fileReader, cleanUri, chunkStart, chunkLength);
|
|
872
|
+
newOffset = await uploadChunk(
|
|
873
|
+
sessionUrl,
|
|
874
|
+
istk,
|
|
875
|
+
chunk,
|
|
876
|
+
chunkStart,
|
|
877
|
+
this.config.tusChunkTimeoutMs,
|
|
878
|
+
options.abortSignal,
|
|
879
|
+
chunkUploaded => options.onProgress?.(chunkStart + chunkUploaded, options.fileSize)
|
|
880
|
+
);
|
|
881
|
+
}
|
|
325
882
|
|
|
326
883
|
if (newOffset <= chunkStart || newOffset > options.fileSize) {
|
|
327
884
|
throw new TusUploadError('Upload stalled: server offset did not advance', {
|
|
@@ -344,11 +901,17 @@ export class MobileTusUploader {
|
|
|
344
901
|
// what lets Forge remove already-written chunk objects (otherwise they
|
|
345
902
|
// are orphaned in S3 — nothing else cleans them up) and settle
|
|
346
903
|
// partial-transfer billing, matching the web client's abort semantics.
|
|
904
|
+
// Unless the caller asked to keep it: a pause is not a cancel, and the
|
|
905
|
+
// bytes Forge holds are exactly what a later resume avoids re-sending.
|
|
347
906
|
const cancelled =
|
|
348
907
|
options.abortSignal?.aborted === true ||
|
|
349
908
|
(err instanceof TusUploadError && err.phase === 'cancelled');
|
|
350
909
|
if (cancelled && uploadUrl) {
|
|
351
|
-
|
|
910
|
+
if (options.abortBehavior === 'keep') {
|
|
911
|
+
debug?.log(`TUS session for ${options.filename} kept on Forge for resume`);
|
|
912
|
+
} else {
|
|
913
|
+
this.terminateUpload(uploadUrl, istk, debug);
|
|
914
|
+
}
|
|
352
915
|
}
|
|
353
916
|
|
|
354
917
|
if (err instanceof TusUploadError) throw err;
|
|
@@ -364,6 +927,76 @@ export class MobileTusUploader {
|
|
|
364
927
|
}
|
|
365
928
|
}
|
|
366
929
|
|
|
930
|
+
/**
|
|
931
|
+
* Where a persisted session stands, or `null` when Forge no longer has it
|
|
932
|
+
* (404/410 — expired or terminated). Any other failure throws with
|
|
933
|
+
* `sessionLost` false (a 5xx or timeout says nothing about the session).
|
|
934
|
+
* The probe reports the session's own length; whether that length is the
|
|
935
|
+
* caller's file is for the caller (or `uploadFromUri`'s resume, which
|
|
936
|
+
* throws `sessionLost` on a mismatch) to judge. Lets a caller reconcile
|
|
937
|
+
* sessions on relaunch (a completed one just needs its ingot verified; a
|
|
938
|
+
* lost one needs a fresh create) before spending an upload slot on it.
|
|
939
|
+
*/
|
|
940
|
+
async probeSession(session: { uploadUrl: string; istk: string }): Promise<TusSessionProbe | null> {
|
|
941
|
+
try {
|
|
942
|
+
return await headTusSession(this.config, session.uploadUrl, session.istk);
|
|
943
|
+
} catch (err) {
|
|
944
|
+
if (err instanceof TusUploadError && err.sessionLost) return null;
|
|
945
|
+
throw err;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Whether the remaining transfer is ONE PATCH the background adapter can
|
|
951
|
+
* send: nothing uploaded yet and the whole file inside a single chunk. The
|
|
952
|
+
* adapter sends whole files and Forge rejects any non-final chunk that is
|
|
953
|
+
* not exactly `X-Chunk-Size`, so a multi-chunk file has no legal single
|
|
954
|
+
* request, and a partially uploaded session would resend bytes Forge holds.
|
|
955
|
+
* A zero-byte file stays on the XHR path (it sends no PATCH at all there).
|
|
956
|
+
*/
|
|
957
|
+
private fitsOneBackgroundRequest(offset: number, fileSize: number, chunkSize: number): boolean {
|
|
958
|
+
return offset === 0 && fileSize > 0 && fileSize <= chunkSize;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Release a session the caller kept on Forge (`abortBehavior: 'keep'`)
|
|
963
|
+
* and will not resume after all: the tus DELETE lets Forge drop the chunk
|
|
964
|
+
* objects it holds (nothing else cleans them up before expiry) and settle
|
|
965
|
+
* the partial transfer's billing. Resolves once the session is gone,
|
|
966
|
+
* which includes Forge answering 404/410 (already expired or terminated);
|
|
967
|
+
* any other failure rejects with a `TusUploadError` (a timeout, or the
|
|
968
|
+
* status Forge sent) so the caller can retry later. Not bound to any
|
|
969
|
+
* abort signal: a terminate is the last thing a caller does with a
|
|
970
|
+
* session, never something to cancel.
|
|
971
|
+
*/
|
|
972
|
+
async terminateSession(session: { uploadUrl: string; istk: string }): Promise<void> {
|
|
973
|
+
// A terminate is the one place the SDK knows the OS job must die with
|
|
974
|
+
// the session: a DELETE under a job the engine is still chaining would
|
|
975
|
+
// leave it PATCHing a gone session until it fails on its own.
|
|
976
|
+
const backgroundTransfer = this.config.backgroundTransfer;
|
|
977
|
+
if (backgroundTransfer) {
|
|
978
|
+
await this.cancelBackgroundTransfer(backgroundTransfer, session.uploadUrl);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const response = await this.requestTerminate(session.uploadUrl, session.istk).catch(err => {
|
|
982
|
+
if (getErrorName(err) === 'AbortError') {
|
|
983
|
+
throw new TusUploadError('TUS DELETE request timed out', {
|
|
984
|
+
cause: err instanceof Error ? err : null,
|
|
985
|
+
phase: 'timeout',
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
throw err;
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
if (response.ok || response.status === 404 || response.status === 410) return;
|
|
992
|
+
|
|
993
|
+
const errorText = await response.text().catch(() => '');
|
|
994
|
+
throw new TusUploadError(`TUS terminate failed: ${response.status} - ${errorText.substring(0, 200)}`, {
|
|
995
|
+
httpStatus: response.status,
|
|
996
|
+
phase: 'unknown',
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
367
1000
|
/**
|
|
368
1001
|
* Best-effort tus termination after a cancelled upload. Fire-and-forget so
|
|
369
1002
|
* cancel UX stays instant and the cancellation error still propagates — a
|
|
@@ -372,18 +1005,54 @@ export class MobileTusUploader {
|
|
|
372
1005
|
* which would kill the DELETE before it left the device.
|
|
373
1006
|
*/
|
|
374
1007
|
private terminateUpload(uploadUrl: string, istk: string, debug?: DebugLogger): void {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
1008
|
+
// The OS job, when there is one, is cancelled before the DELETE so the
|
|
1009
|
+
// engine stops PATCHing a session Forge is about to forget (see
|
|
1010
|
+
// terminateSession); without an engine the DELETE leaves the device
|
|
1011
|
+
// synchronously, exactly as it always has.
|
|
1012
|
+
const backgroundTransfer = this.config.backgroundTransfer;
|
|
1013
|
+
const terminate = backgroundTransfer
|
|
1014
|
+
? this.cancelBackgroundTransfer(backgroundTransfer, uploadUrl, debug).then(() => this.requestTerminate(uploadUrl, istk))
|
|
1015
|
+
: this.requestTerminate(uploadUrl, istk);
|
|
1016
|
+
void terminate.catch(err => {
|
|
1017
|
+
debug?.log(`TUS termination after cancel failed: ${getErrorMessage(err)}`);
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Ask the OS transfer engine to drop the job for a session about to be
|
|
1023
|
+
* terminated. Best-effort and never throwing: a job the engine no longer
|
|
1024
|
+
* has is a no-op by contract, and a failure here must not stop the DELETE
|
|
1025
|
+
* that follows, which is what actually frees Forge's chunks.
|
|
1026
|
+
*/
|
|
1027
|
+
private async cancelBackgroundTransfer(
|
|
1028
|
+
engine: MobileBackgroundTransfer,
|
|
1029
|
+
uploadUrl: string,
|
|
1030
|
+
debug?: DebugLogger
|
|
1031
|
+
): Promise<void> {
|
|
1032
|
+
try {
|
|
1033
|
+
await engine.cancel(uploadUrl);
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
const message = `Background transfer cancel before terminate failed: ${getErrorMessage(err)}`;
|
|
1036
|
+
debug?.log(message);
|
|
1037
|
+
this.config.logger.debug(message, { uploadUrl });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* The one tus DELETE request builder, shared by the fire-and-forget cancel
|
|
1043
|
+
* path and the awaited `terminateSession` so both send exactly the same
|
|
1044
|
+
* request: same headers, same timeout, and no abort signal (see the
|
|
1045
|
+
* callers for why neither may carry one).
|
|
1046
|
+
*/
|
|
1047
|
+
private requestTerminate(uploadUrl: string, istk: string): Promise<Response> {
|
|
1048
|
+
return this.config.fetch(uploadUrl, {
|
|
1049
|
+
method: 'DELETE',
|
|
1050
|
+
headers: {
|
|
1051
|
+
'Tus-Resumable': TUS_VERSION,
|
|
1052
|
+
'X-ISTK': istk,
|
|
1053
|
+
},
|
|
1054
|
+
timeoutMs: this.config.tusPostTimeoutMs,
|
|
1055
|
+
});
|
|
387
1056
|
}
|
|
388
1057
|
|
|
389
1058
|
private async readChunk(
|