@sparkvault/sdk-mobile 7.0.0 → 7.0.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/dist/app-sdk/transfer-retry.d.ts +298 -0
- package/dist/app-sdk/transfer-retry.js +535 -0
- package/dist/app-sdk/transfer-retry.js.map +1 -0
- package/dist/app-sdk/upload-publication.d.ts +81 -0
- package/dist/app-sdk/upload-publication.js +93 -0
- package/dist/app-sdk/upload-publication.js.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +38 -4
- package/dist/errors.js +4 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/ingots.d.ts +30 -18
- package/dist/ingots.js +25 -19
- package/dist/ingots.js.map +1 -1
- package/dist/tus.d.ts +179 -14
- package/dist/tus.js +738 -300
- package/dist/tus.js.map +1 -1
- package/dist/types.d.ts +16 -8
- package/package.json +1 -1
- package/src/app-sdk/transfer-retry.js +563 -0
- package/src/app-sdk/upload-publication.js +102 -0
- package/src/config.ts +40 -0
- package/src/errors.ts +42 -5
- package/src/index.ts +2 -0
- package/src/ingots.ts +57 -26
- package/src/tus.ts +971 -378
- package/src/types.ts +16 -8
package/src/tus.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import type { ResolvedMobileConfig } from './config.js';
|
|
1
|
+
import type { MobileConnectivity, ResolvedMobileConfig } from './config.js';
|
|
2
2
|
import { FORGE_URL_REFUSAL, readForgeSessionUrl, readForgeTransferUrl, resolveForgeSessionUrl } from './app-sdk/forge-url.js';
|
|
3
3
|
import { SPARKVAULT_OWNED_HOST_PATTERNS } from './app-sdk/platform-domains.js';
|
|
4
|
+
import {
|
|
5
|
+
HEAD_AND_RECEIPT_TIMEOUT_MS,
|
|
6
|
+
TRANSFER_VERDICT,
|
|
7
|
+
UPLOAD_EXPIRES_HEADER,
|
|
8
|
+
classifyTransferResponse,
|
|
9
|
+
createTransferBudget,
|
|
10
|
+
parseUploadExpires,
|
|
11
|
+
resumeOffsetProblem,
|
|
12
|
+
retryDelayMs,
|
|
13
|
+
transferJitterKey,
|
|
14
|
+
} from './app-sdk/transfer-retry.js';
|
|
15
|
+
import type { TransferBudget, TransferOutcome } from './app-sdk/transfer-retry.js';
|
|
16
|
+
import { UPLOAD_REFUSAL_CODES } from './app-sdk/upload-publication.js';
|
|
4
17
|
import {
|
|
5
18
|
BackgroundTransferError,
|
|
6
19
|
gateErrorFromBody,
|
|
@@ -35,15 +48,23 @@ export interface ParsedForgeUrl {
|
|
|
35
48
|
*
|
|
36
49
|
* A resume that cannot use the session throws a `TusUploadError` whose
|
|
37
50
|
* `sessionLost` is true exactly when the session is worthless for this file
|
|
38
|
-
* and the caller should drop it and start fresh:
|
|
51
|
+
* and the caller should drop it and start fresh: the expiry the caller holds
|
|
52
|
+
* for it has passed (refused before any request), Forge no longer has it
|
|
39
53
|
* (404/410), or its `Upload-Length` is not this file's size (it belongs to a
|
|
40
|
-
* different file). A
|
|
41
|
-
* `sessionLost`; the session is intact and
|
|
54
|
+
* different file). A HEAD that keeps failing transiently (5xx, timeouts) past
|
|
55
|
+
* the upload's retry budget is NOT `sessionLost`; the session is intact and
|
|
56
|
+
* the same resume can be retried.
|
|
42
57
|
*/
|
|
43
58
|
export interface TusResumeSession {
|
|
44
59
|
uploadUrl: string;
|
|
45
60
|
/** The session's ISTK. Falls back to the one in `forgeUrl` when omitted. */
|
|
46
61
|
istk?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The session's expiry as the caller holds it (`expiresAt` from
|
|
64
|
+
* `onSessionCreated`, kept current through `onSessionExpiry`). Null or
|
|
65
|
+
* omitted is unknown, and Forge's HEAD decides.
|
|
66
|
+
*/
|
|
67
|
+
expiresAt?: number | null;
|
|
47
68
|
}
|
|
48
69
|
|
|
49
70
|
/** Where a tus session stands server-side, as reported by HEAD. */
|
|
@@ -51,6 +72,8 @@ export interface TusSessionProbe {
|
|
|
51
72
|
offset: number;
|
|
52
73
|
length: number;
|
|
53
74
|
chunkSize: number;
|
|
75
|
+
/** When the session lapses (epoch ms, Forge's `Upload-Expires`), or null when the HEAD named none. */
|
|
76
|
+
expiresAt: number | null;
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
export interface TusUploadFromUriOptions {
|
|
@@ -72,6 +95,17 @@ export interface TusUploadFromUriOptions {
|
|
|
72
95
|
* relaunch. Not invoked on resume. A throw here fails the upload.
|
|
73
96
|
*/
|
|
74
97
|
onSessionCreated?: (session: TusSessionInfo) => void;
|
|
98
|
+
/**
|
|
99
|
+
* Invoked whenever the session's expiry, as its latest answer named it,
|
|
100
|
+
* changes: Forge slides it forward with every committed chunk and names it
|
|
101
|
+
* on its answers (`Upload-Expires`). It is null (unknown) after an answer
|
|
102
|
+
* that named none, and from the hand-off of a request the OS carries on
|
|
103
|
+
* without JS (the transfer engine's range, the single-chunk background
|
|
104
|
+
* PATCH): either may have moved it unread. A caller that persists the
|
|
105
|
+
* session keeps its `expiresAt` current with this, so a session it judges
|
|
106
|
+
* expired (`isSessionExpired`) really is. A throw here fails the upload.
|
|
107
|
+
*/
|
|
108
|
+
onSessionExpiry?: (expiresAt: number | null) => void;
|
|
75
109
|
/**
|
|
76
110
|
* `background` rides the best suspension-proof engine the app provides:
|
|
77
111
|
* the OS transfer engine (`backgroundTransfer`) for the whole remaining
|
|
@@ -171,9 +205,12 @@ export function allowsHost(allowedHostPatterns: readonly RegExp[]): (hostname: s
|
|
|
171
205
|
}
|
|
172
206
|
|
|
173
207
|
/**
|
|
174
|
-
* Parse a Forge error response body (JSON text) into the shared
|
|
175
|
-
* shape
|
|
176
|
-
*
|
|
208
|
+
* Parse a Forge error response body (JSON text), whole, into the shared
|
|
209
|
+
* ApiErrorBody shape: the one reader of a failed answer's body, so gate
|
|
210
|
+
* mapping reads its `error.code` / `error.details.resource` and the transfer
|
|
211
|
+
* policy the refusal Core decided (`error.details.coreCode`) from the same
|
|
212
|
+
* parse. Returns an empty body on non-JSON text, which matches no gate and
|
|
213
|
+
* names no refusal.
|
|
177
214
|
*/
|
|
178
215
|
function parseErrorBody(text: string): ApiErrorBody {
|
|
179
216
|
if (!text) return {};
|
|
@@ -193,9 +230,14 @@ function getErrorMessage(err: unknown): string {
|
|
|
193
230
|
return err instanceof Error ? err.message : String(err);
|
|
194
231
|
}
|
|
195
232
|
|
|
233
|
+
/** What every path the caller's abort stops reports: the one cancellation the outer catch terminates or keeps by. */
|
|
234
|
+
function cancelledError(): TusUploadError {
|
|
235
|
+
return new TusUploadError('Upload cancelled', { phase: 'cancelled' });
|
|
236
|
+
}
|
|
237
|
+
|
|
196
238
|
function ensureNotAborted(signal?: AbortSignal): void {
|
|
197
239
|
if (signal?.aborted) {
|
|
198
|
-
throw
|
|
240
|
+
throw cancelledError();
|
|
199
241
|
}
|
|
200
242
|
}
|
|
201
243
|
|
|
@@ -233,28 +275,29 @@ interface TusErrorContext {
|
|
|
233
275
|
fileSize?: number;
|
|
234
276
|
}
|
|
235
277
|
|
|
236
|
-
/**
|
|
237
|
-
* Forge's answers that mean the tus session no longer exists: it expired,
|
|
238
|
-
* or a DELETE terminated it. One list, so every request that can meet a
|
|
239
|
-
* dead session (HEAD, the finalization PATCH, the OS engine's PATCHes,
|
|
240
|
-
* DELETE) and the app's own session bookkeeping agree on what "gone" is.
|
|
241
|
-
*/
|
|
242
|
-
const SESSION_GONE_STATUSES: ReadonlySet<number> = new Set([404, 410]);
|
|
243
|
-
|
|
244
278
|
/**
|
|
245
279
|
* True when a Forge status says the tus session is gone (expired or
|
|
246
280
|
* terminated): the persisted session is worthless, and a caller starts a
|
|
247
|
-
* fresh upload instead of retrying it.
|
|
281
|
+
* fresh upload instead of retrying it. It is the transfer policy's GONE
|
|
282
|
+
* verdict, so every request that can meet a dead session (HEAD, a PATCH, the
|
|
283
|
+
* OS engine's PATCHes, DELETE) and the app's own session bookkeeping agree on
|
|
284
|
+
* what "gone" is.
|
|
248
285
|
*/
|
|
249
286
|
export function isSessionGone(status: number): boolean {
|
|
250
|
-
return
|
|
287
|
+
return Number.isSafeInteger(status) && status > 0 && classifyTransferResponse({ status }).verdict === TRANSFER_VERDICT.GONE;
|
|
251
288
|
}
|
|
252
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Forge no longer has the session: `coreCode` names the code Core ended it
|
|
292
|
+
* with when the answer named one (`NOT_FOUND`, `UPLOAD_PROTOCOL_RETIRED`, a
|
|
293
|
+
* 412's), none of them a refusal a new upload cannot remedy.
|
|
294
|
+
*/
|
|
253
295
|
function sessionLostError(
|
|
254
296
|
status: number,
|
|
255
297
|
context: TusErrorContext,
|
|
256
298
|
phase: TusUploadError['phase'],
|
|
257
|
-
cause: Error | null = null
|
|
299
|
+
cause: Error | null = null,
|
|
300
|
+
coreCode: string | null = null
|
|
258
301
|
): TusUploadError {
|
|
259
302
|
return new TusUploadError(`TUS session no longer exists on the server (${status})`, {
|
|
260
303
|
cause,
|
|
@@ -262,6 +305,324 @@ function sessionLostError(
|
|
|
262
305
|
...context,
|
|
263
306
|
phase,
|
|
264
307
|
sessionLost: true,
|
|
308
|
+
coreCode,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* How far past the expiry Forge named a session still counts as live: the
|
|
314
|
+
* allowance for a device clock running ahead of Forge's. `Upload-Expires` is
|
|
315
|
+
* read off Forge's clock and compared with the device's; a phone on automatic
|
|
316
|
+
* time is within seconds of Forge, one set by hand is typically minutes fast.
|
|
317
|
+
* The two mistakes cost very differently: a live session judged expired is
|
|
318
|
+
* dropped and its file sent again from byte 0, an expired one judged live
|
|
319
|
+
* costs a single HEAD (Forge answers 410, `sessionLost`). So the margin errs
|
|
320
|
+
* long, and stays small against the 72 h idle window a session lives from its
|
|
321
|
+
* last commit (`@sparkvault/app-sdk/upload-expiry`): a session the clock says
|
|
322
|
+
* lapsed hours ago is still dropped without a request.
|
|
323
|
+
*/
|
|
324
|
+
export const SESSION_EXPIRY_CLOCK_MARGIN_MS = 5 * 60 * 1000;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* True when the expiry Forge named for a session (`TusSessionInfo.expiresAt`,
|
|
328
|
+
* kept current through `onSessionExpiry`) is past by more than the clock
|
|
329
|
+
* margin: Forge refuses every request to a session past its expiry, so a
|
|
330
|
+
* caller drops it without asking. Within the margin the session is live for
|
|
331
|
+
* all the device clock can tell, and Forge's HEAD decides. An unknown expiry
|
|
332
|
+
* (null) never has passed: only Forge's HEAD can say.
|
|
333
|
+
*/
|
|
334
|
+
export function isSessionExpired(expiresAt: number | null | undefined, now: number = Date.now()): boolean {
|
|
335
|
+
return typeof expiresAt === 'number' && now > expiresAt + SESSION_EXPIRY_CLOCK_MARGIN_MS;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* One answer to a transfer request, or its absence: what the transfer policy
|
|
340
|
+
* reads (the status and the headers) and what an error built from it says
|
|
341
|
+
* (the body). Every request an upload sends resolves to one, so one place
|
|
342
|
+
* decides what the upload does next.
|
|
343
|
+
*/
|
|
344
|
+
interface TransferAnswer {
|
|
345
|
+
/** The HTTP status; 0 when no answer arrived (a dropped connection, a timeout, a stalled chunk). */
|
|
346
|
+
readonly status: number;
|
|
347
|
+
/** A response header by name, case-insensitively; always null for no answer. */
|
|
348
|
+
readonly header: (name: string) => string | null;
|
|
349
|
+
/** The body of an answer that is not a 2xx (Forge's error JSON); empty otherwise. */
|
|
350
|
+
readonly body: string;
|
|
351
|
+
/** That body's error envelope, parsed once and whole (`parseErrorBody`); empty for a 2xx or no answer. */
|
|
352
|
+
readonly envelope: ApiErrorBody;
|
|
353
|
+
/** Why no answer arrived (status 0): the error the caller sees when no retry follows. */
|
|
354
|
+
readonly unanswered: TusUploadError | null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** An answer that arrived: its status, its headers and the body of one that is not a 2xx. */
|
|
358
|
+
function answerOf(status: number, header: (name: string) => string | null, body: string): TransferAnswer {
|
|
359
|
+
return { status, header, body, envelope: parseErrorBody(body), unanswered: null };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function noAnswer(error: TusUploadError): TransferAnswer {
|
|
363
|
+
return { status: 0, header: () => null, body: '', envelope: {}, unanswered: error };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The transfer policy's reading of an answer to `request`: what to do next,
|
|
368
|
+
* its `Retry-After`, its `Upload-Offset`, and the refusal Core decided when
|
|
369
|
+
* the answer is one.
|
|
370
|
+
*/
|
|
371
|
+
function readOutcome(answer: TransferAnswer, request: TusRequestKind): TransferOutcome {
|
|
372
|
+
return classifyTransferResponse({
|
|
373
|
+
status: answer.status,
|
|
374
|
+
headers: answer.header,
|
|
375
|
+
details: answer.envelope.error?.details ?? null,
|
|
376
|
+
receipt: request.receipt,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* A failed request of an upload: the error the caller sees when no retry
|
|
382
|
+
* follows, and what the transfer policy made of the answer behind it.
|
|
383
|
+
*/
|
|
384
|
+
interface TransferFailure {
|
|
385
|
+
readonly error: SparkVaultMobileError;
|
|
386
|
+
readonly outcome: TransferOutcome;
|
|
387
|
+
/** No answer arrived, so the device may be offline (an answer of any status proves it is not). */
|
|
388
|
+
readonly unanswered: boolean;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** How a request names itself in its errors, and whether it is the receipt. */
|
|
392
|
+
interface TusRequestKind {
|
|
393
|
+
/** The request in the message: `TUS HEAD failed: 503 - …`. */
|
|
394
|
+
readonly label: string;
|
|
395
|
+
readonly phase: TusUploadError['phase'];
|
|
396
|
+
/** The phase a gone session (404/410) is reported in; null for the create, which no session stands behind yet. */
|
|
397
|
+
readonly gonePhase: TusUploadError['phase'] | null;
|
|
398
|
+
/** The PATCH at the final offset, whose 503 is a deferral (the transfer policy's DEFERRED). */
|
|
399
|
+
readonly receipt: boolean;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const CREATE_REQUEST: TusRequestKind = { label: 'TUS create', phase: 'create', gonePhase: null, receipt: false };
|
|
403
|
+
const RESUME_HEAD_REQUEST: TusRequestKind = { label: 'TUS HEAD', phase: 'create', gonePhase: 'create', receipt: false };
|
|
404
|
+
const OFFSET_HEAD_REQUEST: TusRequestKind = { label: 'TUS HEAD', phase: 'upload', gonePhase: 'create', receipt: false };
|
|
405
|
+
const CHUNK_REQUEST: TusRequestKind = { label: 'Chunk upload', phase: 'upload', gonePhase: 'upload', receipt: false };
|
|
406
|
+
const RECEIPT_REQUEST: TusRequestKind = { label: 'TUS finalization', phase: 'upload', gonePhase: 'create', receipt: true };
|
|
407
|
+
|
|
408
|
+
/** What a caller hears once Forge kept deferring the publication until the upload's idle limit. */
|
|
409
|
+
const RECEIVED_PUBLISHING_MESSAGE =
|
|
410
|
+
'Upload received: Forge holds every byte and is still saving the file; it finishes without this device';
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* What a caller reads for a final refusal whose answer carried no sentence of
|
|
414
|
+
* Core's (a HEAD's, which has no body, naming Core's code in
|
|
415
|
+
* `X-Upload-Refusal` alone): this SDK's own words for each code.
|
|
416
|
+
*/
|
|
417
|
+
const REFUSAL_WORDING: ReadonlyMap<string, string> = new Map([
|
|
418
|
+
[UPLOAD_REFUSAL_CODES.UPLOAD_POOL_FULL, 'This upload could not be saved: the owner\'s storage cannot take the file'],
|
|
419
|
+
[UPLOAD_REFUSAL_CODES.UPLOAD_SUPERSEDED, 'This upload was not saved: a newer upload of this file replaced it'],
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Core refused the upload for good, in its own words (the answer's body,
|
|
424
|
+
* `details.coreCode` naming Core's code) or, for an answer with no body (a
|
|
425
|
+
* HEAD's, naming the code in `X-Upload-Refusal`), in this SDK's: the
|
|
426
|
+
* account's storage pool cannot take it, or a later upload replaced it. Not
|
|
427
|
+
* `sessionLost`: a new upload of the same file is no remedy.
|
|
428
|
+
*/
|
|
429
|
+
function refusedError(
|
|
430
|
+
status: number,
|
|
431
|
+
envelope: ApiErrorBody,
|
|
432
|
+
coreCode: string,
|
|
433
|
+
context: TusErrorContext,
|
|
434
|
+
phase: TusUploadError['phase'],
|
|
435
|
+
cause: Error | null = null
|
|
436
|
+
): TusUploadError {
|
|
437
|
+
const coreMessage = envelope.error?.message;
|
|
438
|
+
const message = typeof coreMessage === 'string' && coreMessage
|
|
439
|
+
? coreMessage
|
|
440
|
+
: REFUSAL_WORDING.get(coreCode) ?? `Forge did not save this upload: Core refused it for good (${coreCode})`;
|
|
441
|
+
return new TusUploadError(message, { cause, httpStatus: status, code: 'upload_refused', coreCode, ...context, phase });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The failure a request's answer amounts to: the no-answer error as it was
|
|
446
|
+
* classified, a gone session as `sessionLost`, the refusal Core decided in
|
|
447
|
+
* Core's words (`upload_refused`), the receipt's deferral as the
|
|
448
|
+
* received-publishing outcome a caller resumes later (`retryLater`; thrown
|
|
449
|
+
* only once the idle limit ends the asking), a 402 as the typed gate error
|
|
450
|
+
* the JSON HTTP path throws (so callers run their subscribe / add-on UX off
|
|
451
|
+
* one set of types), and every other status as a `TusUploadError` carrying
|
|
452
|
+
* it and its `Retry-After`.
|
|
453
|
+
*/
|
|
454
|
+
function failureOf(
|
|
455
|
+
answer: TransferAnswer,
|
|
456
|
+
outcome: TransferOutcome,
|
|
457
|
+
request: TusRequestKind,
|
|
458
|
+
context: TusErrorContext
|
|
459
|
+
): TransferFailure {
|
|
460
|
+
return { error: errorOf(answer, outcome, request, context), outcome, unanswered: answer.unanswered !== null };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function errorOf(
|
|
464
|
+
answer: TransferAnswer,
|
|
465
|
+
outcome: TransferOutcome,
|
|
466
|
+
request: TusRequestKind,
|
|
467
|
+
context: TusErrorContext
|
|
468
|
+
): SparkVaultMobileError {
|
|
469
|
+
if (answer.unanswered) return answer.unanswered;
|
|
470
|
+
if (outcome.verdict === TRANSFER_VERDICT.REFUSED && outcome.coreCode !== null) {
|
|
471
|
+
return refusedError(answer.status, answer.envelope, outcome.coreCode, context, request.phase);
|
|
472
|
+
}
|
|
473
|
+
if (outcome.verdict === TRANSFER_VERDICT.GONE && request.gonePhase !== null) {
|
|
474
|
+
return sessionLostError(answer.status, context, request.gonePhase, null, outcome.coreCode);
|
|
475
|
+
}
|
|
476
|
+
if (outcome.verdict === TRANSFER_VERDICT.DEFERRED) {
|
|
477
|
+
return new TusUploadError(RECEIVED_PUBLISHING_MESSAGE, {
|
|
478
|
+
httpStatus: answer.status,
|
|
479
|
+
code: 'upload_received_publishing',
|
|
480
|
+
retryAfterMs: outcome.retryAfterMs,
|
|
481
|
+
...context,
|
|
482
|
+
phase: 'finalize',
|
|
483
|
+
retryLater: true,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const gateError = gateErrorFromBody(answer.status, answer.envelope);
|
|
487
|
+
if (gateError) return gateError;
|
|
488
|
+
return new TusUploadError(`${request.label} failed: ${answer.status} - ${answer.body.substring(0, 200)}`, {
|
|
489
|
+
httpStatus: answer.status,
|
|
490
|
+
retryAfterMs: outcome.retryAfterMs,
|
|
491
|
+
...context,
|
|
492
|
+
phase: request.phase,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The session's expiry as its latest answer named it, reported to the
|
|
498
|
+
* caller (`onSessionExpiry`) each time it changes. An answer that names none
|
|
499
|
+
* leaves it unknown: the request may have moved it unread (a commit whose
|
|
500
|
+
* answer was lost, a deferred finalization), and a caller that dropped a
|
|
501
|
+
* session on an expiry Forge has since moved on would re-send a whole file.
|
|
502
|
+
* A request cut short by the caller's abort keeps the last named value: it
|
|
503
|
+
* can only have slid the expiry by that one request's duration.
|
|
504
|
+
*/
|
|
505
|
+
class SessionExpiry {
|
|
506
|
+
private known: number | null;
|
|
507
|
+
private readonly report?: (expiresAt: number | null) => void;
|
|
508
|
+
|
|
509
|
+
constructor(known: number | null, report?: (expiresAt: number | null) => void) {
|
|
510
|
+
this.known = known;
|
|
511
|
+
this.report = report;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Forge answered (or did not): the expiry is what the answer names. */
|
|
515
|
+
read(answer: TransferAnswer): void {
|
|
516
|
+
this.become(parseUploadExpires(answer.header(UPLOAD_EXPIRES_HEADER)));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* A request the OS carries on without JS is about to leave: it may commit
|
|
521
|
+
* chunks, each sliding the expiry, whose answers this upload never reads,
|
|
522
|
+
* possibly while the app is not running.
|
|
523
|
+
*/
|
|
524
|
+
forget(): void {
|
|
525
|
+
this.become(null);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private become(expiresAt: number | null): void {
|
|
529
|
+
if (expiresAt === this.known) return;
|
|
530
|
+
this.known = expiresAt;
|
|
531
|
+
this.report?.(expiresAt);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* What every request to one session carries: where it lives, its token, the
|
|
537
|
+
* file it is for, the expiry its answers keep current (none for a one-off
|
|
538
|
+
* probe), and the caller's abort.
|
|
539
|
+
*/
|
|
540
|
+
interface SessionLink {
|
|
541
|
+
readonly uploadUrl: string;
|
|
542
|
+
readonly istk: string;
|
|
543
|
+
readonly context: TusErrorContext;
|
|
544
|
+
readonly expiry: SessionExpiry | null;
|
|
545
|
+
readonly signal?: AbortSignal;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* An upload in flight on its session: the file's geometry and the one retry
|
|
550
|
+
* budget every request of it spends (`@sparkvault/app-sdk/transfer-retry`):
|
|
551
|
+
* failures in a row and time without progress, both starting over whenever
|
|
552
|
+
* the server confirms progress, so a slow transfer that keeps landing bytes
|
|
553
|
+
* is never cut off while a dead one ends in bounded time.
|
|
554
|
+
*/
|
|
555
|
+
interface TusTransfer extends SessionLink {
|
|
556
|
+
readonly fileSize: number;
|
|
557
|
+
readonly chunkSize: number;
|
|
558
|
+
readonly budget: TransferBudget;
|
|
559
|
+
readonly expiry: SessionExpiry;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Where an upload stands: the server's offset, and whether the server confirmed the ingot published. */
|
|
563
|
+
interface TransferPosition {
|
|
564
|
+
readonly offset: number;
|
|
565
|
+
readonly published: boolean;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** The file a chunk is read from, and how its chunks travel. */
|
|
569
|
+
interface ChunkSource {
|
|
570
|
+
readonly reader: MobileFileReader;
|
|
571
|
+
readonly uri: string;
|
|
572
|
+
readonly transport?: UploadTransport;
|
|
573
|
+
/** Bytes of the chunk at `offset` that have left the device so far. */
|
|
574
|
+
readonly onChunkProgress: (offset: number, chunkBytesUploaded: number) => void;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** A backoff the caller's abort cuts short: resolves after `ms`, rejects as cancelled the moment the signal fires. */
|
|
578
|
+
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
579
|
+
return new Promise((resolve, reject) => {
|
|
580
|
+
if (signal?.aborted) {
|
|
581
|
+
reject(cancelledError());
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const onAbort = () => {
|
|
585
|
+
clearTimeout(timer);
|
|
586
|
+
reject(cancelledError());
|
|
587
|
+
};
|
|
588
|
+
const timer = setTimeout(() => {
|
|
589
|
+
signal?.removeEventListener('abort', onAbort);
|
|
590
|
+
resolve();
|
|
591
|
+
}, ms);
|
|
592
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Resolve once the device is back online; reject as cancelled the moment the
|
|
598
|
+
* caller aborts, the wait's only other end.
|
|
599
|
+
*/
|
|
600
|
+
function waitUntilOnline(connectivity: MobileConnectivity, signal?: AbortSignal): Promise<void> {
|
|
601
|
+
return new Promise((resolve, reject) => {
|
|
602
|
+
let settled = false;
|
|
603
|
+
let unsubscribe: (() => void) | null = null;
|
|
604
|
+
const settle = (outcome: () => void) => {
|
|
605
|
+
if (settled) return;
|
|
606
|
+
settled = true;
|
|
607
|
+
unsubscribe?.();
|
|
608
|
+
signal?.removeEventListener('abort', onAbort);
|
|
609
|
+
outcome();
|
|
610
|
+
};
|
|
611
|
+
const onAbort = () => settle(() => reject(cancelledError()));
|
|
612
|
+
if (signal?.aborted) {
|
|
613
|
+
onAbort();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
617
|
+
unsubscribe = connectivity.onOnline(() => settle(resolve));
|
|
618
|
+
if (settled) {
|
|
619
|
+
// The listener fired inside the subscribe, before its unsubscribe existed.
|
|
620
|
+
unsubscribe();
|
|
621
|
+
} else if (connectivity.isOnline()) {
|
|
622
|
+
// The network came back between the caller's check and the subscribe,
|
|
623
|
+
// so its event would never arrive.
|
|
624
|
+
settle(resolve);
|
|
625
|
+
}
|
|
265
626
|
});
|
|
266
627
|
}
|
|
267
628
|
|
|
@@ -308,6 +669,38 @@ async function tusRequest(
|
|
|
308
669
|
}
|
|
309
670
|
}
|
|
310
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Send a tus request through `tusRequest` and resolve with its answer: a
|
|
674
|
+
* response of any status, or no answer (a network failure or a timeout, as
|
|
675
|
+
* `tusRequest` classified it). The caller's abort and an SDK error still
|
|
676
|
+
* throw, because no retry follows either.
|
|
677
|
+
*/
|
|
678
|
+
async function fetchAnswer(
|
|
679
|
+
config: ResolvedMobileConfig,
|
|
680
|
+
url: string,
|
|
681
|
+
init: TusRequestInit,
|
|
682
|
+
label: string,
|
|
683
|
+
context: TusErrorContext
|
|
684
|
+
): Promise<TransferAnswer> {
|
|
685
|
+
let response: Response;
|
|
686
|
+
try {
|
|
687
|
+
response = await tusRequest(config, url, init, label, context);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
if (err instanceof TusUploadError && (err.phase === 'network' || err.phase === 'timeout')) {
|
|
690
|
+
return noAnswer(err);
|
|
691
|
+
}
|
|
692
|
+
throw err;
|
|
693
|
+
}
|
|
694
|
+
if (response.status === 0) {
|
|
695
|
+
return noAnswer(new TusUploadError(`${label} got no answer`, { ...context, phase: 'network' }));
|
|
696
|
+
}
|
|
697
|
+
return answerOf(
|
|
698
|
+
response.status,
|
|
699
|
+
name => response.headers.get(name),
|
|
700
|
+
response.ok ? '' : await response.text().catch(() => '')
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
311
704
|
/**
|
|
312
705
|
* Case-insensitive header lookup for adapter results. Native HTTP stacks
|
|
313
706
|
* hand headers back in whatever case they please (iOS lower-cases them),
|
|
@@ -329,7 +722,7 @@ function getHeaderIgnoreCase(headers: Record<string, string> | undefined, name:
|
|
|
329
722
|
*/
|
|
330
723
|
type TusSessionSource =
|
|
331
724
|
| { mode: 'create'; parsed: ParsedForgeUrl }
|
|
332
|
-
| { mode: 'resume'; uploadUrl: string; istk: string };
|
|
725
|
+
| { mode: 'resume'; uploadUrl: string; istk: string; expiresAt: number | null };
|
|
333
726
|
|
|
334
727
|
function resolveSessionSource(options: TusUploadFromUriOptions, allowedHostPatterns: readonly RegExp[]): TusSessionSource {
|
|
335
728
|
if (options.resume) {
|
|
@@ -339,7 +732,11 @@ function resolveSessionSource(options: TusUploadFromUriOptions, allowedHostPatte
|
|
|
339
732
|
if (!istk) {
|
|
340
733
|
throw new SparkVaultValidationError('resume.istk or forgeUrl is required to resume a TUS upload session');
|
|
341
734
|
}
|
|
342
|
-
|
|
735
|
+
const expiresAt = options.resume.expiresAt ?? null;
|
|
736
|
+
if (expiresAt !== null && !Number.isFinite(expiresAt)) {
|
|
737
|
+
throw new SparkVaultValidationError('resume.expiresAt is an epoch-ms expiry or null', { received: expiresAt });
|
|
738
|
+
}
|
|
739
|
+
return { mode: 'resume', uploadUrl, istk, expiresAt };
|
|
343
740
|
}
|
|
344
741
|
if (!options.forgeUrl) {
|
|
345
742
|
throw new SparkVaultValidationError('forgeUrl is required to create a TUS upload session');
|
|
@@ -347,60 +744,20 @@ function resolveSessionSource(options: TusUploadFromUriOptions, allowedHostPatte
|
|
|
347
744
|
return { mode: 'create', parsed: parseForgeUrl(options.forgeUrl, allowedHostPatterns) };
|
|
348
745
|
}
|
|
349
746
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const response = await tusRequest(
|
|
362
|
-
config,
|
|
363
|
-
parsed.tusEndpoint,
|
|
364
|
-
{
|
|
365
|
-
method: 'POST',
|
|
366
|
-
headers: {
|
|
367
|
-
'Tus-Resumable': TUS_VERSION,
|
|
368
|
-
'Upload-Length': String(fileSize),
|
|
369
|
-
'X-ISTK': parsed.istk,
|
|
370
|
-
},
|
|
371
|
-
signal,
|
|
372
|
-
timeoutMs: config.tusPostTimeoutMs,
|
|
373
|
-
},
|
|
374
|
-
'TUS POST request',
|
|
375
|
-
{ filename, fileSize }
|
|
376
|
-
);
|
|
377
|
-
|
|
378
|
-
if (!response.ok) {
|
|
379
|
-
const errorText = await response.text().catch(() => '');
|
|
380
|
-
|
|
381
|
-
// A 402 here is a billable gate hit mid-create (e.g. storage/bandwidth pool
|
|
382
|
-
// exhausted). Route it through the same typed-gate mapping as the JSON HTTP
|
|
383
|
-
// path so callers handle subscribe / add-on UX off one set of error types.
|
|
384
|
-
const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
|
|
385
|
-
if (gateError) {
|
|
386
|
-
throw gateError;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
throw new TusUploadError(`TUS create failed: ${response.status} - ${errorText}`, {
|
|
390
|
-
httpStatus: response.status,
|
|
391
|
-
filename,
|
|
392
|
-
fileSize,
|
|
393
|
-
phase: 'create',
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Every chunk carries the file's bytes, so the session must live under the
|
|
398
|
-
// endpoint it was created at.
|
|
399
|
-
const uploadUrl = resolveForgeSessionUrl(response.headers.get('Location'), parsed.tusEndpoint);
|
|
747
|
+
/**
|
|
748
|
+
* The session a create's 2xx opened: where it lives, its chunk geometry and
|
|
749
|
+
* its expiry. Every chunk carries the file's bytes, so the session must live
|
|
750
|
+
* under the endpoint it was created at.
|
|
751
|
+
*/
|
|
752
|
+
function readCreatedSession(
|
|
753
|
+
answer: TransferAnswer,
|
|
754
|
+
endpoint: string,
|
|
755
|
+
context: TusErrorContext
|
|
756
|
+
): { uploadUrl: string; chunkSize: number; expiresAt: number | null } {
|
|
757
|
+
const uploadUrl = resolveForgeSessionUrl(answer.header('Location'), endpoint);
|
|
400
758
|
if (!uploadUrl) {
|
|
401
759
|
throw new TusUploadError('TUS create response Location header is missing or outside the upload endpoint', {
|
|
402
|
-
|
|
403
|
-
fileSize,
|
|
760
|
+
...context,
|
|
404
761
|
phase: 'create',
|
|
405
762
|
});
|
|
406
763
|
}
|
|
@@ -409,149 +766,57 @@ async function createTusUpload(
|
|
|
409
766
|
// that is not exactly the negotiated size, so the chunk size is the server's
|
|
410
767
|
// to dictate via `X-Chunk-Size` (mirrors sdk-js). A client-side guess could
|
|
411
768
|
// only ever be rejected, so a missing header is a contract violation.
|
|
412
|
-
const chunkSizeHeader =
|
|
769
|
+
const chunkSizeHeader = answer.header('X-Chunk-Size');
|
|
413
770
|
const chunkSize = parseIntegerHeader(chunkSizeHeader, 1);
|
|
414
771
|
if (chunkSize === null) {
|
|
415
772
|
throw new TusUploadError(`TUS create response missing or invalid X-Chunk-Size header (received ${JSON.stringify(chunkSizeHeader)})`, {
|
|
416
|
-
|
|
417
|
-
fileSize,
|
|
773
|
+
...context,
|
|
418
774
|
phase: 'create',
|
|
419
775
|
});
|
|
420
776
|
}
|
|
421
777
|
|
|
422
|
-
return { uploadUrl, chunkSize };
|
|
778
|
+
return { uploadUrl, chunkSize, expiresAt: parseUploadExpires(answer.header(UPLOAD_EXPIRES_HEADER)) };
|
|
423
779
|
}
|
|
424
780
|
|
|
425
781
|
/**
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
* expired or was terminated: the error carries `sessionLost` so the caller
|
|
431
|
-
* drops its persisted session and creates a fresh one rather than retrying
|
|
432
|
-
* a dead URL. Any other failure (a 5xx, an auth rejection, a timeout)
|
|
433
|
-
* leaves `sessionLost` false: the
|
|
434
|
-
* session may well be intact, and treating it as dead would re-upload a
|
|
435
|
-
* whole file over a blip.
|
|
782
|
+
* Where a HEAD's 2xx says the session stands: offset, length and chunk
|
|
783
|
+
* geometry, all required (a 2xx without them is not Forge, a captive
|
|
784
|
+
* portal's page being the classic case), and the expiry when named. An
|
|
785
|
+
* offset past the length can only be a corrupt session.
|
|
436
786
|
*/
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
signal?: AbortSignal,
|
|
442
|
-
context: TusErrorContext = {}
|
|
443
|
-
): Promise<TusSessionProbe> {
|
|
444
|
-
ensureNotAborted(signal);
|
|
445
|
-
|
|
446
|
-
const response = await tusRequest(
|
|
447
|
-
config,
|
|
448
|
-
uploadUrl,
|
|
449
|
-
{
|
|
450
|
-
method: 'HEAD',
|
|
451
|
-
headers: {
|
|
452
|
-
'Tus-Resumable': TUS_VERSION,
|
|
453
|
-
'X-ISTK': istk,
|
|
454
|
-
},
|
|
455
|
-
signal,
|
|
456
|
-
timeoutMs: config.tusPostTimeoutMs,
|
|
457
|
-
},
|
|
458
|
-
'TUS HEAD request',
|
|
459
|
-
context
|
|
460
|
-
);
|
|
461
|
-
|
|
462
|
-
if (isSessionGone(response.status)) {
|
|
463
|
-
throw sessionLostError(response.status, context, 'create');
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
if (!response.ok) {
|
|
467
|
-
const errorText = await response.text().catch(() => '');
|
|
468
|
-
const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
|
|
469
|
-
if (gateError) {
|
|
470
|
-
throw gateError;
|
|
471
|
-
}
|
|
472
|
-
throw new TusUploadError(`TUS HEAD failed: ${response.status} - ${errorText}`, {
|
|
473
|
-
httpStatus: response.status,
|
|
474
|
-
...context,
|
|
475
|
-
phase: 'create',
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const offset = parseIntegerHeader(response.headers.get('Upload-Offset'), 0);
|
|
480
|
-
const length = parseIntegerHeader(response.headers.get('Upload-Length'), 0);
|
|
481
|
-
const chunkSize = parseIntegerHeader(response.headers.get('X-Chunk-Size'), 1);
|
|
787
|
+
function readSessionProbe(answer: TransferAnswer, phase: TusUploadError['phase'], context: TusErrorContext): TusSessionProbe {
|
|
788
|
+
const offset = parseIntegerHeader(answer.header('Upload-Offset'), 0);
|
|
789
|
+
const length = parseIntegerHeader(answer.header('Upload-Length'), 0);
|
|
790
|
+
const chunkSize = parseIntegerHeader(answer.header('X-Chunk-Size'), 1);
|
|
482
791
|
if (offset === null || length === null || chunkSize === null || offset > length) {
|
|
483
792
|
throw new TusUploadError(
|
|
484
793
|
'TUS HEAD response missing or invalid Upload-Offset / Upload-Length / X-Chunk-Size headers',
|
|
485
|
-
{ ...context, phase
|
|
794
|
+
{ ...context, phase }
|
|
486
795
|
);
|
|
487
796
|
}
|
|
488
|
-
|
|
489
|
-
return { offset, length, chunkSize };
|
|
797
|
+
return { offset, length, chunkSize, expiresAt: parseUploadExpires(answer.header(UPLOAD_EXPIRES_HEADER)) };
|
|
490
798
|
}
|
|
491
799
|
|
|
492
800
|
/**
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* (Core/DynamoDB/billing), and answers 204 for one that already completed.
|
|
498
|
-
* Without this a resume that finds the transfer complete would skip straight
|
|
499
|
-
* to the status poll and could report a never-activated ingot as done.
|
|
500
|
-
*
|
|
501
|
-
* 409 means another finalizer holds the lease right now: retry later, the
|
|
502
|
-
* session is intact. A status `isSessionGone` recognises means the session
|
|
503
|
-
* is gone (`sessionLost`).
|
|
801
|
+
* A session offset the server named (a HEAD's, or a 409's `Upload-Offset`),
|
|
802
|
+
* checked before anything is sent from it: Forge commits whole chunks, so a
|
|
803
|
+
* session stands at a chunk boundary or at the total, never past it, and an
|
|
804
|
+
* answer that says otherwise is not one to resume from.
|
|
504
805
|
*/
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const response = await tusRequest(
|
|
516
|
-
config,
|
|
517
|
-
uploadUrl,
|
|
518
|
-
{
|
|
519
|
-
method: 'PATCH',
|
|
520
|
-
headers: {
|
|
521
|
-
'Tus-Resumable': TUS_VERSION,
|
|
522
|
-
'Upload-Offset': String(fileSize),
|
|
523
|
-
'Content-Type': 'application/offset+octet-stream',
|
|
524
|
-
'X-ISTK': istk,
|
|
525
|
-
},
|
|
526
|
-
signal,
|
|
527
|
-
timeoutMs: config.tusChunkTimeoutMs,
|
|
528
|
-
},
|
|
529
|
-
'TUS finalization request',
|
|
530
|
-
context
|
|
531
|
-
);
|
|
532
|
-
|
|
533
|
-
if (isSessionGone(response.status)) {
|
|
534
|
-
throw sessionLostError(response.status, context, 'create');
|
|
535
|
-
}
|
|
536
|
-
if (response.status === 409) {
|
|
537
|
-
throw new TusUploadError('TUS finalization is already in progress on the server; retry shortly', {
|
|
538
|
-
httpStatus: 409,
|
|
539
|
-
...context,
|
|
540
|
-
phase: 'upload',
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
if (!response.ok) {
|
|
544
|
-
const errorText = await response.text().catch(() => '');
|
|
545
|
-
const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
|
|
546
|
-
if (gateError) {
|
|
547
|
-
throw gateError;
|
|
548
|
-
}
|
|
549
|
-
throw new TusUploadError(`TUS finalization failed: ${response.status} - ${errorText.substring(0, 200)}`, {
|
|
550
|
-
httpStatus: response.status,
|
|
806
|
+
function checkedServerOffset(
|
|
807
|
+
offset: number | null,
|
|
808
|
+
geometry: { fileSize: number; chunkSize: number },
|
|
809
|
+
phase: TusUploadError['phase'],
|
|
810
|
+
context: TusErrorContext
|
|
811
|
+
): number {
|
|
812
|
+
const problem = resumeOffsetProblem(offset, { totalSize: geometry.fileSize, chunkSize: geometry.chunkSize });
|
|
813
|
+
if (offset === null || problem !== null) {
|
|
814
|
+
throw new TusUploadError(`Forge named an upload offset this upload cannot continue from (${offset}: ${problem})`, {
|
|
551
815
|
...context,
|
|
552
|
-
phase
|
|
816
|
+
phase,
|
|
553
817
|
});
|
|
554
818
|
}
|
|
819
|
+
return offset;
|
|
555
820
|
}
|
|
556
821
|
|
|
557
822
|
/**
|
|
@@ -562,26 +827,29 @@ async function confirmTusFinalization(
|
|
|
562
827
|
const CHUNK_ABSOLUTE_CAP_MS = 60 * 60_000;
|
|
563
828
|
|
|
564
829
|
/**
|
|
565
|
-
* PATCH one chunk
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
830
|
+
* PATCH one chunk over XHR and resolve with Forge's answer, or with no answer
|
|
831
|
+
* when the connection drops, the request times out or the chunk stalls. Only
|
|
832
|
+
* the caller's abort rejects. `stallTimeoutMs` is a STALL watchdog, not a
|
|
833
|
+
* deadline: it fires only when no upload progress (and no response) arrives
|
|
834
|
+
* for that long. Forge fixes non-final chunks at 50 MB and the client cannot
|
|
835
|
+
* shrink them, so an absolute per-request timeout would kill every
|
|
836
|
+
* legitimately slow uplink (a 50 MB chunk needs ~3.5 Mbps sustained to beat
|
|
837
|
+
* 120 s). The final PATCH also waits for Forge to finalize after the last
|
|
838
|
+
* byte is sent; a progress-based watchdog gives that the same patience.
|
|
839
|
+
* Bytes leaving are a sign of life: they restart the transfer's idle clock
|
|
840
|
+
* (never its failure count, which only the server's confirmation resets).
|
|
572
841
|
*/
|
|
573
|
-
function
|
|
574
|
-
|
|
575
|
-
istk: string,
|
|
842
|
+
function sendChunkOverXhr(
|
|
843
|
+
transfer: TusTransfer,
|
|
576
844
|
chunk: TusChunk,
|
|
577
845
|
offset: number,
|
|
578
846
|
stallTimeoutMs: number,
|
|
579
|
-
signal?: AbortSignal,
|
|
580
847
|
onChunkProgress?: (chunkBytesUploaded: number) => void
|
|
581
|
-
): Promise<
|
|
848
|
+
): Promise<TransferAnswer> {
|
|
582
849
|
if (typeof XMLHttpRequest === 'undefined') {
|
|
583
850
|
throw new SparkVaultValidationError('XMLHttpRequest is required for mobile TUS uploads');
|
|
584
851
|
}
|
|
852
|
+
const { uploadUrl, istk, signal, context } = transfer;
|
|
585
853
|
|
|
586
854
|
return new Promise((resolve, reject) => {
|
|
587
855
|
const xhr = new XMLHttpRequest();
|
|
@@ -604,7 +872,7 @@ function uploadChunk(
|
|
|
604
872
|
|
|
605
873
|
const abort = () => {
|
|
606
874
|
xhr.abort();
|
|
607
|
-
finish(() => reject(
|
|
875
|
+
finish(() => reject(cancelledError()));
|
|
608
876
|
};
|
|
609
877
|
|
|
610
878
|
if (signal?.aborted) {
|
|
@@ -613,10 +881,13 @@ function uploadChunk(
|
|
|
613
881
|
}
|
|
614
882
|
signal?.addEventListener('abort', abort, { once: true });
|
|
615
883
|
|
|
884
|
+
const unanswered = (message: string, phase: 'network' | 'timeout') =>
|
|
885
|
+
finish(() => resolve(noAnswer(new TusUploadError(message, { ...context, phase }))));
|
|
886
|
+
|
|
616
887
|
// Settle BEFORE aborting: the abort handler below would otherwise report
|
|
617
888
|
// the stall as a user cancellation.
|
|
618
889
|
const stall = (message: string) => {
|
|
619
|
-
|
|
890
|
+
unanswered(message, 'timeout');
|
|
620
891
|
xhr.abort();
|
|
621
892
|
};
|
|
622
893
|
const armWatchdog = () => {
|
|
@@ -629,38 +900,24 @@ function uploadChunk(
|
|
|
629
900
|
|
|
630
901
|
xhr.upload.onprogress = event => {
|
|
631
902
|
armWatchdog();
|
|
903
|
+
transfer.budget.alive();
|
|
632
904
|
if (event.lengthComputable) {
|
|
633
905
|
onChunkProgress?.(event.loaded);
|
|
634
906
|
}
|
|
635
907
|
};
|
|
636
908
|
|
|
637
909
|
xhr.onload = () => {
|
|
638
|
-
if (xhr.status
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
// classic case): report no advance and let the stall guard fail the
|
|
642
|
-
// attempt, rather than crediting bytes that may never have arrived.
|
|
643
|
-
// parseInt would also turn a garbage header into NaN, which slips
|
|
644
|
-
// through that guard (every comparison with NaN is false) and ends
|
|
645
|
-
// the upload loop as a success.
|
|
646
|
-
const newOffset = parseIntegerHeader(xhr.getResponseHeader('Upload-Offset'), 0) ?? offset;
|
|
647
|
-
finish(() => resolve(newOffset));
|
|
648
|
-
} else {
|
|
649
|
-
const gateError = gateErrorFromBody(xhr.status, parseErrorBody(xhr.responseText));
|
|
650
|
-
if (gateError) {
|
|
651
|
-
finish(() => reject(gateError));
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
finish(() => reject(new TusUploadError(`Chunk upload failed: ${xhr.status} - ${xhr.responseText.substring(0, 200)}`, {
|
|
655
|
-
httpStatus: xhr.status,
|
|
656
|
-
phase: 'upload',
|
|
657
|
-
})));
|
|
910
|
+
if (xhr.status === 0) {
|
|
911
|
+
unanswered('Chunk upload got no answer', 'network');
|
|
912
|
+
return;
|
|
658
913
|
}
|
|
914
|
+
const answered = xhr.status >= 200 && xhr.status < 300;
|
|
915
|
+
finish(() => resolve(answerOf(xhr.status, name => xhr.getResponseHeader(name), answered ? '' : xhr.responseText)));
|
|
659
916
|
};
|
|
660
917
|
|
|
661
|
-
xhr.onerror = () =>
|
|
662
|
-
xhr.onabort = () => finish(() => reject(new TusUploadError('Chunk upload aborted', { phase: 'cancelled' })));
|
|
663
|
-
xhr.ontimeout = () =>
|
|
918
|
+
xhr.onerror = () => unanswered('Chunk upload network error', 'network');
|
|
919
|
+
xhr.onabort = () => finish(() => reject(new TusUploadError('Chunk upload aborted', { ...context, phase: 'cancelled' })));
|
|
920
|
+
xhr.ontimeout = () => unanswered('Chunk upload timed out', 'timeout');
|
|
664
921
|
|
|
665
922
|
xhr.open('PATCH', uploadUrl);
|
|
666
923
|
xhr.setRequestHeader('Tus-Resumable', TUS_VERSION);
|
|
@@ -682,64 +939,60 @@ function uploadChunk(
|
|
|
682
939
|
|
|
683
940
|
/**
|
|
684
941
|
* PATCH a whole file as the session's only chunk on the app's background
|
|
685
|
-
* URLSession
|
|
686
|
-
*
|
|
687
|
-
*
|
|
942
|
+
* URLSession and resolve with Forge's answer, or with no answer when the
|
|
943
|
+
* adapter got none. The OS owns the transfer, so it finishes even if iOS
|
|
944
|
+
* suspends the app; the trade is no progress events and no abort. Its answer
|
|
945
|
+
* is read exactly like the XHR path's, so callers see one error taxonomy.
|
|
688
946
|
*/
|
|
689
|
-
async function
|
|
947
|
+
async function sendWholeFileInBackground(
|
|
690
948
|
uploader: MobileBackgroundUploader,
|
|
691
|
-
|
|
692
|
-
istk: string,
|
|
949
|
+
transfer: TusTransfer,
|
|
693
950
|
fileUri: string,
|
|
694
951
|
offset: number
|
|
695
|
-
): Promise<
|
|
952
|
+
): Promise<TransferAnswer> {
|
|
696
953
|
let result: Awaited<ReturnType<MobileBackgroundUploader['uploadFile']>>;
|
|
697
954
|
try {
|
|
698
|
-
result = await uploader.uploadFile(uploadUrl, fileUri, {
|
|
955
|
+
result = await uploader.uploadFile(transfer.uploadUrl, fileUri, {
|
|
699
956
|
method: 'PATCH',
|
|
700
957
|
headers: {
|
|
701
958
|
'Tus-Resumable': TUS_VERSION,
|
|
702
959
|
'Upload-Offset': String(offset),
|
|
703
960
|
'Content-Type': 'application/offset+octet-stream',
|
|
704
|
-
'X-ISTK': istk,
|
|
961
|
+
'X-ISTK': transfer.istk,
|
|
705
962
|
},
|
|
706
963
|
});
|
|
707
964
|
} catch (err) {
|
|
708
965
|
// The adapter only throws when no HTTP response came back (offline, DNS,
|
|
709
966
|
// the OS cancelled the task); an HTTP failure is a status below.
|
|
710
|
-
|
|
967
|
+
return noAnswer(new TusUploadError(`Background chunk upload failed: ${getErrorMessage(err)}`, {
|
|
711
968
|
cause: err instanceof Error ? err : null,
|
|
969
|
+
...transfer.context,
|
|
712
970
|
phase: 'network',
|
|
713
|
-
});
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
const body = typeof result.body === 'string' ? result.body : '';
|
|
717
|
-
if (result.status >= 200 && result.status < 300) {
|
|
718
|
-
// Forge always answers a PATCH with a readable Upload-Offset. A 2xx
|
|
719
|
-
// without one is not Forge (a captive portal's 200 page is the classic
|
|
720
|
-
// case), so report no advance: the stall guard fails the attempt and the
|
|
721
|
-
// next one asks Forge (HEAD) instead of marking bytes uploaded that may
|
|
722
|
-
// never have arrived. Same strict parse as the XHR path (uploadChunk).
|
|
723
|
-
return parseIntegerHeader(getHeaderIgnoreCase(result.headers, 'Upload-Offset'), 0) ?? offset;
|
|
971
|
+
}));
|
|
724
972
|
}
|
|
725
973
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
974
|
+
if (!Number.isSafeInteger(result.status) || result.status <= 0) {
|
|
975
|
+
return noAnswer(new TusUploadError(`Background chunk upload got no answer (status ${String(result.status)})`, {
|
|
976
|
+
...transfer.context,
|
|
977
|
+
phase: 'network',
|
|
978
|
+
}));
|
|
729
979
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
980
|
+
const answered = result.status >= 200 && result.status < 300;
|
|
981
|
+
return answerOf(
|
|
982
|
+
result.status,
|
|
983
|
+
name => getHeaderIgnoreCase(result.headers, name),
|
|
984
|
+
!answered && typeof result.body === 'string' ? result.body : ''
|
|
985
|
+
);
|
|
734
986
|
}
|
|
735
987
|
|
|
736
988
|
/**
|
|
737
989
|
* Translate what the OS transfer adapter reports into the SDK's one error
|
|
738
990
|
* taxonomy, so a caller sees the same `TusUploadError` phases and typed gate
|
|
739
991
|
* errors whichever engine carried the bytes. Forge's status and body ride
|
|
740
|
-
* through untouched:
|
|
741
|
-
*
|
|
742
|
-
*
|
|
992
|
+
* through untouched: the refusal Core decided is `upload_refused` in Core's
|
|
993
|
+
* words as on the XHR path, a 402 becomes the gate error the XHR path
|
|
994
|
+
* throws, a status `isSessionGone` recognises marks the session lost exactly
|
|
995
|
+
* as HEAD would, and any other status keeps its number for the caller's own policy
|
|
743
996
|
* (a 409 during finalize is a retry-later, not a lost session). The
|
|
744
997
|
* remaining kinds are transport
|
|
745
998
|
* outcomes: the caller's own abort is `cancelled`; an OS-ended task
|
|
@@ -765,9 +1018,18 @@ export function mapBackgroundTransferError(
|
|
|
765
1018
|
const status = err.httpStatus;
|
|
766
1019
|
const body = err.body ?? '';
|
|
767
1020
|
if (status !== undefined) {
|
|
768
|
-
const
|
|
1021
|
+
const envelope = parseErrorBody(body);
|
|
1022
|
+
const outcome = Number.isSafeInteger(status) && status > 0
|
|
1023
|
+
? classifyTransferResponse({ status, details: envelope.error?.details ?? null })
|
|
1024
|
+
: null;
|
|
1025
|
+
if (outcome?.verdict === TRANSFER_VERDICT.REFUSED && outcome.coreCode !== null) {
|
|
1026
|
+
return refusedError(status, envelope, outcome.coreCode, context, 'upload', err);
|
|
1027
|
+
}
|
|
1028
|
+
const gateError = gateErrorFromBody(status, envelope);
|
|
769
1029
|
if (gateError) return gateError;
|
|
770
|
-
if (
|
|
1030
|
+
if (outcome?.verdict === TRANSFER_VERDICT.GONE) {
|
|
1031
|
+
return sessionLostError(status, context, 'upload', err, outcome.coreCode);
|
|
1032
|
+
}
|
|
771
1033
|
}
|
|
772
1034
|
return new TusUploadError(
|
|
773
1035
|
`Background chunk upload failed: ${status ?? 'unknown status'} - ${body.substring(0, 200)}`,
|
|
@@ -831,6 +1093,29 @@ export class MobileTusUploader {
|
|
|
831
1093
|
this.config = config;
|
|
832
1094
|
}
|
|
833
1095
|
|
|
1096
|
+
/**
|
|
1097
|
+
* Upload a file over tus: create the session (or resume a kept one), send
|
|
1098
|
+
* the bytes Forge does not hold, and resolve once Forge has confirmed the
|
|
1099
|
+
* ingot published.
|
|
1100
|
+
*
|
|
1101
|
+
* Every answer is read through the platform's transfer policy
|
|
1102
|
+
* (`@sparkvault/app-sdk/transfer-retry`), and one budget per call bounds
|
|
1103
|
+
* every retry, the create's included: a request that got no answer, 408,
|
|
1104
|
+
* 425, 429, a 5xx or a lease-held 409 waits its backoff (never shorter than
|
|
1105
|
+
* `Retry-After`), then asks Forge where the session stands (HEAD) and sends
|
|
1106
|
+
* from there; a 409 carrying `Upload-Offset` continues from that offset at
|
|
1107
|
+
* once; a receipt Forge defers (503 with `Retry-After`) is asked again,
|
|
1108
|
+
* each time after a longer backoff, without spending a failure until the
|
|
1109
|
+
* idle limit ends the upload as `upload_received_publishing`
|
|
1110
|
+
* (`retryLater`); a gone session is `sessionLost` (`coreCode` naming the
|
|
1111
|
+
* code Core ended it with, when the answer named one); a final refusal of
|
|
1112
|
+
* Core's (`UPLOAD_POOL_FULL`, `UPLOAD_SUPERSEDED`) is `upload_refused` in
|
|
1113
|
+
* Core's words (`coreCode`); any other 4xx ends the upload with the
|
|
1114
|
+
* server's answer. No chunk is ever sent at an offset the server did not just name. While the device is offline (`isOnline`), a request that got
|
|
1115
|
+
* no answer spends nothing: the upload waits for the network. The OS
|
|
1116
|
+
* transfer engine's range is the exception: the engine chains its own
|
|
1117
|
+
* requests, and its failure ends the call for the caller's own retry.
|
|
1118
|
+
*/
|
|
834
1119
|
async uploadFromUri(options: TusUploadFromUriOptions): Promise<void> {
|
|
835
1120
|
const fileReader = this.config.fileReader;
|
|
836
1121
|
if (!fileReader) {
|
|
@@ -841,61 +1126,65 @@ export class MobileTusUploader {
|
|
|
841
1126
|
const istk = source.mode === 'create' ? source.parsed.istk : source.istk;
|
|
842
1127
|
const debug = options.debug;
|
|
843
1128
|
const cleanUri = options.fileUri.split('#')[0];
|
|
844
|
-
|
|
1129
|
+
const signal = options.abortSignal;
|
|
1130
|
+
const fileSize = options.fileSize;
|
|
1131
|
+
const context: TusErrorContext = { filename: options.filename, fileSize };
|
|
1132
|
+
const budget = createTransferBudget();
|
|
845
1133
|
// Known up front on resume so a cancel during the HEAD still terminates
|
|
846
1134
|
// the (existing) session.
|
|
847
1135
|
let uploadUrl: string | null = source.mode === 'resume' ? source.uploadUrl : null;
|
|
1136
|
+
// The last offset the caller heard, so the end of the upload reports 100%
|
|
1137
|
+
// exactly once however the last byte was confirmed.
|
|
1138
|
+
let reported: number | null = null;
|
|
1139
|
+
const report = (uploaded: number) => {
|
|
1140
|
+
reported = uploaded;
|
|
1141
|
+
options.onProgress?.(uploaded, fileSize);
|
|
1142
|
+
};
|
|
848
1143
|
|
|
849
1144
|
try {
|
|
850
|
-
let
|
|
851
|
-
let
|
|
1145
|
+
let transfer: TusTransfer;
|
|
1146
|
+
let position: TransferPosition;
|
|
852
1147
|
if (source.mode === 'resume') {
|
|
853
|
-
sessionUrl = source.uploadUrl;
|
|
854
1148
|
debug?.log(`Resuming TUS upload for ${options.filename}`);
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
// `sessionLost` means: the caller drops it and starts fresh, the same
|
|
864
|
-
// as for a 404/410, instead of retrying a resume that can never work.
|
|
865
|
-
if (probe.length !== options.fileSize) {
|
|
866
|
-
throw new TusUploadError(
|
|
867
|
-
`TUS session Upload-Length (${probe.length}) does not match the file size (${options.fileSize}); the session belongs to a different file`,
|
|
868
|
-
{ filename: options.filename, fileSize: options.fileSize, phase: 'create', sessionLost: true }
|
|
869
|
-
);
|
|
870
|
-
}
|
|
871
|
-
chunkSize = probe.chunkSize;
|
|
872
|
-
bytesUploaded = probe.offset;
|
|
873
|
-
debug?.log(`TUS session for ${options.filename} is at offset ${bytesUploaded}/${options.fileSize}`);
|
|
874
|
-
if (bytesUploaded >= options.fileSize) {
|
|
875
|
-
// Every byte is there, but the finalization the last chunk should
|
|
876
|
-
// have triggered may not have happened (that is often why the
|
|
877
|
-
// previous attempt failed): ask Forge to finalize and acknowledge.
|
|
878
|
-
await confirmTusFinalization(this.config, sessionUrl, istk, options.fileSize, options.abortSignal, {
|
|
879
|
-
filename: options.filename,
|
|
880
|
-
fileSize: options.fileSize,
|
|
1149
|
+
// A session past the expiry Forge named for it is gone: Forge refuses
|
|
1150
|
+
// every request to it, so asking would only spend one to learn that.
|
|
1151
|
+
const heldExpiry = source.expiresAt;
|
|
1152
|
+
if (heldExpiry !== null && isSessionExpired(heldExpiry)) {
|
|
1153
|
+
throw new TusUploadError(`TUS session expired at ${new Date(heldExpiry).toISOString()}`, {
|
|
1154
|
+
...context,
|
|
1155
|
+
phase: 'create',
|
|
1156
|
+
sessionLost: true,
|
|
881
1157
|
});
|
|
882
|
-
options.onProgress?.(bytesUploaded, options.fileSize);
|
|
883
1158
|
}
|
|
1159
|
+
const expiry = new SessionExpiry(source.expiresAt, options.onSessionExpiry);
|
|
1160
|
+
const link: SessionLink = { uploadUrl: source.uploadUrl, istk, context, expiry, signal };
|
|
1161
|
+
const probe = await this.readResumePoint(link, budget, fileSize);
|
|
1162
|
+
transfer = { ...link, expiry, fileSize, chunkSize: probe.chunkSize, budget };
|
|
1163
|
+
position = { offset: checkedServerOffset(probe.offset, transfer, 'create', context), published: false };
|
|
1164
|
+
debug?.log(`TUS session for ${options.filename} is at offset ${position.offset}/${fileSize}`);
|
|
884
1165
|
} else {
|
|
885
1166
|
debug?.log(`Starting TUS upload for ${options.filename}`);
|
|
886
|
-
const created = await
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1167
|
+
const created = await this.createSession(source.parsed, fileSize, budget, context, signal);
|
|
1168
|
+
uploadUrl = created.uploadUrl;
|
|
1169
|
+
transfer = {
|
|
1170
|
+
uploadUrl: created.uploadUrl,
|
|
1171
|
+
istk,
|
|
1172
|
+
context,
|
|
1173
|
+
expiry: new SessionExpiry(created.expiresAt, options.onSessionExpiry),
|
|
1174
|
+
signal,
|
|
1175
|
+
fileSize,
|
|
1176
|
+
chunkSize: created.chunkSize,
|
|
1177
|
+
budget,
|
|
1178
|
+
};
|
|
1179
|
+
position = { offset: 0, published: false };
|
|
896
1180
|
// Before the first PATCH: once bytes are in flight the session is
|
|
897
1181
|
// worth resuming, so the caller must already hold it.
|
|
898
|
-
options.onSessionCreated?.({
|
|
1182
|
+
options.onSessionCreated?.({
|
|
1183
|
+
uploadUrl: created.uploadUrl,
|
|
1184
|
+
istk,
|
|
1185
|
+
chunkSize: created.chunkSize,
|
|
1186
|
+
expiresAt: created.expiresAt,
|
|
1187
|
+
});
|
|
899
1188
|
}
|
|
900
1189
|
|
|
901
1190
|
// The OS transfer engine, when the app provides one, takes the whole
|
|
@@ -907,83 +1196,59 @@ export class MobileTusUploader {
|
|
|
907
1196
|
// that resumes the same session re-joins the job the OS is still
|
|
908
1197
|
// running instead of PATCHing over it.
|
|
909
1198
|
const backgroundTransfer = options.transport === 'background' ? this.config.backgroundTransfer : undefined;
|
|
910
|
-
if (backgroundTransfer &&
|
|
911
|
-
ensureNotAborted(
|
|
1199
|
+
if (backgroundTransfer && position.offset < fileSize) {
|
|
1200
|
+
ensureNotAborted(signal);
|
|
1201
|
+
transfer.expiry.forget();
|
|
912
1202
|
const policy = options.transferPolicy ?? {};
|
|
913
1203
|
const finalOffset = await transferRemainderInBackground(
|
|
914
1204
|
backgroundTransfer,
|
|
915
1205
|
{
|
|
916
|
-
uploadUrl:
|
|
1206
|
+
uploadUrl: transfer.uploadUrl,
|
|
917
1207
|
istk,
|
|
918
1208
|
fileUri: cleanUri,
|
|
919
|
-
fileSize
|
|
920
|
-
chunkSize,
|
|
921
|
-
offset:
|
|
1209
|
+
fileSize,
|
|
1210
|
+
chunkSize: transfer.chunkSize,
|
|
1211
|
+
offset: position.offset,
|
|
922
1212
|
allowsCellularAccess: policy.allowsCellularAccess ?? true,
|
|
923
1213
|
allowsConstrainedNetworkAccess: policy.allowsConstrainedNetworkAccess ?? true,
|
|
924
1214
|
},
|
|
925
|
-
{ filename: options.filename, abortSignal:
|
|
1215
|
+
{ filename: options.filename, abortSignal: signal, onProgress: offset => report(offset) }
|
|
926
1216
|
);
|
|
927
1217
|
// The engine only resolves at the end of the file; anything else is
|
|
928
1218
|
// an engine that gave up quietly, and an offset past the file is as
|
|
929
1219
|
// untrustworthy as one short of it. Same treatment as a non-advancing
|
|
930
1220
|
// PATCH: fail, and let the next attempt ask Forge (HEAD) where the
|
|
931
1221
|
// session really stands.
|
|
932
|
-
if (finalOffset !==
|
|
1222
|
+
if (finalOffset !== fileSize) {
|
|
933
1223
|
throw new TusUploadError(
|
|
934
|
-
`Upload stalled: background transfer ended at offset ${finalOffset} of ${
|
|
935
|
-
{ phase: 'stalled'
|
|
1224
|
+
`Upload stalled: background transfer ended at offset ${finalOffset} of ${fileSize}`,
|
|
1225
|
+
{ ...context, phase: 'stalled' }
|
|
936
1226
|
);
|
|
937
1227
|
}
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
1228
|
+
// The engine's last PATCH was answered 2xx at the final offset, which
|
|
1229
|
+
// Forge answers only once the ingot is published. An abort that lands
|
|
1230
|
+
// after the OS delivered the last byte changes nothing: the transfer
|
|
1231
|
+
// is complete, and the loop below (with its abort check) has nothing
|
|
1232
|
+
// left to do. Same rule as the single-chunk adapter: a finished
|
|
1233
|
+
// upload is never reported as cancelled.
|
|
1234
|
+
position = { offset: fileSize, published: true };
|
|
944
1235
|
}
|
|
945
1236
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
// cancelled, and a DELETE now would drop bytes Forge has already
|
|
960
|
-
// finalized. Only a response that leaves bytes missing reaches
|
|
961
|
-
// the `ensureNotAborted` at the top of the next iteration.
|
|
962
|
-
newOffset = await uploadWholeFileInBackground(backgroundUploader, sessionUrl, istk, cleanUri, chunkStart);
|
|
963
|
-
} else {
|
|
964
|
-
const chunk = await this.readChunk(fileReader, cleanUri, chunkStart, chunkLength);
|
|
965
|
-
newOffset = await uploadChunk(
|
|
966
|
-
sessionUrl,
|
|
967
|
-
istk,
|
|
968
|
-
chunk,
|
|
969
|
-
chunkStart,
|
|
970
|
-
this.config.tusChunkTimeoutMs,
|
|
971
|
-
options.abortSignal,
|
|
972
|
-
chunkUploaded => options.onProgress?.(chunkStart + chunkUploaded, options.fileSize)
|
|
973
|
-
);
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
if (newOffset <= chunkStart || newOffset > options.fileSize) {
|
|
977
|
-
throw new TusUploadError('Upload stalled: server offset did not advance', {
|
|
978
|
-
phase: 'stalled',
|
|
979
|
-
filename: options.filename,
|
|
980
|
-
fileSize: options.fileSize,
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
bytesUploaded = newOffset;
|
|
985
|
-
options.onProgress?.(bytesUploaded, options.fileSize);
|
|
1237
|
+
const chunks: ChunkSource = {
|
|
1238
|
+
reader: fileReader,
|
|
1239
|
+
uri: cleanUri,
|
|
1240
|
+
transport: options.transport,
|
|
1241
|
+
onChunkProgress: (offset, chunkBytesUploaded) => report(offset + chunkBytesUploaded),
|
|
1242
|
+
};
|
|
1243
|
+
while (!position.published) {
|
|
1244
|
+
ensureNotAborted(signal);
|
|
1245
|
+
const before = position.offset;
|
|
1246
|
+
position = position.offset < fileSize
|
|
1247
|
+
? await this.sendChunkAt(transfer, position.offset, chunks)
|
|
1248
|
+
: await this.confirmReceipt(transfer);
|
|
1249
|
+
if (position.offset > before) report(position.offset);
|
|
986
1250
|
}
|
|
1251
|
+
if (reported !== fileSize) report(fileSize);
|
|
987
1252
|
|
|
988
1253
|
debug?.success(`TUS upload completed for ${options.filename}`);
|
|
989
1254
|
} catch (err) {
|
|
@@ -1012,10 +1277,11 @@ export class MobileTusUploader {
|
|
|
1012
1277
|
// the caller intact so the subscribe / add-on UX fires; never bury them
|
|
1013
1278
|
// in a generic TusUploadError.
|
|
1014
1279
|
if (err instanceof SparkVaultMobileError) throw err;
|
|
1015
|
-
// Only a fileReader read or
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1280
|
+
// Only a fileReader read or a caller's hook (onSessionCreated,
|
|
1281
|
+
// onSessionExpiry, onProgress) can throw anything else here: every
|
|
1282
|
+
// request classifies its own failure (tusRequest, the chunk senders,
|
|
1283
|
+
// the adapters). Those are the caller's errors, wrapped with the file
|
|
1284
|
+
// context and nothing inferred about them.
|
|
1019
1285
|
throw TusUploadError.fromError(error, {
|
|
1020
1286
|
filename: options.filename,
|
|
1021
1287
|
fileSize: options.fileSize,
|
|
@@ -1023,27 +1289,354 @@ export class MobileTusUploader {
|
|
|
1023
1289
|
}
|
|
1024
1290
|
}
|
|
1025
1291
|
|
|
1292
|
+
/**
|
|
1293
|
+
* Open the upload's session. A create the transfer policy calls RETRY (no
|
|
1294
|
+
* answer, 408, 425, 429, a 5xx) is sent again under the upload's budget,
|
|
1295
|
+
* its backoff never shorter than `Retry-After`: Forge refuses such a create
|
|
1296
|
+
* before it claims the token, or hands the claim back on a transient
|
|
1297
|
+
* key-service failure (503), so the same ISTK opens the session on a later
|
|
1298
|
+
* attempt. Every other answer is final and never a lost session, a 401
|
|
1299
|
+
* included: the token was already used, by an earlier create whose answer
|
|
1300
|
+
* was lost, and no Location survives to continue that session from. No
|
|
1301
|
+
* session URL exists yet to spread the retries by, and the ISTK never feeds
|
|
1302
|
+
* one, so they spread by a key of the endpoint (`transferJitterKey`).
|
|
1303
|
+
*/
|
|
1304
|
+
private async createSession(
|
|
1305
|
+
parsed: ParsedForgeUrl,
|
|
1306
|
+
fileSize: number,
|
|
1307
|
+
budget: TransferBudget,
|
|
1308
|
+
context: TusErrorContext,
|
|
1309
|
+
signal?: AbortSignal
|
|
1310
|
+
): Promise<{ uploadUrl: string; chunkSize: number; expiresAt: number | null }> {
|
|
1311
|
+
const jitterKey = transferJitterKey(parsed.tusEndpoint);
|
|
1312
|
+
for (;;) {
|
|
1313
|
+
ensureNotAborted(signal);
|
|
1314
|
+
// Forge's create contract is these three headers: the ingot's name and
|
|
1315
|
+
// type were set by Core when the ingot was created, and Forge publishes
|
|
1316
|
+
// Core's.
|
|
1317
|
+
const answer = await fetchAnswer(
|
|
1318
|
+
this.config,
|
|
1319
|
+
parsed.tusEndpoint,
|
|
1320
|
+
{
|
|
1321
|
+
method: 'POST',
|
|
1322
|
+
headers: {
|
|
1323
|
+
'Tus-Resumable': TUS_VERSION,
|
|
1324
|
+
'Upload-Length': String(fileSize),
|
|
1325
|
+
'X-ISTK': parsed.istk,
|
|
1326
|
+
},
|
|
1327
|
+
signal,
|
|
1328
|
+
timeoutMs: this.config.tusPostTimeoutMs,
|
|
1329
|
+
},
|
|
1330
|
+
'TUS POST request',
|
|
1331
|
+
context
|
|
1332
|
+
);
|
|
1333
|
+
const outcome = readOutcome(answer, CREATE_REQUEST);
|
|
1334
|
+
if (outcome.verdict === TRANSFER_VERDICT.OK) {
|
|
1335
|
+
const created = readCreatedSession(answer, parsed.tusEndpoint, context);
|
|
1336
|
+
// The session exists: the server confirmed progress.
|
|
1337
|
+
budget.progressed();
|
|
1338
|
+
return created;
|
|
1339
|
+
}
|
|
1340
|
+
const failure = failureOf(answer, outcome, CREATE_REQUEST, context);
|
|
1341
|
+
if (outcome.verdict !== TRANSFER_VERDICT.RETRY) throw failure.error;
|
|
1342
|
+
await this.waitToRetry(budget, failure, jitterKey, signal);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Where a kept session stands, asked of Forge (HEAD) under the upload's
|
|
1348
|
+
* budget, so resume never re-sends bytes the server holds: it is the only
|
|
1349
|
+
* way to learn what a background transfer delivered after the process was
|
|
1350
|
+
* killed, and what a failed attempt left behind. A HEAD that gets no
|
|
1351
|
+
* answer or a transient one is asked again after its backoff; a gone
|
|
1352
|
+
* session is `sessionLost`, so the caller drops it and creates a fresh one
|
|
1353
|
+
* rather than retrying a dead URL. A session is bound to one file: a length that
|
|
1354
|
+
* is not this file's means the persisted session belongs to another file
|
|
1355
|
+
* (or this file changed), and PATCHing into it would corrupt the ingot.
|
|
1356
|
+
* That makes it as worthless as a gone one: `sessionLost` too.
|
|
1357
|
+
*/
|
|
1358
|
+
private async readResumePoint(link: SessionLink, budget: TransferBudget, fileSize: number): Promise<TusSessionProbe> {
|
|
1359
|
+
for (;;) {
|
|
1360
|
+
const head = await this.headSession(link, RESUME_HEAD_REQUEST);
|
|
1361
|
+
if ('failure' in head) {
|
|
1362
|
+
await this.waitToRetry(budget, head.failure, link.uploadUrl, link.signal);
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
if (head.probe.length !== fileSize) {
|
|
1366
|
+
throw new TusUploadError(
|
|
1367
|
+
`TUS session Upload-Length (${head.probe.length}) does not match the file size (${fileSize}); the session belongs to a different file`,
|
|
1368
|
+
{ ...link.context, phase: 'create', sessionLost: true }
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
return head.probe;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Ask Forge once where the session stands (tus HEAD): the probe on a 2xx,
|
|
1377
|
+
* or the failure its answer amounts to. Forge re-drives a received
|
|
1378
|
+
* upload's publication on its HEAD and answers the full offset whatever the
|
|
1379
|
+
* re-drive came to, so an offset at the total proves receipt only: the
|
|
1380
|
+
* receipt PATCH (`confirmReceipt`) is what says published.
|
|
1381
|
+
*/
|
|
1382
|
+
private async headSession(
|
|
1383
|
+
link: SessionLink,
|
|
1384
|
+
request: TusRequestKind
|
|
1385
|
+
): Promise<{ probe: TusSessionProbe } | { failure: TransferFailure }> {
|
|
1386
|
+
ensureNotAborted(link.signal);
|
|
1387
|
+
const answer = await fetchAnswer(
|
|
1388
|
+
this.config,
|
|
1389
|
+
link.uploadUrl,
|
|
1390
|
+
{
|
|
1391
|
+
method: 'HEAD',
|
|
1392
|
+
headers: {
|
|
1393
|
+
'Tus-Resumable': TUS_VERSION,
|
|
1394
|
+
'X-ISTK': link.istk,
|
|
1395
|
+
},
|
|
1396
|
+
signal: link.signal,
|
|
1397
|
+
timeoutMs: HEAD_AND_RECEIPT_TIMEOUT_MS,
|
|
1398
|
+
},
|
|
1399
|
+
'TUS HEAD request',
|
|
1400
|
+
link.context
|
|
1401
|
+
);
|
|
1402
|
+
link.expiry?.read(answer);
|
|
1403
|
+
const outcome = readOutcome(answer, request);
|
|
1404
|
+
if (outcome.verdict === TRANSFER_VERDICT.OK) {
|
|
1405
|
+
return { probe: readSessionProbe(answer, request.phase, link.context) };
|
|
1406
|
+
}
|
|
1407
|
+
return { failure: failureOf(answer, outcome, request, link.context) };
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Send the chunk that starts at `offset`. Answers where the upload stands
|
|
1412
|
+
* next: past the chunk on its 2xx, which for the last chunk is also the
|
|
1413
|
+
* publication receipt (Forge answers it only once the ingot is published),
|
|
1414
|
+
* or wherever the server says after a failure.
|
|
1415
|
+
*/
|
|
1416
|
+
private async sendChunkAt(transfer: TusTransfer, offset: number, chunks: ChunkSource): Promise<TransferPosition> {
|
|
1417
|
+
const answer = await this.sendChunk(transfer, offset, chunks);
|
|
1418
|
+
transfer.expiry.read(answer);
|
|
1419
|
+
const outcome = readOutcome(answer, CHUNK_REQUEST);
|
|
1420
|
+
if (outcome.verdict !== TRANSFER_VERDICT.OK) {
|
|
1421
|
+
return this.positionAfterFailure(transfer, failureOf(answer, outcome, CHUNK_REQUEST, transfer.context), offset);
|
|
1422
|
+
}
|
|
1423
|
+
// Forge answers every PATCH with Upload-Offset, so a 2xx without a
|
|
1424
|
+
// readable one is not Forge (a captive portal's 200 page is the classic
|
|
1425
|
+
// case): it reports no advance, and the stall guard fails the attempt
|
|
1426
|
+
// rather than crediting bytes that may never have arrived. The parse is
|
|
1427
|
+
// strict because parseInt would turn a garbage header into NaN, which
|
|
1428
|
+
// slips through the guard (every comparison with NaN is false) and would
|
|
1429
|
+
// end the upload as a success; an offset past the file is as
|
|
1430
|
+
// untrustworthy as one short of it.
|
|
1431
|
+
const confirmed = parseIntegerHeader(answer.header('Upload-Offset'), 0) ?? offset;
|
|
1432
|
+
if (confirmed <= offset || confirmed > transfer.fileSize) {
|
|
1433
|
+
throw new TusUploadError('Upload stalled: server offset did not advance', {
|
|
1434
|
+
...transfer.context,
|
|
1435
|
+
phase: 'stalled',
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
transfer.budget.progressed();
|
|
1439
|
+
return { offset: confirmed, published: confirmed === transfer.fileSize };
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/** PATCH the chunk at `offset` on the transport the upload rides, resolving with Forge's answer. */
|
|
1443
|
+
private async sendChunk(transfer: TusTransfer, offset: number, chunks: ChunkSource): Promise<TransferAnswer> {
|
|
1444
|
+
const backgroundUploader = chunks.transport === 'background' ? this.config.backgroundUploader : undefined;
|
|
1445
|
+
if (backgroundUploader && this.fitsOneBackgroundRequest(offset, transfer.fileSize, transfer.chunkSize)) {
|
|
1446
|
+
// The OS owns this request, so an abort that fires while it is in
|
|
1447
|
+
// flight cannot stop it, and it may finish while the app is away. It
|
|
1448
|
+
// only ever carries a whole single-chunk file, so a 2xx here means the
|
|
1449
|
+
// transfer is complete: the loop ends and the upload finishes normally
|
|
1450
|
+
// (the caller verifies the ingot), because a finished upload must never
|
|
1451
|
+
// be reported as cancelled, and a DELETE now would drop bytes Forge has
|
|
1452
|
+
// already finalized. Only an answer that leaves bytes missing reaches
|
|
1453
|
+
// the abort check before the next request.
|
|
1454
|
+
transfer.expiry.forget();
|
|
1455
|
+
return sendWholeFileInBackground(backgroundUploader, transfer, chunks.uri, offset);
|
|
1456
|
+
}
|
|
1457
|
+
const chunkLength = Math.min(transfer.chunkSize, transfer.fileSize - offset);
|
|
1458
|
+
const chunk = await this.readChunk(chunks.reader, chunks.uri, offset, chunkLength);
|
|
1459
|
+
return sendChunkOverXhr(
|
|
1460
|
+
transfer,
|
|
1461
|
+
chunk,
|
|
1462
|
+
offset,
|
|
1463
|
+
this.config.tusChunkTimeoutMs,
|
|
1464
|
+
chunkBytesUploaded => chunks.onChunkProgress(offset, chunkBytesUploaded)
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Where the upload stands after a failed chunk PATCH, in the server's
|
|
1470
|
+
* words: a 409's own `Upload-Offset` at once, otherwise the session's HEAD
|
|
1471
|
+
* after the backoff, the HEAD itself retried the same way out of the same
|
|
1472
|
+
* budget. A PATCH whose answer was lost may have landed, and one sent at a
|
|
1473
|
+
* stale offset is refused, so no chunk is ever sent again at an offset the
|
|
1474
|
+
* server did not just name.
|
|
1475
|
+
*/
|
|
1476
|
+
private async positionAfterFailure(
|
|
1477
|
+
transfer: TusTransfer,
|
|
1478
|
+
failure: TransferFailure,
|
|
1479
|
+
sentOffset: number
|
|
1480
|
+
): Promise<TransferPosition> {
|
|
1481
|
+
let current = failure;
|
|
1482
|
+
for (;;) {
|
|
1483
|
+
if (current.outcome.verdict === TRANSFER_VERDICT.RESYNC) {
|
|
1484
|
+
return this.resyncedPosition(transfer, current, sentOffset);
|
|
1485
|
+
}
|
|
1486
|
+
await this.waitToRetry(transfer.budget, current, transfer.uploadUrl, transfer.signal);
|
|
1487
|
+
const head = await this.headSession(transfer, OFFSET_HEAD_REQUEST);
|
|
1488
|
+
if ('failure' in head) {
|
|
1489
|
+
current = head.failure;
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
if (head.probe.length !== transfer.fileSize) {
|
|
1493
|
+
throw new TusUploadError(
|
|
1494
|
+
`TUS session Upload-Length (${head.probe.length}) does not match the file size (${transfer.fileSize}); the session belongs to a different file`,
|
|
1495
|
+
{ ...transfer.context, phase: 'upload', sessionLost: true }
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
const offset = checkedServerOffset(head.probe.offset, transfer, 'upload', transfer.context);
|
|
1499
|
+
// Bytes reached the server despite the failure, so the transfer is
|
|
1500
|
+
// alive: the budget starts over rather than being spent down across
|
|
1501
|
+
// the many chunks of a large file.
|
|
1502
|
+
if (offset > sentOffset) transfer.budget.progressed();
|
|
1503
|
+
return { offset, published: false };
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* Continue from the offset a 409 named, at once and without a HEAD: the
|
|
1509
|
+
* server just said where the session stands. Past what this upload sent,
|
|
1510
|
+
* the server holds more than it knew, which is progress; anywhere else the
|
|
1511
|
+
* answer contradicts the request, so it spends the budget like any failure
|
|
1512
|
+
* and repeating it ends the upload.
|
|
1513
|
+
*/
|
|
1514
|
+
private resyncedPosition(transfer: TusTransfer, failure: TransferFailure, sentOffset: number): TransferPosition {
|
|
1515
|
+
const offset = checkedServerOffset(failure.outcome.offset, transfer, 'upload', transfer.context);
|
|
1516
|
+
if (offset > sentOffset) {
|
|
1517
|
+
transfer.budget.progressed();
|
|
1518
|
+
} else if (!transfer.budget.failed(TRANSFER_VERDICT.RESYNC)) {
|
|
1519
|
+
throw failure.error;
|
|
1520
|
+
}
|
|
1521
|
+
return { offset, published: false };
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Every byte is on the server but publication is unconfirmed: the final
|
|
1526
|
+
* chunk's answer was lost or deferred, or a HEAD's offset proved receipt
|
|
1527
|
+
* only. A zero-length PATCH at the final offset re-drives publication if it
|
|
1528
|
+
* did not finish (a transient Core or billing failure after the last chunk)
|
|
1529
|
+
* and answers 204 once the ingot is published, which is the receipt every
|
|
1530
|
+
* upload resolves on. While another request finalizes it answers 409 with
|
|
1531
|
+
* `Retry-After`, asked again as a failure; while the publication is
|
|
1532
|
+
* deferred, 503 with `Retry-After`, asked again, each time after a longer
|
|
1533
|
+
* backoff, without spending a failure until the idle limit ends the upload
|
|
1534
|
+
* as `upload_received_publishing` (`retryLater`); a 503 naming no
|
|
1535
|
+
* `Retry-After` is no deferral of Forge's, asked again as a failure. A 409
|
|
1536
|
+
* naming an earlier offset means bytes are missing, and the upload
|
|
1537
|
+
* continues from there. Forge answers it only after its own call to Core,
|
|
1538
|
+
* so it waits `HEAD_AND_RECEIPT_TIMEOUT_MS`, as a HEAD does.
|
|
1539
|
+
*/
|
|
1540
|
+
private async confirmReceipt(transfer: TusTransfer): Promise<TransferPosition> {
|
|
1541
|
+
ensureNotAborted(transfer.signal);
|
|
1542
|
+
const answer = await fetchAnswer(
|
|
1543
|
+
this.config,
|
|
1544
|
+
transfer.uploadUrl,
|
|
1545
|
+
{
|
|
1546
|
+
method: 'PATCH',
|
|
1547
|
+
headers: {
|
|
1548
|
+
'Tus-Resumable': TUS_VERSION,
|
|
1549
|
+
'Upload-Offset': String(transfer.fileSize),
|
|
1550
|
+
'Content-Type': 'application/offset+octet-stream',
|
|
1551
|
+
'X-ISTK': transfer.istk,
|
|
1552
|
+
},
|
|
1553
|
+
signal: transfer.signal,
|
|
1554
|
+
timeoutMs: HEAD_AND_RECEIPT_TIMEOUT_MS,
|
|
1555
|
+
},
|
|
1556
|
+
'TUS finalization request',
|
|
1557
|
+
transfer.context
|
|
1558
|
+
);
|
|
1559
|
+
transfer.expiry.read(answer);
|
|
1560
|
+
const outcome = readOutcome(answer, RECEIPT_REQUEST);
|
|
1561
|
+
if (outcome.verdict === TRANSFER_VERDICT.OK) {
|
|
1562
|
+
return { offset: transfer.fileSize, published: true };
|
|
1563
|
+
}
|
|
1564
|
+
const failure = failureOf(answer, outcome, RECEIPT_REQUEST, transfer.context);
|
|
1565
|
+
if (outcome.verdict === TRANSFER_VERDICT.RESYNC) {
|
|
1566
|
+
return this.resyncedPosition(transfer, failure, transfer.fileSize);
|
|
1567
|
+
}
|
|
1568
|
+
await this.waitToRetry(transfer.budget, failure, transfer.uploadUrl, transfer.signal);
|
|
1569
|
+
return { offset: transfer.fileSize, published: false };
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Wait out the backoff of a failed request, spending a retry of the
|
|
1574
|
+
* upload's budget, or of a deferred receipt, spending none for as long as
|
|
1575
|
+
* the idle limit allows; never shorter than the answer's `Retry-After`.
|
|
1576
|
+
* Throws the failure no retry follows (a gone session, the server's
|
|
1577
|
+
* verdict), and the last one, or the deferral, once the budget is spent. A
|
|
1578
|
+
* request that got no answer while the device is offline spends nothing:
|
|
1579
|
+
* the upload waits for the network (or the caller's abort), the idle clock
|
|
1580
|
+
* starts over when it is back, and the next request asks again at once.
|
|
1581
|
+
*/
|
|
1582
|
+
private async waitToRetry(
|
|
1583
|
+
budget: TransferBudget,
|
|
1584
|
+
failure: TransferFailure,
|
|
1585
|
+
jitterKey: string,
|
|
1586
|
+
signal?: AbortSignal
|
|
1587
|
+
): Promise<void> {
|
|
1588
|
+
const { verdict, retryAfterMs } = failure.outcome;
|
|
1589
|
+
if (verdict === TRANSFER_VERDICT.DEFERRED) {
|
|
1590
|
+
if (!budget.deferred(retryAfterMs)) throw failure.error;
|
|
1591
|
+
await abortableDelay(retryDelayMs({ attempt: budget.attempt, retryAfterMs, jitterKey }), signal);
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
if (verdict !== TRANSFER_VERDICT.RETRY) {
|
|
1595
|
+
throw failure.error;
|
|
1596
|
+
}
|
|
1597
|
+
const connectivity = this.config.connectivity;
|
|
1598
|
+
if (failure.unanswered && connectivity && !connectivity.isOnline()) {
|
|
1599
|
+
await waitUntilOnline(connectivity, signal);
|
|
1600
|
+
budget.alive();
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
if (!budget.failed(verdict, retryAfterMs)) {
|
|
1604
|
+
throw failure.error;
|
|
1605
|
+
}
|
|
1606
|
+
const delayMs = retryDelayMs({
|
|
1607
|
+
attempt: budget.attempt,
|
|
1608
|
+
retryAfterMs,
|
|
1609
|
+
jitterKey,
|
|
1610
|
+
});
|
|
1611
|
+
await abortableDelay(delayMs, signal);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1026
1614
|
/**
|
|
1027
1615
|
* Where a persisted session stands, or `null` when Forge no longer has it
|
|
1028
|
-
* (404/410 — expired or terminated). Any other
|
|
1029
|
-
* `sessionLost` false (a 5xx or timeout says nothing
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1616
|
+
* (404/410 — expired or terminated), from one HEAD and no retry. Any other
|
|
1617
|
+
* failure throws with `sessionLost` false (a 5xx or a timeout says nothing
|
|
1618
|
+
* against the session), carrying the answer's `retryAfterMs`; a final
|
|
1619
|
+
* refusal Core decided on the HEAD's re-drive (`UPLOAD_POOL_FULL` or
|
|
1620
|
+
* `UPLOAD_SUPERSEDED` in `X-Upload-Refusal`) throws as `upload_refused`,
|
|
1621
|
+
* which no fresh create remedies, and any other code Core ended the session
|
|
1622
|
+
* with reads as gone (null). An offset at
|
|
1623
|
+
* the session's length proves every byte received, not published: a
|
|
1624
|
+
* resume of it sends the receipt. The probe reports the session's own length;
|
|
1625
|
+
* whether that length is the caller's file is for the caller (or
|
|
1626
|
+
* `uploadFromUri`'s resume, which throws `sessionLost` on a mismatch) to
|
|
1627
|
+
* judge. Lets a caller reconcile sessions on relaunch (a completed one just
|
|
1628
|
+
* needs its ingot verified; a lost one needs a fresh create) before
|
|
1629
|
+
* spending an upload slot on it.
|
|
1035
1630
|
*
|
|
1036
1631
|
* @throws SparkVaultValidationError, before any request, when `uploadUrl`
|
|
1037
1632
|
* is not an upload session on an allowed transfer host.
|
|
1038
1633
|
*/
|
|
1039
1634
|
async probeSession(session: { uploadUrl: string; istk: string }): Promise<TusSessionProbe | null> {
|
|
1040
1635
|
const uploadUrl = readKeptSessionUrl(session.uploadUrl, this.config.allowedDownloadHostPatterns);
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
throw err;
|
|
1046
|
-
}
|
|
1636
|
+
const head = await this.headSession({ uploadUrl, istk: session.istk, context: {}, expiry: null }, RESUME_HEAD_REQUEST);
|
|
1637
|
+
if ('probe' in head) return head.probe;
|
|
1638
|
+
if (head.failure.outcome.verdict === TRANSFER_VERDICT.GONE) return null;
|
|
1639
|
+
throw head.failure.error;
|
|
1047
1640
|
}
|
|
1048
1641
|
|
|
1049
1642
|
/**
|