@sparkvault/sdk-mobile 5.3.0 → 5.3.1

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
@@ -149,17 +149,6 @@ function getErrorMessage(err: unknown): string {
149
149
  return err instanceof Error ? err.message : String(err);
150
150
  }
151
151
 
152
- function inferTusPhase(error: Error): TusUploadError['phase'] {
153
- const message = error.message;
154
- if (message.includes('cancelled') || message.includes('aborted')) return 'cancelled';
155
- if (message.includes('timeout') || message.includes('timed out')) return 'timeout';
156
- if (message.includes('stall')) return 'stalled';
157
- if (message.includes('network')) return 'network';
158
- if (message.includes('create') || message.includes('POST')) return 'create';
159
- if (message.includes('upload') || message.includes('PATCH') || message.includes('Chunk')) return 'upload';
160
- return 'unknown';
161
- }
162
-
163
152
  function ensureNotAborted(signal?: AbortSignal): void {
164
153
  if (signal?.aborted) {
165
154
  throw new TusUploadError('Upload cancelled', { phase: 'cancelled' });
@@ -194,6 +183,87 @@ function parseIntegerHeader(raw: string | null, minimum: number): number | null
194
183
  return Number.isSafeInteger(value) && value >= minimum ? value : null;
195
184
  }
196
185
 
186
+ /** File context carried on every `TusUploadError` a request throws. */
187
+ interface TusErrorContext {
188
+ filename?: string;
189
+ fileSize?: number;
190
+ }
191
+
192
+ /**
193
+ * Forge's answers that mean the tus session no longer exists: it expired,
194
+ * or a DELETE terminated it. One list, so every request that can meet a
195
+ * dead session (HEAD, the finalization PATCH, the OS engine's PATCHes,
196
+ * DELETE) and the app's own session bookkeeping agree on what "gone" is.
197
+ */
198
+ const SESSION_GONE_STATUSES: ReadonlySet<number> = new Set([404, 410]);
199
+
200
+ /**
201
+ * True when a Forge status says the tus session is gone (expired or
202
+ * terminated): the persisted session is worthless, and a caller starts a
203
+ * fresh upload instead of retrying it.
204
+ */
205
+ export function isSessionGone(status: number): boolean {
206
+ return SESSION_GONE_STATUSES.has(status);
207
+ }
208
+
209
+ function sessionLostError(
210
+ status: number,
211
+ context: TusErrorContext,
212
+ phase: TusUploadError['phase'],
213
+ cause: Error | null = null
214
+ ): TusUploadError {
215
+ return new TusUploadError(`TUS session no longer exists on the server (${status})`, {
216
+ cause,
217
+ httpStatus: status,
218
+ ...context,
219
+ phase,
220
+ sessionLost: true,
221
+ });
222
+ }
223
+
224
+ interface TusRequestInit {
225
+ method: 'POST' | 'HEAD' | 'PATCH' | 'DELETE';
226
+ headers: Record<string, string>;
227
+ signal?: AbortSignal;
228
+ timeoutMs: number;
229
+ }
230
+
231
+ /**
232
+ * The one seam every tus request sent through `config.fetch` (the create
233
+ * POST, HEAD, the finalization PATCH, DELETE) passes its rejection through,
234
+ * so a request that never got an HTTP answer is classified once and the
235
+ * same way everywhere. The caller's own abort is `cancelled` and the request
236
+ * timeout is `timeout`: both arrive as an `AbortError` and the signal tells
237
+ * them apart, and an XHR adapter's `TimeoutError` is a timeout too (the
238
+ * same contract http.ts honours). An SDK error passes through untouched.
239
+ * Everything else is `network` (an offline device, a DNS failure, a reset
240
+ * connection), so `isNetworkError` holds and the caller retries instead of
241
+ * charging the failure to the file. Chunk PATCHes ride XHR directly and
242
+ * classify their own events (see uploadChunk).
243
+ */
244
+ async function tusRequest(
245
+ config: ResolvedMobileConfig,
246
+ url: string,
247
+ init: TusRequestInit,
248
+ label: string,
249
+ context: TusErrorContext = {}
250
+ ): Promise<Response> {
251
+ try {
252
+ return await config.fetch(url, init);
253
+ } catch (err) {
254
+ if (err instanceof SparkVaultMobileError) throw err;
255
+ const cause = err instanceof Error ? err : null;
256
+ const name = getErrorName(err);
257
+ if (name === 'AbortError' && init.signal?.aborted) {
258
+ throw new TusUploadError('Upload cancelled', { cause, ...context, phase: 'cancelled' });
259
+ }
260
+ if (name === 'AbortError' || name === 'TimeoutError') {
261
+ throw new TusUploadError(`${label} timed out`, { cause, ...context, phase: 'timeout' });
262
+ }
263
+ throw new TusUploadError(`${label} failed: ${getErrorMessage(err)}`, { cause, ...context, phase: 'network' });
264
+ }
265
+ }
266
+
197
267
  /**
198
268
  * Case-insensitive header lookup for adapter results. Native HTTP stacks
199
269
  * hand headers back in whatever case they please (iOS lower-cases them),
@@ -242,27 +312,23 @@ async function createTusUpload(
242
312
  ensureNotAborted(signal);
243
313
 
244
314
  const metadata = `filename ${base64EncodeUtf8(filename)},filetype ${base64EncodeUtf8(contentType || 'application/octet-stream')}`;
245
- const response = await config.fetch(parsed.tusEndpoint, {
246
- method: 'POST',
247
- headers: {
248
- 'Tus-Resumable': TUS_VERSION,
249
- 'Upload-Length': String(fileSize),
250
- 'Upload-Metadata': metadata,
251
- 'X-ISTK': parsed.istk,
315
+ const response = await tusRequest(
316
+ config,
317
+ parsed.tusEndpoint,
318
+ {
319
+ method: 'POST',
320
+ headers: {
321
+ 'Tus-Resumable': TUS_VERSION,
322
+ 'Upload-Length': String(fileSize),
323
+ 'Upload-Metadata': metadata,
324
+ 'X-ISTK': parsed.istk,
325
+ },
326
+ signal,
327
+ timeoutMs: config.tusPostTimeoutMs,
252
328
  },
253
- signal,
254
- timeoutMs: config.tusPostTimeoutMs,
255
- }).catch(err => {
256
- if (getErrorName(err) === 'AbortError') {
257
- throw new TusUploadError('TUS POST request timed out', {
258
- cause: err instanceof Error ? err : null,
259
- filename,
260
- fileSize,
261
- phase: 'timeout',
262
- });
263
- }
264
- throw err;
265
- });
329
+ 'TUS POST request',
330
+ { filename, fileSize }
331
+ );
266
332
 
267
333
  if (!response.ok) {
268
334
  const errorText = await response.text().catch(() => '');
@@ -315,10 +381,11 @@ async function createTusUpload(
315
381
  * Ask Forge where a session stands (tus HEAD). This is the only way to learn
316
382
  * what a background URLSession delivered after the process was killed, and
317
383
  * 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
384
+ * server holds. A status `isSessionGone` recognises means the session
385
+ * expired or was terminated: the error carries `sessionLost` so the caller
386
+ * drops its persisted session and creates a fresh one rather than retrying
387
+ * a dead URL. Any other failure (a 5xx, an auth rejection, a timeout)
388
+ * leaves `sessionLost` false: the
322
389
  * session may well be intact, and treating it as dead would re-upload a
323
390
  * whole file over a blip.
324
391
  */
@@ -327,38 +394,28 @@ async function headTusSession(
327
394
  uploadUrl: string,
328
395
  istk: string,
329
396
  signal?: AbortSignal,
330
- context: { filename?: string; fileSize?: number } = {}
397
+ context: TusErrorContext = {}
331
398
  ): Promise<TusSessionProbe> {
332
399
  ensureNotAborted(signal);
333
400
 
334
- const response = await config.fetch(uploadUrl, {
335
- method: 'HEAD',
336
- headers: {
337
- 'Tus-Resumable': TUS_VERSION,
338
- 'X-ISTK': istk,
401
+ const response = await tusRequest(
402
+ config,
403
+ uploadUrl,
404
+ {
405
+ method: 'HEAD',
406
+ headers: {
407
+ 'Tus-Resumable': TUS_VERSION,
408
+ 'X-ISTK': istk,
409
+ },
410
+ signal,
411
+ timeoutMs: config.tusPostTimeoutMs,
339
412
  },
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
- });
413
+ 'TUS HEAD request',
414
+ context
415
+ );
354
416
 
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
- });
417
+ if (isSessionGone(response.status)) {
418
+ throw sessionLostError(response.status, context, 'create');
362
419
  }
363
420
 
364
421
  if (!response.ok) {
@@ -397,7 +454,8 @@ async function headTusSession(
397
454
  * to the status poll and could report a never-activated ingot as done.
398
455
  *
399
456
  * 409 means another finalizer holds the lease right now: retry later, the
400
- * session is intact. 404/410 mean the session is gone (`sessionLost`).
457
+ * session is intact. A status `isSessionGone` recognises means the session
458
+ * is gone (`sessionLost`).
401
459
  */
402
460
  async function confirmTusFinalization(
403
461
  config: ResolvedMobileConfig,
@@ -405,38 +463,30 @@ async function confirmTusFinalization(
405
463
  istk: string,
406
464
  fileSize: number,
407
465
  signal?: AbortSignal,
408
- context: { filename?: string; fileSize?: number } = {}
466
+ context: TusErrorContext = {}
409
467
  ): Promise<void> {
410
468
  ensureNotAborted(signal);
411
469
 
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,
470
+ const response = await tusRequest(
471
+ config,
472
+ uploadUrl,
473
+ {
474
+ method: 'PATCH',
475
+ headers: {
476
+ 'Tus-Resumable': TUS_VERSION,
477
+ 'Upload-Offset': String(fileSize),
478
+ 'Content-Type': 'application/offset+octet-stream',
479
+ 'X-ISTK': istk,
480
+ },
481
+ signal,
482
+ timeoutMs: config.tusChunkTimeoutMs,
419
483
  },
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
- });
484
+ 'TUS finalization request',
485
+ context
486
+ );
432
487
 
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
- });
488
+ if (isSessionGone(response.status)) {
489
+ throw sessionLostError(response.status, context, 'create');
440
490
  }
441
491
  if (response.status === 409) {
442
492
  throw new TusUploadError('TUS finalization is already in progress on the server; retry shortly', {
@@ -620,12 +670,12 @@ async function uploadWholeFileInBackground(
620
670
 
621
671
  const body = typeof result.body === 'string' ? result.body : '';
622
672
  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;
673
+ // Forge always answers a PATCH with a readable Upload-Offset. A 2xx
674
+ // without one is not Forge (a captive portal's 200 page is the classic
675
+ // case), so report no advance: the stall guard fails the attempt and the
676
+ // next one asks Forge (HEAD) instead of marking bytes uploaded that may
677
+ // never have arrived. Same strict parse as the XHR path (uploadChunk).
678
+ return parseIntegerHeader(getHeaderIgnoreCase(result.headers, 'Upload-Offset'), 0) ?? offset;
629
679
  }
630
680
 
631
681
  const gateError = gateErrorFromBody(result.status, parseErrorBody(body));
@@ -643,9 +693,10 @@ async function uploadWholeFileInBackground(
643
693
  * taxonomy, so a caller sees the same `TusUploadError` phases and typed gate
644
694
  * errors whichever engine carried the bytes. Forge's status and body ride
645
695
  * 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
696
+ * status `isSessionGone` recognises marks the session lost exactly as HEAD
697
+ * would, and any other status keeps its number for the caller's own policy
698
+ * (a 409 during finalize is a retry-later, not a lost session). The
699
+ * remaining kinds are transport
649
700
  * outcomes: the caller's own abort is `cancelled`; an OS-ended task
650
701
  * (`interrupted`) is a network failure, because the right response is the
651
702
  * same as for a dropped connection: resume from the server offset. Anything
@@ -654,7 +705,7 @@ async function uploadWholeFileInBackground(
654
705
  */
655
706
  export function mapBackgroundTransferError(
656
707
  err: unknown,
657
- context: { filename?: string; fileSize?: number } = {}
708
+ context: TusErrorContext = {}
658
709
  ): SparkVaultMobileError {
659
710
  if (!(err instanceof BackgroundTransferError)) {
660
711
  return new TusUploadError(`Background transfer failed: ${getErrorMessage(err)}`, {
@@ -671,13 +722,11 @@ export function mapBackgroundTransferError(
671
722
  if (status !== undefined) {
672
723
  const gateError = gateErrorFromBody(status, parseErrorBody(body));
673
724
  if (gateError) return gateError;
725
+ if (isSessionGone(status)) return sessionLostError(status, context, 'upload', err);
674
726
  }
675
- const sessionLost = status === 404 || status === 410;
676
727
  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 }
728
+ `Background chunk upload failed: ${status ?? 'unknown status'} - ${body.substring(0, 200)}`,
729
+ { cause: err, httpStatus: status ?? null, ...context, phase: 'upload' }
681
730
  );
682
731
  }
683
732
  case 'cancelled':
@@ -919,10 +968,13 @@ export class MobileTusUploader {
919
968
  // the caller intact so the subscribe / add-on UX fires; never bury them
920
969
  // in a generic TusUploadError.
921
970
  if (err instanceof SparkVaultMobileError) throw err;
971
+ // Only a fileReader read or an onSessionCreated hook can throw anything
972
+ // else here: every request classifies its own failure (tusRequest,
973
+ // uploadChunk, the adapters). Those are the caller's errors, wrapped
974
+ // with the file context and nothing inferred about them.
922
975
  throw TusUploadError.fromError(error, {
923
976
  filename: options.filename,
924
977
  fileSize: options.fileSize,
925
- phase: inferTusPhase(error),
926
978
  });
927
979
  }
928
980
  }
@@ -963,9 +1015,10 @@ export class MobileTusUploader {
963
1015
  * and will not resume after all: the tus DELETE lets Forge drop the chunk
964
1016
  * objects it holds (nothing else cleans them up before expiry) and settle
965
1017
  * 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
1018
+ * which includes Forge answering a status `isSessionGone` recognises
1019
+ * (already expired or terminated); any other failure rejects with a
1020
+ * `TusUploadError` (a timeout, a connectivity failure, or the status
1021
+ * Forge sent) so the caller can retry later. Not bound to any
969
1022
  * abort signal: a terminate is the last thing a caller does with a
970
1023
  * session, never something to cancel.
971
1024
  */
@@ -978,17 +1031,9 @@ export class MobileTusUploader {
978
1031
  await this.cancelBackgroundTransfer(backgroundTransfer, session.uploadUrl);
979
1032
  }
980
1033
 
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
- });
1034
+ const response = await this.requestTerminate(session.uploadUrl, session.istk);
990
1035
 
991
- if (response.ok || response.status === 404 || response.status === 410) return;
1036
+ if (response.ok || isSessionGone(response.status)) return;
992
1037
 
993
1038
  const errorText = await response.text().catch(() => '');
994
1039
  throw new TusUploadError(`TUS terminate failed: ${response.status} - ${errorText.substring(0, 200)}`, {
@@ -1045,14 +1090,19 @@ export class MobileTusUploader {
1045
1090
  * callers for why neither may carry one).
1046
1091
  */
1047
1092
  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,
1093
+ return tusRequest(
1094
+ this.config,
1095
+ uploadUrl,
1096
+ {
1097
+ method: 'DELETE',
1098
+ headers: {
1099
+ 'Tus-Resumable': TUS_VERSION,
1100
+ 'X-ISTK': istk,
1101
+ },
1102
+ timeoutMs: this.config.tusPostTimeoutMs,
1053
1103
  },
1054
- timeoutMs: this.config.tusPostTimeoutMs,
1055
- });
1104
+ 'TUS DELETE request'
1105
+ );
1056
1106
  }
1057
1107
 
1058
1108
  private async readChunk(
package/src/types.ts CHANGED
@@ -603,7 +603,7 @@ export interface UploadResult {
603
603
  * moments later, but a caller recording "backed up" must not take that on
604
604
  * faith (resume the session later; Forge re-confirms finalization).
605
605
  */
606
- status?: string;
606
+ status?: Ingot['status'];
607
607
  }
608
608
 
609
609
  export interface IngotSharingConfig {
@@ -769,11 +769,13 @@ export interface BackgroundTransferJob {
769
769
  }
770
770
 
771
771
  /**
772
- * Why an OS transfer ended without reaching the final offset. `http` carries
773
- * Forge's status and body (the SDK maps gates and lost sessions from them);
774
- * `cancelled` is the caller's own abort; `interrupted` is the OS ending the
775
- * task without being asked (a relaunch, a system cancel); `stalled` is a 2xx
776
- * whose offset did not advance; `file` is a source the engine could not read.
772
+ * Why an OS transfer ended without reaching the final offset: the engine's
773
+ * own failure kinds plus `cancelled`, which only the app adapter produces
774
+ * for the caller's own abort (the engine never reports a cancel it was asked
775
+ * for). `http` carries Forge's status and body (the SDK maps gates and lost
776
+ * sessions from them); `interrupted` is the OS ending the task without being
777
+ * asked (a relaunch, a system cancel); `stalled` is a 2xx whose offset did
778
+ * not advance; `file` is a source the engine could not read.
777
779
  */
778
780
  export type BackgroundTransferFailureKind = 'network' | 'http' | 'file' | 'cancelled' | 'stalled' | 'interrupted';
779
781
 
@@ -842,6 +844,15 @@ export interface MobileFileInfo {
842
844
  sizeBytes?: number;
843
845
  }
844
846
 
847
+ /**
848
+ * App-provided file download adapter. The rejection contract is typed, not
849
+ * textual: a download the caller's `abortSignal` cut short rejects with an
850
+ * Error whose `name` is `'AbortError'` (the SDK reports it as cancelled and
851
+ * never retries it); a download that ran past `timeoutMs` rejects with a
852
+ * `SparkVaultTimeoutError` (retried like any connectivity failure). Any
853
+ * other rejection is a transport failure the SDK retries with a fresh
854
+ * signed URL.
855
+ */
845
856
  export interface MobileFileDownloader {
846
857
  download(
847
858
  url: string,