@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/config.ts
CHANGED
|
@@ -31,6 +31,11 @@ export interface SparkVaultMobileConfig {
|
|
|
31
31
|
userAgent?: string;
|
|
32
32
|
timeoutMs?: number;
|
|
33
33
|
fileTransferTimeoutMs?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Timeout of a tus create POST and DELETE. A HEAD and the receipt (the
|
|
36
|
+
* PATCH at the final offset) wait HEAD_AND_RECEIPT_TIMEOUT_MS instead:
|
|
37
|
+
* Forge answers either only after its own call to Core.
|
|
38
|
+
*/
|
|
34
39
|
tusPostTimeoutMs?: number;
|
|
35
40
|
/** Stall watchdog per tus chunk PATCH: aborts after this long with NO upload progress (not an absolute deadline). */
|
|
36
41
|
tusChunkTimeoutMs?: number;
|
|
@@ -53,6 +58,21 @@ export interface SparkVaultMobileConfig {
|
|
|
53
58
|
* upload, and every upload when this is unset, streams over XHR.
|
|
54
59
|
*/
|
|
55
60
|
backgroundUploader?: MobileBackgroundUploader;
|
|
61
|
+
/**
|
|
62
|
+
* Whether the device has a network path right now (an app wires it to its
|
|
63
|
+
* OS network signal). Read when an upload request got no answer: while it
|
|
64
|
+
* answers false the upload waits for `onOnline` instead of spending its
|
|
65
|
+
* retries, then asks Forge where the session stands. Only a definite
|
|
66
|
+
* "offline" should answer false; a path that may come up is online. Given
|
|
67
|
+
* together with `onOnline`, or not at all.
|
|
68
|
+
*/
|
|
69
|
+
isOnline?: () => boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Subscribe to the network coming back; returns the unsubscribe. The pair
|
|
72
|
+
* of `isOnline`. A wait it ends is otherwise ended only by the upload's
|
|
73
|
+
* `abortSignal`.
|
|
74
|
+
*/
|
|
75
|
+
onOnline?: (listener: () => void) => () => void;
|
|
56
76
|
logger?: Partial<SparkVaultLogger>;
|
|
57
77
|
/**
|
|
58
78
|
* Host allowlist for the backend-issued Forge transfer URLs the SDK sends an
|
|
@@ -62,6 +82,12 @@ export interface SparkVaultMobileConfig {
|
|
|
62
82
|
allowedDownloadHostPatterns?: RegExp[];
|
|
63
83
|
}
|
|
64
84
|
|
|
85
|
+
/** The device's network signal, as the upload's offline wait reads it (`isOnline` with `onOnline`). */
|
|
86
|
+
export interface MobileConnectivity {
|
|
87
|
+
isOnline(): boolean;
|
|
88
|
+
onOnline(listener: () => void): () => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
65
91
|
export interface ResolvedMobileConfig {
|
|
66
92
|
tokenStorage: TokenStorageAdapter;
|
|
67
93
|
apiBaseUrl: string;
|
|
@@ -78,10 +104,23 @@ export interface ResolvedMobileConfig {
|
|
|
78
104
|
fileDownloader?: MobileFileDownloader;
|
|
79
105
|
backgroundTransfer?: MobileBackgroundTransfer;
|
|
80
106
|
backgroundUploader?: MobileBackgroundUploader;
|
|
107
|
+
connectivity?: MobileConnectivity;
|
|
81
108
|
logger: SparkVaultLogger;
|
|
82
109
|
allowedDownloadHostPatterns: RegExp[];
|
|
83
110
|
}
|
|
84
111
|
|
|
112
|
+
/** The `isOnline` / `onOnline` pair as one signal, or none: one without the other cannot wait for the network. */
|
|
113
|
+
function resolveConnectivity(config: SparkVaultMobileConfig): MobileConnectivity | undefined {
|
|
114
|
+
const { isOnline, onOnline } = config;
|
|
115
|
+
if (isOnline === undefined && onOnline === undefined) return undefined;
|
|
116
|
+
if (typeof isOnline !== 'function' || typeof onOnline !== 'function') {
|
|
117
|
+
throw new SparkVaultValidationError('isOnline and onOnline are given together, as functions', {
|
|
118
|
+
field: typeof isOnline === 'function' ? 'onOnline' : 'isOnline',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return { isOnline, onOnline };
|
|
122
|
+
}
|
|
123
|
+
|
|
85
124
|
function normalizeBaseUrl(url: string, field: string): string {
|
|
86
125
|
if (!url || typeof url !== 'string') {
|
|
87
126
|
throw new SparkVaultValidationError(`${field} must be a non-empty string`, { field });
|
|
@@ -157,6 +196,7 @@ export function resolveMobileConfig(config: SparkVaultMobileConfig): ResolvedMob
|
|
|
157
196
|
fileDownloader: config.fileDownloader,
|
|
158
197
|
backgroundTransfer: config.backgroundTransfer,
|
|
159
198
|
backgroundUploader: config.backgroundUploader,
|
|
199
|
+
connectivity: resolveConnectivity(config),
|
|
160
200
|
logger: { ...noopLogger, ...config.logger },
|
|
161
201
|
allowedDownloadHostPatterns: config.allowedDownloadHostPatterns ?? [...SPARKVAULT_OWNED_HOST_PATTERNS],
|
|
162
202
|
};
|
package/src/errors.ts
CHANGED
|
@@ -181,28 +181,62 @@ export class TusUploadError extends SparkVaultMobileError {
|
|
|
181
181
|
readonly cause: Error | null;
|
|
182
182
|
readonly filename: string | null;
|
|
183
183
|
readonly fileSize: number | null;
|
|
184
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Where the upload stopped: `finalize` is after the transfer, when every
|
|
186
|
+
* byte was on Forge but the ingot was not confirmed published.
|
|
187
|
+
*/
|
|
188
|
+
readonly phase: 'create' | 'upload' | 'finalize' | 'network' | 'timeout' | 'cancelled' | 'stalled' | 'unknown';
|
|
185
189
|
/**
|
|
186
190
|
* True when Forge no longer has the session (it answered a status
|
|
187
|
-
* `isSessionGone` recognises
|
|
188
|
-
* cannot carry this file. The persisted session is worthless, so
|
|
189
|
-
* caller starts a fresh upload instead of retrying it.
|
|
191
|
+
* `isSessionGone` recognises, or the expiry it named has passed), or the
|
|
192
|
+
* session cannot carry this file. The persisted session is worthless, so
|
|
193
|
+
* the caller starts a fresh upload instead of retrying it.
|
|
190
194
|
*/
|
|
191
195
|
readonly sessionLost: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* The `Retry-After` of the answer that ended the upload, in ms: the
|
|
198
|
+
* earliest a next attempt can expect another answer. Null when that answer
|
|
199
|
+
* named none, or no answer arrived.
|
|
200
|
+
*/
|
|
201
|
+
readonly retryAfterMs: number | null;
|
|
202
|
+
/**
|
|
203
|
+
* True when every byte is on Forge and the session is intact but the ingot
|
|
204
|
+
* was not confirmed published: the vault did not read it `active` within
|
|
205
|
+
* the activation poll budget, or Forge was still saving the file at the
|
|
206
|
+
* upload's idle limit (code `upload_received_publishing`; it finishes
|
|
207
|
+
* without this device). The caller retries later by resuming the session
|
|
208
|
+
* it persisted, which asks Forge for the receipt again and sends no bytes.
|
|
209
|
+
*/
|
|
210
|
+
readonly retryLater: boolean;
|
|
211
|
+
/**
|
|
212
|
+
* The code Core ended this upload with, as Forge named it. With code
|
|
213
|
+
* `upload_refused` a final refusal (`UPLOAD_POOL_FULL`: the account's
|
|
214
|
+
* storage pool cannot take it; `UPLOAD_SUPERSEDED`: a later upload replaced
|
|
215
|
+
* it), whose message is Core's own sentence and which no new upload of the
|
|
216
|
+
* same file remedies; with `sessionLost` any other code (`NOT_FOUND`: the
|
|
217
|
+
* file the upload was for is gone; `UPLOAD_PROTOCOL_RETIRED`; a 412's),
|
|
218
|
+
* which a fresh upload starts over from. Null when the answer named none.
|
|
219
|
+
*/
|
|
220
|
+
readonly coreCode: string | null;
|
|
192
221
|
|
|
193
222
|
constructor(
|
|
194
223
|
message: string,
|
|
195
224
|
options: {
|
|
196
225
|
cause?: Error | null;
|
|
197
226
|
httpStatus?: number | null;
|
|
227
|
+
/** The error's code when it names an outcome of its own; otherwise the HTTP status, or `tus_upload_error`. */
|
|
228
|
+
code?: string;
|
|
198
229
|
filename?: string | null;
|
|
199
230
|
fileSize?: number | null;
|
|
200
231
|
phase?: TusUploadError['phase'];
|
|
201
232
|
sessionLost?: boolean;
|
|
233
|
+
retryAfterMs?: number | null;
|
|
234
|
+
retryLater?: boolean;
|
|
235
|
+
coreCode?: string | null;
|
|
202
236
|
} = {}
|
|
203
237
|
) {
|
|
204
238
|
super(message, {
|
|
205
|
-
code: options.httpStatus ?? 'tus_upload_error',
|
|
239
|
+
code: options.code ?? options.httpStatus ?? 'tus_upload_error',
|
|
206
240
|
statusCode: options.httpStatus ?? undefined,
|
|
207
241
|
});
|
|
208
242
|
this.name = 'TusUploadError';
|
|
@@ -211,6 +245,9 @@ export class TusUploadError extends SparkVaultMobileError {
|
|
|
211
245
|
this.fileSize = options.fileSize ?? null;
|
|
212
246
|
this.phase = options.phase ?? 'unknown';
|
|
213
247
|
this.sessionLost = options.sessionLost ?? false;
|
|
248
|
+
this.retryAfterMs = options.retryAfterMs ?? null;
|
|
249
|
+
this.retryLater = options.retryLater ?? false;
|
|
250
|
+
this.coreCode = options.coreCode ?? null;
|
|
214
251
|
Object.setPrototypeOf(this, TusUploadError.prototype);
|
|
215
252
|
}
|
|
216
253
|
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
createSparkVaultMobileClient,
|
|
4
4
|
} from './client.js';
|
|
5
5
|
export type {
|
|
6
|
+
MobileConnectivity,
|
|
6
7
|
SparkVaultMobileConfig,
|
|
7
8
|
ResolvedMobileConfig,
|
|
8
9
|
} from './config.js';
|
|
@@ -92,6 +93,7 @@ export {
|
|
|
92
93
|
MobileUsersClient,
|
|
93
94
|
} from './users.js';
|
|
94
95
|
export {
|
|
96
|
+
isSessionExpired,
|
|
95
97
|
isSessionGone,
|
|
96
98
|
MobileTusUploader,
|
|
97
99
|
parseForgeUrl,
|
package/src/ingots.ts
CHANGED
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
isRetryableDownloadError,
|
|
8
8
|
SparkVaultMobileError,
|
|
9
9
|
SparkVaultValidationError,
|
|
10
|
+
TusUploadError,
|
|
10
11
|
} from './errors.js';
|
|
11
12
|
import type { MobileHttpClient } from './http.js';
|
|
12
|
-
import { allowsHost, MobileTusUploader } from './tus.js';
|
|
13
|
+
import { allowsHost, MobileTusUploader, type TusSessionProbe } from './tus.js';
|
|
13
14
|
import type {
|
|
14
15
|
DebugLogger,
|
|
15
16
|
HashCheckResponse,
|
|
@@ -97,6 +98,12 @@ export interface UploadFromUriOptions extends UploadOptions {
|
|
|
97
98
|
* attempt or a relaunch instead of re-sending bytes Forge already holds.
|
|
98
99
|
*/
|
|
99
100
|
onSessionCreated?: (session: IngotUploadSession) => void;
|
|
101
|
+
/**
|
|
102
|
+
* Invoked whenever the session's expiry changes (see
|
|
103
|
+
* `TusUploadFromUriOptions.onSessionExpiry`): a caller that persists the
|
|
104
|
+
* session keeps its `expiresAt` current with it.
|
|
105
|
+
*/
|
|
106
|
+
onSessionExpiry?: (expiresAt: number | null) => void;
|
|
100
107
|
/** See `UploadTransport`; default `foreground`. */
|
|
101
108
|
transport?: UploadTransport;
|
|
102
109
|
/** Network policy for the OS transfer engine; see `UploadTransferPolicy`. Default: both allowed. */
|
|
@@ -127,8 +134,14 @@ export interface ResumeUploadOptions {
|
|
|
127
134
|
fileUri: string;
|
|
128
135
|
fileSize: number;
|
|
129
136
|
name: string;
|
|
130
|
-
/**
|
|
131
|
-
|
|
137
|
+
/**
|
|
138
|
+
* The persisted tus session handed to `onSessionCreated`, with the expiry
|
|
139
|
+
* the caller keeps for it (`onSessionExpiry`): a session past it is
|
|
140
|
+
* refused as `sessionLost` before any request.
|
|
141
|
+
*/
|
|
142
|
+
session: { uploadUrl: string; istk: string; expiresAt?: number | null };
|
|
143
|
+
/** See `UploadFromUriOptions.onSessionExpiry`. */
|
|
144
|
+
onSessionExpiry?: (expiresAt: number | null) => void;
|
|
132
145
|
/** See `UploadTransport`; default `foreground`. */
|
|
133
146
|
transport?: UploadTransport;
|
|
134
147
|
/** Network policy for the OS transfer engine; see `UploadTransferPolicy`. Default: both allowed. */
|
|
@@ -343,6 +356,7 @@ export class MobileIngotsClient {
|
|
|
343
356
|
filename: name,
|
|
344
357
|
forgeUrl: init.forgeUrl,
|
|
345
358
|
onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId: init.ingotId, created }),
|
|
359
|
+
onSessionExpiry: options.onSessionExpiry,
|
|
346
360
|
transport: options.transport,
|
|
347
361
|
transferPolicy: options.transferPolicy,
|
|
348
362
|
onProgress: options.onProgress,
|
|
@@ -351,7 +365,11 @@ export class MobileIngotsClient {
|
|
|
351
365
|
debug: options.debug,
|
|
352
366
|
});
|
|
353
367
|
|
|
354
|
-
const ingot = await this.verifyIngotActive(
|
|
368
|
+
const ingot = await this.verifyIngotActive(
|
|
369
|
+
{ vaultId: options.vaultId, ingotId: init.ingotId, vat: options.vat },
|
|
370
|
+
{ filename: name, fileSize: options.fileSize },
|
|
371
|
+
options.debug
|
|
372
|
+
);
|
|
355
373
|
|
|
356
374
|
return {
|
|
357
375
|
ingot_id: init.ingotId,
|
|
@@ -373,10 +391,10 @@ export class MobileIngotsClient {
|
|
|
373
391
|
* sent, then the ingot is verified active exactly as a fresh upload is.
|
|
374
392
|
*
|
|
375
393
|
* A `TusUploadError` with `sessionLost` means Forge no longer has the
|
|
376
|
-
* session (
|
|
377
|
-
* with `uploadFromUri`. A session `uploadUrl` that is not
|
|
378
|
-
* on an allowed transfer host is refused
|
|
379
|
-
* the ISTK or a byte is sent anywhere.
|
|
394
|
+
* session (its expiry passed, or it was terminated); the caller drops it
|
|
395
|
+
* and starts over with `uploadFromUri`. A session `uploadUrl` that is not
|
|
396
|
+
* an upload session on an allowed transfer host is refused
|
|
397
|
+
* (SparkVaultValidationError) before the ISTK or a byte is sent anywhere.
|
|
380
398
|
*/
|
|
381
399
|
async resumeUpload(options: ResumeUploadOptions): Promise<UploadResult> {
|
|
382
400
|
validateVaultId(options.vaultId);
|
|
@@ -390,6 +408,7 @@ export class MobileIngotsClient {
|
|
|
390
408
|
fileSize: options.fileSize,
|
|
391
409
|
filename: options.name,
|
|
392
410
|
resume: options.session,
|
|
411
|
+
onSessionExpiry: options.onSessionExpiry,
|
|
393
412
|
transport: options.transport,
|
|
394
413
|
transferPolicy: options.transferPolicy,
|
|
395
414
|
onProgress: options.onProgress,
|
|
@@ -398,7 +417,11 @@ export class MobileIngotsClient {
|
|
|
398
417
|
debug: options.debug,
|
|
399
418
|
});
|
|
400
419
|
|
|
401
|
-
const ingot = await this.verifyIngotActive(
|
|
420
|
+
const ingot = await this.verifyIngotActive(
|
|
421
|
+
{ vaultId: options.vaultId, ingotId: options.ingotId, vat: options.vat },
|
|
422
|
+
{ filename: options.name, fileSize: options.fileSize },
|
|
423
|
+
options.debug
|
|
424
|
+
);
|
|
402
425
|
|
|
403
426
|
return {
|
|
404
427
|
ingot_id: options.ingotId,
|
|
@@ -418,9 +441,7 @@ export class MobileIngotsClient {
|
|
|
418
441
|
* An `uploadUrl` that is not an upload session on an allowed transfer host
|
|
419
442
|
* is refused (SparkVaultValidationError) before the ISTK is sent anywhere.
|
|
420
443
|
*/
|
|
421
|
-
probeUploadSession(
|
|
422
|
-
session: { uploadUrl: string; istk: string }
|
|
423
|
-
): Promise<{ offset: number; length: number; chunkSize: number } | null> {
|
|
444
|
+
probeUploadSession(session: { uploadUrl: string; istk: string }): Promise<TusSessionProbe | null> {
|
|
424
445
|
return this.tus.probeSession(session);
|
|
425
446
|
}
|
|
426
447
|
|
|
@@ -512,6 +533,7 @@ export class MobileIngotsClient {
|
|
|
512
533
|
forgeUrl: response.data.forge_url,
|
|
513
534
|
// A replace never mints an ingot: the row is the caller's existing one.
|
|
514
535
|
onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId, created: false }),
|
|
536
|
+
onSessionExpiry: options.onSessionExpiry,
|
|
515
537
|
transport: options.transport,
|
|
516
538
|
transferPolicy: options.transferPolicy,
|
|
517
539
|
onProgress,
|
|
@@ -520,7 +542,11 @@ export class MobileIngotsClient {
|
|
|
520
542
|
debug,
|
|
521
543
|
});
|
|
522
544
|
|
|
523
|
-
const ingot = await this.verifyIngotActive(
|
|
545
|
+
const ingot = await this.verifyIngotActive(
|
|
546
|
+
{ vaultId, ingotId: response.data.ingot_id, vat },
|
|
547
|
+
{ filename: name, fileSize },
|
|
548
|
+
debug
|
|
549
|
+
);
|
|
524
550
|
|
|
525
551
|
return {
|
|
526
552
|
ingot_id: response.data.ingot_id,
|
|
@@ -866,22 +892,24 @@ export class MobileIngotsClient {
|
|
|
866
892
|
}
|
|
867
893
|
|
|
868
894
|
/**
|
|
869
|
-
*
|
|
895
|
+
* Read a just-uploaded ingot until the server reports it `active`.
|
|
870
896
|
*
|
|
871
|
-
* The
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
897
|
+
* The upload resolved on Forge's receipt (a 2xx to the PATCH at the final
|
|
898
|
+
* offset, which Forge answers only once the ingot is published), so a read
|
|
899
|
+
* that is not `active` yet is a read lagging that publication, which the
|
|
900
|
+
* poll waits out. It never re-creates or re-uploads anything. A row still
|
|
901
|
+
* not `active` past the poll budget is never reported as a success: it
|
|
902
|
+
* throws a `TusUploadError` whose `retryLater` is set (every byte is
|
|
903
|
+
* received, publication is unconfirmed), so the caller resumes the session
|
|
904
|
+
* it persisted later, which asks Forge for the receipt again and sends no
|
|
905
|
+
* bytes, rather than recording a backup the vault may not hold.
|
|
878
906
|
*/
|
|
879
907
|
private async verifyIngotActive(
|
|
880
|
-
vaultId: string,
|
|
881
|
-
|
|
882
|
-
vat: string,
|
|
908
|
+
ingotRef: { vaultId: string; ingotId: string; vat: string },
|
|
909
|
+
upload: { filename: string; fileSize: number },
|
|
883
910
|
debug?: DebugLogger
|
|
884
911
|
): Promise<Ingot> {
|
|
912
|
+
const { vaultId, ingotId, vat } = ingotRef;
|
|
885
913
|
const deadline = Date.now() + MobileIngotsClient.INGOT_STATUS_POLL_BUDGET_MS;
|
|
886
914
|
let delay = MobileIngotsClient.INGOT_STATUS_POLL_INITIAL_MS;
|
|
887
915
|
let ingot = await this.get(vaultId, ingotId, vat);
|
|
@@ -891,9 +919,12 @@ export class MobileIngotsClient {
|
|
|
891
919
|
if (remaining <= 0) {
|
|
892
920
|
debug?.log(
|
|
893
921
|
`Ingot ${ingotId} still '${ingot.status}' after ${MobileIngotsClient.INGOT_STATUS_POLL_BUDGET_MS}ms; ` +
|
|
894
|
-
'upload
|
|
922
|
+
'the upload is received and will be confirmed on a later attempt'
|
|
923
|
+
);
|
|
924
|
+
throw new TusUploadError(
|
|
925
|
+
`Upload received; the vault has not confirmed ingot ${ingotId} published yet (status ${ingot.status ?? 'unknown'})`,
|
|
926
|
+
{ ...upload, phase: 'finalize', retryLater: true }
|
|
895
927
|
);
|
|
896
|
-
return ingot;
|
|
897
928
|
}
|
|
898
929
|
|
|
899
930
|
await new Promise(resolve => setTimeout(resolve, Math.min(delay, remaining)));
|