@sparkvault/sdk-mobile 5.2.2 → 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/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 { ApiErrorBody, DebugLogger, MobileFileReader, UploadProgressCallback } from './types.js';
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
- forgeUrl: string;
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
- /** Strict positive-integer parse of Forge's advertised chunk size; anything else is `null`. */
113
- function parseChunkSizeHeader(raw: string | null): number | null {
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 > 0 ? value : null;
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 = parseChunkSizeHeader(chunkSizeHeader);
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
- timeoutMs: number,
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
- const offsetHeader = xhr.getResponseHeader('Upload-Offset');
247
- const newOffset = offsetHeader ? parseInt(offsetHeader, 10) : offset + chunk.byteLength;
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(`Chunk upload timeout after ${timeoutMs / 1000}s`, { phase: 'timeout' })));
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
- xhr.timeout = timeoutMs;
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 parsed = parseForgeUrl(options.forgeUrl);
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
- let uploadUrl: string | null = null;
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
- debug?.log(`Starting TUS upload for ${options.filename}`);
299
- const created = await createTusUpload(
300
- this.config,
301
- parsed,
302
- options.fileSize,
303
- options.filename,
304
- options.contentType,
305
- options.abortSignal
306
- );
307
- uploadUrl = created.uploadUrl;
308
- const chunkSize = created.chunkSize;
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
- const newOffset = await uploadChunk(
317
- uploadUrl,
318
- parsed.istk,
319
- chunk,
320
- chunkStart,
321
- this.config.tusChunkTimeoutMs,
322
- options.abortSignal,
323
- chunkUploaded => options.onProgress?.(chunkStart + chunkUploaded, options.fileSize)
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
- this.terminateUpload(uploadUrl, parsed.istk, debug);
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
- void this.config
376
- .fetch(uploadUrl, {
377
- method: 'DELETE',
378
- headers: {
379
- 'Tus-Resumable': TUS_VERSION,
380
- 'X-ISTK': istk,
381
- },
382
- timeoutMs: this.config.tusPostTimeoutMs,
383
- })
384
- .catch(err => {
385
- debug?.log(`TUS termination after cancel failed: ${getErrorMessage(err)}`);
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(