@sparkvault/sdk-mobile 5.2.2 → 5.3.0

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/tus.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { base64EncodeUtf8 } from './encoding.js';
2
- import { gateErrorFromBody, SparkVaultMobileError, SparkVaultValidationError, TusUploadError, } from './errors.js';
2
+ import { BackgroundTransferError, gateErrorFromBody, SparkVaultMobileError, SparkVaultValidationError, TusUploadError, } from './errors.js';
3
3
  const TUS_VERSION = '1.0.0';
4
4
  export function parseForgeUrl(forgeUrl) {
5
5
  let url;
@@ -66,12 +66,44 @@ function ensureNotAborted(signal) {
66
66
  throw new TusUploadError('Upload cancelled', { phase: 'cancelled' });
67
67
  }
68
68
  }
69
- /** Strict positive-integer parse of Forge's advertised chunk size; anything else is `null`. */
70
- function parseChunkSizeHeader(raw) {
69
+ /**
70
+ * Strict integer parse of a tus byte-count header (`Upload-Offset`,
71
+ * `Upload-Length`, `X-Chunk-Size`); anything malformed, unsafe, or below
72
+ * `minimum` is `null` so a corrupt header can never become an offset.
73
+ */
74
+ function parseIntegerHeader(raw, minimum) {
71
75
  if (raw === null || !/^\d+$/.test(raw.trim()))
72
76
  return null;
73
77
  const value = Number(raw);
74
- return Number.isSafeInteger(value) && value > 0 ? value : null;
78
+ return Number.isSafeInteger(value) && value >= minimum ? value : null;
79
+ }
80
+ /**
81
+ * Case-insensitive header lookup for adapter results. Native HTTP stacks
82
+ * hand headers back in whatever case they please (iOS lower-cases them),
83
+ * while Forge writes `Upload-Offset`.
84
+ */
85
+ function getHeaderIgnoreCase(headers, name) {
86
+ if (!headers)
87
+ return null;
88
+ const wanted = name.toLowerCase();
89
+ for (const key of Object.keys(headers)) {
90
+ if (key.toLowerCase() === wanted)
91
+ return headers[key];
92
+ }
93
+ return null;
94
+ }
95
+ function resolveSessionSource(options) {
96
+ if (options.resume) {
97
+ const istk = options.resume.istk ?? (options.forgeUrl ? parseForgeUrl(options.forgeUrl).istk : null);
98
+ if (!istk) {
99
+ throw new SparkVaultValidationError('resume.istk or forgeUrl is required to resume a TUS upload session');
100
+ }
101
+ return { mode: 'resume', uploadUrl: options.resume.uploadUrl, istk };
102
+ }
103
+ if (!options.forgeUrl) {
104
+ throw new SparkVaultValidationError('forgeUrl is required to create a TUS upload session');
105
+ }
106
+ return { mode: 'create', parsed: parseForgeUrl(options.forgeUrl) };
75
107
  }
76
108
  async function createTusUpload(config, parsed, fileSize, filename, contentType, signal) {
77
109
  ensureNotAborted(signal);
@@ -127,7 +159,7 @@ async function createTusUpload(config, parsed, fileSize, filename, contentType,
127
159
  // to dictate via `X-Chunk-Size` (mirrors sdk-js). A client-side guess could
128
160
  // only ever be rejected, so a missing header is a contract violation.
129
161
  const chunkSizeHeader = response.headers.get('X-Chunk-Size');
130
- const chunkSize = parseChunkSizeHeader(chunkSizeHeader);
162
+ const chunkSize = parseIntegerHeader(chunkSizeHeader, 1);
131
163
  if (chunkSize === null) {
132
164
  throw new TusUploadError(`TUS create response missing or invalid X-Chunk-Size header (received ${JSON.stringify(chunkSizeHeader)})`, {
133
165
  filename,
@@ -137,15 +169,159 @@ async function createTusUpload(config, parsed, fileSize, filename, contentType,
137
169
  }
138
170
  return { uploadUrl, chunkSize };
139
171
  }
140
- function uploadChunk(uploadUrl, istk, chunk, offset, timeoutMs, signal, onChunkProgress) {
172
+ /**
173
+ * Ask Forge where a session stands (tus HEAD). This is the only way to learn
174
+ * what a background URLSession delivered after the process was killed, and
175
+ * what a failed attempt left behind — so resume never re-sends bytes the
176
+ * server holds. A 404/410 means the session expired or was terminated: the
177
+ * error carries `sessionLost` so the caller drops its persisted session and
178
+ * creates a fresh one rather than retrying a dead URL. Any other failure
179
+ * (a 5xx, an auth rejection, a timeout) leaves `sessionLost` false: the
180
+ * session may well be intact, and treating it as dead would re-upload a
181
+ * whole file over a blip.
182
+ */
183
+ async function headTusSession(config, uploadUrl, istk, signal, context = {}) {
184
+ ensureNotAborted(signal);
185
+ const response = await config.fetch(uploadUrl, {
186
+ method: 'HEAD',
187
+ headers: {
188
+ 'Tus-Resumable': TUS_VERSION,
189
+ 'X-ISTK': istk,
190
+ },
191
+ signal,
192
+ timeoutMs: config.tusPostTimeoutMs,
193
+ }).catch(err => {
194
+ if (getErrorName(err) === 'AbortError') {
195
+ // The same AbortError ends a user cancel and the request timeout; the
196
+ // caller's signal tells them apart.
197
+ throw new TusUploadError(signal?.aborted ? 'Upload cancelled' : 'TUS HEAD request timed out', {
198
+ cause: err instanceof Error ? err : null,
199
+ ...context,
200
+ phase: signal?.aborted ? 'cancelled' : 'timeout',
201
+ });
202
+ }
203
+ throw err;
204
+ });
205
+ if (response.status === 404 || response.status === 410) {
206
+ throw new TusUploadError(`TUS session no longer exists on the server (${response.status})`, {
207
+ httpStatus: response.status,
208
+ ...context,
209
+ phase: 'create',
210
+ sessionLost: true,
211
+ });
212
+ }
213
+ if (!response.ok) {
214
+ const errorText = await response.text().catch(() => '');
215
+ const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
216
+ if (gateError) {
217
+ throw gateError;
218
+ }
219
+ throw new TusUploadError(`TUS HEAD failed: ${response.status} - ${errorText}`, {
220
+ httpStatus: response.status,
221
+ ...context,
222
+ phase: 'create',
223
+ });
224
+ }
225
+ const offset = parseIntegerHeader(response.headers.get('Upload-Offset'), 0);
226
+ const length = parseIntegerHeader(response.headers.get('Upload-Length'), 0);
227
+ const chunkSize = parseIntegerHeader(response.headers.get('X-Chunk-Size'), 1);
228
+ if (offset === null || length === null || chunkSize === null || offset > length) {
229
+ throw new TusUploadError('TUS HEAD response missing or invalid Upload-Offset / Upload-Length / X-Chunk-Size headers', { ...context, phase: 'create' });
230
+ }
231
+ return { offset, length, chunkSize };
232
+ }
233
+ /**
234
+ * Confirm finalization of a session Forge already holds every byte of: a
235
+ * zero-length PATCH at `Upload-Offset: fileSize`. Forge treats any PATCH at
236
+ * or past the declared size as "finalize (again) and acknowledge" - it
237
+ * re-runs a finalization that failed transiently after the last chunk
238
+ * (Core/DynamoDB/billing), and answers 204 for one that already completed.
239
+ * Without this a resume that finds the transfer complete would skip straight
240
+ * to the status poll and could report a never-activated ingot as done.
241
+ *
242
+ * 409 means another finalizer holds the lease right now: retry later, the
243
+ * session is intact. 404/410 mean the session is gone (`sessionLost`).
244
+ */
245
+ async function confirmTusFinalization(config, uploadUrl, istk, fileSize, signal, context = {}) {
246
+ ensureNotAborted(signal);
247
+ const response = await config.fetch(uploadUrl, {
248
+ method: 'PATCH',
249
+ headers: {
250
+ 'Tus-Resumable': TUS_VERSION,
251
+ 'Upload-Offset': String(fileSize),
252
+ 'Content-Type': 'application/offset+octet-stream',
253
+ 'X-ISTK': istk,
254
+ },
255
+ signal,
256
+ timeoutMs: config.tusChunkTimeoutMs,
257
+ }).catch(err => {
258
+ if (getErrorName(err) === 'AbortError') {
259
+ throw new TusUploadError(signal?.aborted ? 'Upload cancelled' : 'TUS finalization request timed out', {
260
+ cause: err instanceof Error ? err : null,
261
+ ...context,
262
+ phase: signal?.aborted ? 'cancelled' : 'timeout',
263
+ });
264
+ }
265
+ throw err;
266
+ });
267
+ if (response.status === 404 || response.status === 410) {
268
+ throw new TusUploadError(`TUS session no longer exists on the server (${response.status})`, {
269
+ httpStatus: response.status,
270
+ ...context,
271
+ phase: 'create',
272
+ sessionLost: true,
273
+ });
274
+ }
275
+ if (response.status === 409) {
276
+ throw new TusUploadError('TUS finalization is already in progress on the server; retry shortly', {
277
+ httpStatus: 409,
278
+ ...context,
279
+ phase: 'upload',
280
+ });
281
+ }
282
+ if (!response.ok) {
283
+ const errorText = await response.text().catch(() => '');
284
+ const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
285
+ if (gateError) {
286
+ throw gateError;
287
+ }
288
+ throw new TusUploadError(`TUS finalization failed: ${response.status} - ${errorText.substring(0, 200)}`, {
289
+ httpStatus: response.status,
290
+ ...context,
291
+ phase: 'upload',
292
+ });
293
+ }
294
+ }
295
+ /**
296
+ * Hard ceiling on a single chunk PATCH regardless of progress. The stall
297
+ * watchdog is the real limit; this only ends a transfer whose progress events
298
+ * keep trickling in without ever finishing.
299
+ */
300
+ const CHUNK_ABSOLUTE_CAP_MS = 60 * 60000;
301
+ /**
302
+ * PATCH one chunk. `stallTimeoutMs` is a STALL watchdog, not a deadline: it
303
+ * fires only when no upload progress (and no response) arrives for that long.
304
+ * Forge fixes non-final chunks at 50 MB and the client cannot shrink them, so
305
+ * an absolute per-request timeout would kill every legitimately slow uplink
306
+ * (a 50 MB chunk needs ~3.5 Mbps sustained to beat 120 s) and restart the
307
+ * file from byte 0. The final PATCH also waits for Forge to finalize after the
308
+ * last byte is sent; a progress-based watchdog gives that the same patience.
309
+ */
310
+ function uploadChunk(uploadUrl, istk, chunk, offset, stallTimeoutMs, signal, onChunkProgress) {
141
311
  if (typeof XMLHttpRequest === 'undefined') {
142
312
  throw new SparkVaultValidationError('XMLHttpRequest is required for mobile TUS uploads');
143
313
  }
144
314
  return new Promise((resolve, reject) => {
145
315
  const xhr = new XMLHttpRequest();
146
316
  let settled = false;
317
+ let watchdog = null;
318
+ let cap = null;
147
319
  const cleanup = () => {
148
320
  signal?.removeEventListener('abort', abort);
321
+ if (watchdog)
322
+ clearTimeout(watchdog);
323
+ if (cap)
324
+ clearTimeout(cap);
149
325
  };
150
326
  const finish = (fn) => {
151
327
  if (settled)
@@ -163,15 +339,33 @@ function uploadChunk(uploadUrl, istk, chunk, offset, timeoutMs, signal, onChunkP
163
339
  return;
164
340
  }
165
341
  signal?.addEventListener('abort', abort, { once: true });
342
+ // Settle BEFORE aborting: the abort handler below would otherwise report
343
+ // the stall as a user cancellation.
344
+ const stall = (message) => {
345
+ finish(() => reject(new TusUploadError(message, { phase: 'timeout' })));
346
+ xhr.abort();
347
+ };
348
+ const armWatchdog = () => {
349
+ if (watchdog)
350
+ clearTimeout(watchdog);
351
+ watchdog = setTimeout(() => stall(`Chunk upload stalled: no progress for ${stallTimeoutMs / 1000}s`), stallTimeoutMs);
352
+ };
166
353
  xhr.upload.onprogress = event => {
354
+ armWatchdog();
167
355
  if (event.lengthComputable) {
168
356
  onChunkProgress?.(event.loaded);
169
357
  }
170
358
  };
171
359
  xhr.onload = () => {
172
360
  if (xhr.status >= 200 && xhr.status < 300) {
173
- const offsetHeader = xhr.getResponseHeader('Upload-Offset');
174
- const newOffset = offsetHeader ? parseInt(offsetHeader, 10) : offset + chunk.byteLength;
361
+ // Forge answers every PATCH with Upload-Offset, so a 2xx without a
362
+ // readable one is not Forge (a captive portal's 200 page is the
363
+ // classic case): report no advance and let the stall guard fail the
364
+ // attempt, rather than crediting bytes that may never have arrived.
365
+ // parseInt would also turn a garbage header into NaN, which slips
366
+ // through that guard (every comparison with NaN is false) and ends
367
+ // the upload loop as a success.
368
+ const newOffset = parseIntegerHeader(xhr.getResponseHeader('Upload-Offset'), 0) ?? offset;
175
369
  finish(() => resolve(newOffset));
176
370
  }
177
371
  else {
@@ -188,18 +382,147 @@ function uploadChunk(uploadUrl, istk, chunk, offset, timeoutMs, signal, onChunkP
188
382
  };
189
383
  xhr.onerror = () => finish(() => reject(new TusUploadError('Chunk upload network error', { phase: 'network' })));
190
384
  xhr.onabort = () => finish(() => reject(new TusUploadError('Chunk upload aborted', { phase: 'cancelled' })));
191
- xhr.ontimeout = () => finish(() => reject(new TusUploadError(`Chunk upload timeout after ${timeoutMs / 1000}s`, { phase: 'timeout' })));
385
+ xhr.ontimeout = () => finish(() => reject(new TusUploadError('Chunk upload timed out', { phase: 'timeout' })));
192
386
  xhr.open('PATCH', uploadUrl);
193
387
  xhr.setRequestHeader('Tus-Resumable', TUS_VERSION);
194
388
  xhr.setRequestHeader('Upload-Offset', String(offset));
195
389
  xhr.setRequestHeader('Content-Type', 'application/offset+octet-stream');
196
390
  xhr.setRequestHeader('X-ISTK', istk);
197
- xhr.timeout = timeoutMs;
391
+ // No absolute XHR deadline (see the function comment); the watchdog below
392
+ // is armed before send and re-armed on every progress event.
393
+ armWatchdog();
394
+ cap = setTimeout(() => stall(`Chunk upload exceeded ${CHUNK_ABSOLUTE_CAP_MS / 60000} minutes`), CHUNK_ABSOLUTE_CAP_MS);
198
395
  // React Native-only body shape (see TusChunk): the native layer decodes
199
396
  // `base64` itself, so the chunk bytes never touch the JS thread.
200
397
  xhr.send({ base64: chunk.base64 });
201
398
  });
202
399
  }
400
+ /**
401
+ * PATCH a whole file as the session's only chunk on the app's background
402
+ * URLSession. The OS owns the transfer, so it finishes even if iOS suspends
403
+ * the app; the trade is no progress events and no abort. The result is mapped
404
+ * exactly like the XHR path so callers see one error taxonomy.
405
+ */
406
+ async function uploadWholeFileInBackground(uploader, uploadUrl, istk, fileUri, offset) {
407
+ let result;
408
+ try {
409
+ result = await uploader.uploadFile(uploadUrl, fileUri, {
410
+ method: 'PATCH',
411
+ headers: {
412
+ 'Tus-Resumable': TUS_VERSION,
413
+ 'Upload-Offset': String(offset),
414
+ 'Content-Type': 'application/offset+octet-stream',
415
+ 'X-ISTK': istk,
416
+ },
417
+ });
418
+ }
419
+ catch (err) {
420
+ // The adapter only throws when no HTTP response came back (offline, DNS,
421
+ // the OS cancelled the task); an HTTP failure is a status below.
422
+ throw new TusUploadError(`Background chunk upload failed: ${getErrorMessage(err)}`, {
423
+ cause: err instanceof Error ? err : null,
424
+ phase: 'network',
425
+ });
426
+ }
427
+ const body = typeof result.body === 'string' ? result.body : '';
428
+ if (result.status >= 200 && result.status < 300) {
429
+ const offsetHeader = getHeaderIgnoreCase(result.headers, 'Upload-Offset');
430
+ // Forge always answers a PATCH with Upload-Offset. A 2xx without it is not
431
+ // Forge (a captive portal's 200 page is the classic case), so report no
432
+ // advance: the stall guard fails the attempt and the next one asks Forge
433
+ // (HEAD) instead of marking bytes uploaded that may never have arrived.
434
+ return offsetHeader ? parseInt(offsetHeader, 10) : offset;
435
+ }
436
+ const gateError = gateErrorFromBody(result.status, parseErrorBody(body));
437
+ if (gateError) {
438
+ throw gateError;
439
+ }
440
+ throw new TusUploadError(`Chunk upload failed: ${result.status} - ${body.substring(0, 200)}`, {
441
+ httpStatus: result.status,
442
+ phase: 'upload',
443
+ });
444
+ }
445
+ /**
446
+ * Translate what the OS transfer adapter reports into the SDK's one error
447
+ * taxonomy, so a caller sees the same `TusUploadError` phases and typed gate
448
+ * errors whichever engine carried the bytes. Forge's status and body ride
449
+ * through untouched: a 402 becomes the gate error the XHR path throws, a
450
+ * 404/410 marks the session lost exactly as HEAD would, and any other status
451
+ * keeps its number for the caller's own policy (a 409 during finalize is a
452
+ * retry-later, not a lost session). The remaining kinds are transport
453
+ * outcomes: the caller's own abort is `cancelled`; an OS-ended task
454
+ * (`interrupted`) is a network failure, because the right response is the
455
+ * same as for a dropped connection: resume from the server offset. Anything
456
+ * that is not a `BackgroundTransferError` is an adapter defect and is
457
+ * reported as a network failure with the cause attached, never swallowed.
458
+ */
459
+ export function mapBackgroundTransferError(err, context = {}) {
460
+ if (!(err instanceof BackgroundTransferError)) {
461
+ return new TusUploadError(`Background transfer failed: ${getErrorMessage(err)}`, {
462
+ cause: err instanceof Error ? err : null,
463
+ ...context,
464
+ phase: 'network',
465
+ });
466
+ }
467
+ switch (err.kind) {
468
+ case 'http': {
469
+ const status = err.httpStatus;
470
+ const body = err.body ?? '';
471
+ if (status !== undefined) {
472
+ const gateError = gateErrorFromBody(status, parseErrorBody(body));
473
+ if (gateError)
474
+ return gateError;
475
+ }
476
+ const sessionLost = status === 404 || status === 410;
477
+ return new TusUploadError(sessionLost
478
+ ? `TUS session no longer exists on the server (${status})`
479
+ : `Background chunk upload failed: ${status ?? 'unknown status'} - ${body.substring(0, 200)}`, { cause: err, httpStatus: status ?? null, ...context, phase: 'upload', sessionLost });
480
+ }
481
+ case 'cancelled':
482
+ return new TusUploadError('Upload cancelled', { cause: err, ...context, phase: 'cancelled' });
483
+ case 'stalled':
484
+ return new TusUploadError(`Upload stalled: ${err.message}`, { cause: err, ...context, phase: 'stalled' });
485
+ case 'file':
486
+ return new TusUploadError(`Background transfer could not read the file: ${err.message}`, {
487
+ cause: err,
488
+ ...context,
489
+ phase: 'upload',
490
+ });
491
+ case 'interrupted':
492
+ return new TusUploadError(`Background transfer interrupted: ${err.message}`, {
493
+ cause: err,
494
+ ...context,
495
+ phase: 'network',
496
+ });
497
+ case 'network':
498
+ return new TusUploadError(`Background transfer network error: ${err.message}`, {
499
+ cause: err,
500
+ ...context,
501
+ phase: 'network',
502
+ });
503
+ }
504
+ }
505
+ /**
506
+ * Hand the whole remaining range of a session to the OS transfer engine and
507
+ * wait for its verdict. The engine chains every chunk itself, so this is one
508
+ * await for the entire file however large it is, and the transfer keeps
509
+ * going while the app is suspended or killed (the adapter re-attaches to
510
+ * the job by session URL). Progress arrives as absolute server offsets and
511
+ * is forwarded as (uploaded, total) like every other path; failures are
512
+ * mapped onto the XHR taxonomy so callers see one set of errors.
513
+ */
514
+ async function transferRemainderInBackground(engine, job, options) {
515
+ try {
516
+ const result = await engine.transfer(job, {
517
+ abortSignal: options.abortSignal,
518
+ onProgress: offset => options.onProgress?.(offset, job.fileSize),
519
+ });
520
+ return result.offset;
521
+ }
522
+ catch (err) {
523
+ throw mapBackgroundTransferError(err, { filename: options.filename, fileSize: job.fileSize });
524
+ }
525
+ }
203
526
  export class MobileTusUploader {
204
527
  constructor(config) {
205
528
  this.config = config;
@@ -209,22 +532,115 @@ export class MobileTusUploader {
209
532
  if (!fileReader) {
210
533
  throw new SparkVaultValidationError('fileReader adapter is required for URI uploads');
211
534
  }
212
- const parsed = parseForgeUrl(options.forgeUrl);
535
+ const source = resolveSessionSource(options);
536
+ const istk = source.mode === 'create' ? source.parsed.istk : source.istk;
213
537
  const debug = options.debug;
214
538
  const cleanUri = options.fileUri.split('#')[0];
215
539
  let bytesUploaded = 0;
216
- let uploadUrl = null;
540
+ // Known up front on resume so a cancel during the HEAD still terminates
541
+ // the (existing) session.
542
+ let uploadUrl = source.mode === 'resume' ? source.uploadUrl : null;
217
543
  try {
218
- debug?.log(`Starting TUS upload for ${options.filename}`);
219
- const created = await createTusUpload(this.config, parsed, options.fileSize, options.filename, options.contentType, options.abortSignal);
220
- uploadUrl = created.uploadUrl;
221
- const chunkSize = created.chunkSize;
544
+ let chunkSize;
545
+ let sessionUrl;
546
+ if (source.mode === 'resume') {
547
+ sessionUrl = source.uploadUrl;
548
+ debug?.log(`Resuming TUS upload for ${options.filename}`);
549
+ const probe = await headTusSession(this.config, source.uploadUrl, istk, options.abortSignal, {
550
+ filename: options.filename,
551
+ fileSize: options.fileSize,
552
+ });
553
+ // A session is bound to one file: a different length means the
554
+ // persisted session belongs to another file (or this file changed),
555
+ // and PATCHing into it would corrupt the ingot. That makes the
556
+ // session worthless for this file, which is exactly what
557
+ // `sessionLost` means: the caller drops it and starts fresh, the same
558
+ // as for a 404/410, instead of retrying a resume that can never work.
559
+ if (probe.length !== options.fileSize) {
560
+ throw new TusUploadError(`TUS session Upload-Length (${probe.length}) does not match the file size (${options.fileSize}); the session belongs to a different file`, { filename: options.filename, fileSize: options.fileSize, phase: 'create', sessionLost: true });
561
+ }
562
+ chunkSize = probe.chunkSize;
563
+ bytesUploaded = probe.offset;
564
+ debug?.log(`TUS session for ${options.filename} is at offset ${bytesUploaded}/${options.fileSize}`);
565
+ if (bytesUploaded >= options.fileSize) {
566
+ // Every byte is there, but the finalization the last chunk should
567
+ // have triggered may not have happened (that is often why the
568
+ // previous attempt failed): ask Forge to finalize and acknowledge.
569
+ await confirmTusFinalization(this.config, sessionUrl, istk, options.fileSize, options.abortSignal, {
570
+ filename: options.filename,
571
+ fileSize: options.fileSize,
572
+ });
573
+ options.onProgress?.(bytesUploaded, options.fileSize);
574
+ }
575
+ }
576
+ else {
577
+ debug?.log(`Starting TUS upload for ${options.filename}`);
578
+ const created = await createTusUpload(this.config, source.parsed, options.fileSize, options.filename, options.contentType, options.abortSignal);
579
+ sessionUrl = created.uploadUrl;
580
+ uploadUrl = sessionUrl;
581
+ chunkSize = created.chunkSize;
582
+ // Before the first PATCH: once bytes are in flight the session is
583
+ // worth resuming, so the caller must already hold it.
584
+ options.onSessionCreated?.({ uploadUrl: sessionUrl, istk, chunkSize });
585
+ }
586
+ // The OS transfer engine, when the app provides one, takes the whole
587
+ // remaining range in a single hand-off: it chains the chunks itself and
588
+ // keeps going while the app is suspended or killed, which no JS loop
589
+ // can. Any size and any offset qualify, so a resumed multi-GB video
590
+ // rides it too; the single-chunk adapter and XHR in the loop below are
591
+ // the fallbacks. Start-or-attach on the session URL means a relaunch
592
+ // that resumes the same session re-joins the job the OS is still
593
+ // running instead of PATCHing over it.
594
+ const backgroundTransfer = options.transport === 'background' ? this.config.backgroundTransfer : undefined;
595
+ if (backgroundTransfer && bytesUploaded < options.fileSize) {
596
+ ensureNotAborted(options.abortSignal);
597
+ const policy = options.transferPolicy ?? {};
598
+ const finalOffset = await transferRemainderInBackground(backgroundTransfer, {
599
+ uploadUrl: sessionUrl,
600
+ istk,
601
+ fileUri: cleanUri,
602
+ fileSize: options.fileSize,
603
+ chunkSize,
604
+ offset: bytesUploaded,
605
+ allowsCellularAccess: policy.allowsCellularAccess ?? true,
606
+ allowsConstrainedNetworkAccess: policy.allowsConstrainedNetworkAccess ?? true,
607
+ }, { filename: options.filename, abortSignal: options.abortSignal, onProgress: options.onProgress });
608
+ // The engine only resolves at the end of the file; anything else is
609
+ // an engine that gave up quietly, and an offset past the file is as
610
+ // untrustworthy as one short of it. Same treatment as a non-advancing
611
+ // PATCH: fail, and let the next attempt ask Forge (HEAD) where the
612
+ // session really stands.
613
+ if (finalOffset !== options.fileSize) {
614
+ throw new TusUploadError(`Upload stalled: background transfer ended at offset ${finalOffset} of ${options.fileSize}`, { phase: 'stalled', filename: options.filename, fileSize: options.fileSize });
615
+ }
616
+ // An abort that lands after the OS delivered the last byte changes
617
+ // nothing: the transfer is complete, and the loop below (with its
618
+ // abort check) has nothing left to do. Same rule as the single-chunk
619
+ // adapter: a finished upload is never reported as cancelled.
620
+ bytesUploaded = finalOffset;
621
+ options.onProgress?.(bytesUploaded, options.fileSize);
622
+ }
222
623
  while (bytesUploaded < options.fileSize) {
223
624
  ensureNotAborted(options.abortSignal);
224
- const chunkLength = Math.min(chunkSize, options.fileSize - bytesUploaded);
225
- const chunk = await this.readChunk(fileReader, cleanUri, bytesUploaded, chunkLength);
226
625
  const chunkStart = bytesUploaded;
227
- const newOffset = await uploadChunk(uploadUrl, parsed.istk, chunk, chunkStart, this.config.tusChunkTimeoutMs, options.abortSignal, chunkUploaded => options.onProgress?.(chunkStart + chunkUploaded, options.fileSize));
626
+ const chunkLength = Math.min(chunkSize, options.fileSize - chunkStart);
627
+ let newOffset;
628
+ const backgroundUploader = options.transport === 'background' ? this.config.backgroundUploader : undefined;
629
+ if (backgroundUploader && this.fitsOneBackgroundRequest(chunkStart, options.fileSize, chunkSize)) {
630
+ // The OS owns this request, so an abort that fires while it is in
631
+ // flight cannot stop it. It only ever carries a whole single-chunk
632
+ // file, so a 2xx here means the transfer is complete: the loop
633
+ // ends and the upload finishes normally (the caller verifies the
634
+ // ingot), because a finished upload must never be reported as
635
+ // cancelled, and a DELETE now would drop bytes Forge has already
636
+ // finalized. Only a response that leaves bytes missing reaches
637
+ // the `ensureNotAborted` at the top of the next iteration.
638
+ newOffset = await uploadWholeFileInBackground(backgroundUploader, sessionUrl, istk, cleanUri, chunkStart);
639
+ }
640
+ else {
641
+ const chunk = await this.readChunk(fileReader, cleanUri, chunkStart, chunkLength);
642
+ newOffset = await uploadChunk(sessionUrl, istk, chunk, chunkStart, this.config.tusChunkTimeoutMs, options.abortSignal, chunkUploaded => options.onProgress?.(chunkStart + chunkUploaded, options.fileSize));
643
+ }
228
644
  if (newOffset <= chunkStart || newOffset > options.fileSize) {
229
645
  throw new TusUploadError('Upload stalled: server offset did not advance', {
230
646
  phase: 'stalled',
@@ -244,10 +660,17 @@ export class MobileTusUploader {
244
660
  // what lets Forge remove already-written chunk objects (otherwise they
245
661
  // are orphaned in S3 — nothing else cleans them up) and settle
246
662
  // partial-transfer billing, matching the web client's abort semantics.
663
+ // Unless the caller asked to keep it: a pause is not a cancel, and the
664
+ // bytes Forge holds are exactly what a later resume avoids re-sending.
247
665
  const cancelled = options.abortSignal?.aborted === true ||
248
666
  (err instanceof TusUploadError && err.phase === 'cancelled');
249
667
  if (cancelled && uploadUrl) {
250
- this.terminateUpload(uploadUrl, parsed.istk, debug);
668
+ if (options.abortBehavior === 'keep') {
669
+ debug?.log(`TUS session for ${options.filename} kept on Forge for resume`);
670
+ }
671
+ else {
672
+ this.terminateUpload(uploadUrl, istk, debug);
673
+ }
251
674
  }
252
675
  if (err instanceof TusUploadError)
253
676
  throw err;
@@ -263,6 +686,73 @@ export class MobileTusUploader {
263
686
  });
264
687
  }
265
688
  }
689
+ /**
690
+ * Where a persisted session stands, or `null` when Forge no longer has it
691
+ * (404/410 — expired or terminated). Any other failure throws with
692
+ * `sessionLost` false (a 5xx or timeout says nothing about the session).
693
+ * The probe reports the session's own length; whether that length is the
694
+ * caller's file is for the caller (or `uploadFromUri`'s resume, which
695
+ * throws `sessionLost` on a mismatch) to judge. Lets a caller reconcile
696
+ * sessions on relaunch (a completed one just needs its ingot verified; a
697
+ * lost one needs a fresh create) before spending an upload slot on it.
698
+ */
699
+ async probeSession(session) {
700
+ try {
701
+ return await headTusSession(this.config, session.uploadUrl, session.istk);
702
+ }
703
+ catch (err) {
704
+ if (err instanceof TusUploadError && err.sessionLost)
705
+ return null;
706
+ throw err;
707
+ }
708
+ }
709
+ /**
710
+ * Whether the remaining transfer is ONE PATCH the background adapter can
711
+ * send: nothing uploaded yet and the whole file inside a single chunk. The
712
+ * adapter sends whole files and Forge rejects any non-final chunk that is
713
+ * not exactly `X-Chunk-Size`, so a multi-chunk file has no legal single
714
+ * request, and a partially uploaded session would resend bytes Forge holds.
715
+ * A zero-byte file stays on the XHR path (it sends no PATCH at all there).
716
+ */
717
+ fitsOneBackgroundRequest(offset, fileSize, chunkSize) {
718
+ return offset === 0 && fileSize > 0 && fileSize <= chunkSize;
719
+ }
720
+ /**
721
+ * Release a session the caller kept on Forge (`abortBehavior: 'keep'`)
722
+ * and will not resume after all: the tus DELETE lets Forge drop the chunk
723
+ * objects it holds (nothing else cleans them up before expiry) and settle
724
+ * the partial transfer's billing. Resolves once the session is gone,
725
+ * which includes Forge answering 404/410 (already expired or terminated);
726
+ * any other failure rejects with a `TusUploadError` (a timeout, or the
727
+ * status Forge sent) so the caller can retry later. Not bound to any
728
+ * abort signal: a terminate is the last thing a caller does with a
729
+ * session, never something to cancel.
730
+ */
731
+ async terminateSession(session) {
732
+ // A terminate is the one place the SDK knows the OS job must die with
733
+ // the session: a DELETE under a job the engine is still chaining would
734
+ // leave it PATCHing a gone session until it fails on its own.
735
+ const backgroundTransfer = this.config.backgroundTransfer;
736
+ if (backgroundTransfer) {
737
+ await this.cancelBackgroundTransfer(backgroundTransfer, session.uploadUrl);
738
+ }
739
+ const response = await this.requestTerminate(session.uploadUrl, session.istk).catch(err => {
740
+ if (getErrorName(err) === 'AbortError') {
741
+ throw new TusUploadError('TUS DELETE request timed out', {
742
+ cause: err instanceof Error ? err : null,
743
+ phase: 'timeout',
744
+ });
745
+ }
746
+ throw err;
747
+ });
748
+ if (response.ok || response.status === 404 || response.status === 410)
749
+ return;
750
+ const errorText = await response.text().catch(() => '');
751
+ throw new TusUploadError(`TUS terminate failed: ${response.status} - ${errorText.substring(0, 200)}`, {
752
+ httpStatus: response.status,
753
+ phase: 'unknown',
754
+ });
755
+ }
266
756
  /**
267
757
  * Best-effort tus termination after a cancelled upload. Fire-and-forget so
268
758
  * cancel UX stays instant and the cancellation error still propagates — a
@@ -271,17 +761,48 @@ export class MobileTusUploader {
271
761
  * which would kill the DELETE before it left the device.
272
762
  */
273
763
  terminateUpload(uploadUrl, istk, debug) {
274
- void this.config
275
- .fetch(uploadUrl, {
764
+ // The OS job, when there is one, is cancelled before the DELETE so the
765
+ // engine stops PATCHing a session Forge is about to forget (see
766
+ // terminateSession); without an engine the DELETE leaves the device
767
+ // synchronously, exactly as it always has.
768
+ const backgroundTransfer = this.config.backgroundTransfer;
769
+ const terminate = backgroundTransfer
770
+ ? this.cancelBackgroundTransfer(backgroundTransfer, uploadUrl, debug).then(() => this.requestTerminate(uploadUrl, istk))
771
+ : this.requestTerminate(uploadUrl, istk);
772
+ void terminate.catch(err => {
773
+ debug?.log(`TUS termination after cancel failed: ${getErrorMessage(err)}`);
774
+ });
775
+ }
776
+ /**
777
+ * Ask the OS transfer engine to drop the job for a session about to be
778
+ * terminated. Best-effort and never throwing: a job the engine no longer
779
+ * has is a no-op by contract, and a failure here must not stop the DELETE
780
+ * that follows, which is what actually frees Forge's chunks.
781
+ */
782
+ async cancelBackgroundTransfer(engine, uploadUrl, debug) {
783
+ try {
784
+ await engine.cancel(uploadUrl);
785
+ }
786
+ catch (err) {
787
+ const message = `Background transfer cancel before terminate failed: ${getErrorMessage(err)}`;
788
+ debug?.log(message);
789
+ this.config.logger.debug(message, { uploadUrl });
790
+ }
791
+ }
792
+ /**
793
+ * The one tus DELETE request builder, shared by the fire-and-forget cancel
794
+ * path and the awaited `terminateSession` so both send exactly the same
795
+ * request: same headers, same timeout, and no abort signal (see the
796
+ * callers for why neither may carry one).
797
+ */
798
+ requestTerminate(uploadUrl, istk) {
799
+ return this.config.fetch(uploadUrl, {
276
800
  method: 'DELETE',
277
801
  headers: {
278
802
  'Tus-Resumable': TUS_VERSION,
279
803
  'X-ISTK': istk,
280
804
  },
281
805
  timeoutMs: this.config.tusPostTimeoutMs,
282
- })
283
- .catch(err => {
284
- debug?.log(`TUS termination after cancel failed: ${getErrorMessage(err)}`);
285
806
  });
286
807
  }
287
808
  async readChunk(fileReader, fileUri, position, length) {