@sparkvault/sdk-mobile 5.2.1 → 5.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +20 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +56 -3
- package/dist/errors.js +56 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/ingots.d.ts +101 -1
- package/dist/ingots.js +83 -2
- package/dist/ingots.js.map +1 -1
- package/dist/notify.d.ts +17 -4
- package/dist/notify.js.map +1 -1
- package/dist/notify.test-d.d.ts +31 -0
- package/dist/notify.test-d.js +18 -0
- package/dist/notify.test-d.js.map +1 -0
- package/dist/tus.d.ts +132 -2
- package/dist/tus.js +546 -25
- package/dist/tus.js.map +1 -1
- package/dist/types.d.ts +124 -0
- package/package.json +1 -1
- package/src/config.ts +23 -0
- package/src/errors.ts +86 -3
- package/src/index.ts +6 -0
- package/src/ingots.ts +155 -2
- package/src/notify.test-d.ts +42 -0
- package/src/notify.ts +17 -4
- package/src/tus.ts +717 -48
- package/src/types.ts +128 -0
package/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
|
-
/**
|
|
70
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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) {
|