@droponair/sdk-js 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.37.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Uploads carry on where they stopped.** A file over 8 MB goes up in parts, and
|
|
8
|
+
an upload broken by a dead connection sends only the parts the storage does not
|
|
9
|
+
have yet. Nothing to change in an app: it happens inside
|
|
10
|
+
`prepareAttachmentAndUpload`, and `onUploadProgress` counts the whole file as
|
|
11
|
+
before.
|
|
12
|
+
|
|
13
|
+
## 0.36.0
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Downloads carry on where they stopped.** A download that breaks keeps what it
|
|
18
|
+
already had and asks the storage for the rest by range, retrying six times with a
|
|
19
|
+
growing wait. Nothing to change in an app: it happens inside `downloadAttachment`.
|
|
20
|
+
|
|
3
21
|
## 0.35.0
|
|
4
22
|
|
|
5
23
|
### Added
|
|
@@ -69,6 +69,18 @@ export declare class AttachmentClient {
|
|
|
69
69
|
toWire(ref: AttachmentRef): WireAttachmentRef;
|
|
70
70
|
/** Convert a wire AttachmentRef (from a received message) into the public type. */
|
|
71
71
|
static fromWire(wire: WireAttachmentRef): AttachmentRef;
|
|
72
|
+
/**
|
|
73
|
+
* Sends a file a part at a time, skipping the parts the storage already has.
|
|
74
|
+
*
|
|
75
|
+
* Each part is asked for a link of its own, sent, and its tag kept; when they are
|
|
76
|
+
* all there the storage puts them together. An upload that broke leaves its parts
|
|
77
|
+
* behind, so trying again sends only what is missing.
|
|
78
|
+
*/
|
|
79
|
+
private uploadInParts;
|
|
80
|
+
/** What the storage already holds for this upload. */
|
|
81
|
+
private partsAlreadyStored;
|
|
82
|
+
private partUrl;
|
|
83
|
+
private finishParts;
|
|
72
84
|
private uploadBytes;
|
|
73
85
|
private wrapKeyForRecipients;
|
|
74
86
|
private unwrapKey;
|
|
@@ -40,6 +40,11 @@ async function readWithProgress(resp, expectedBytes, onProgress) {
|
|
|
40
40
|
}
|
|
41
41
|
return out;
|
|
42
42
|
}
|
|
43
|
+
/** How many times a broken download is tried again, and how long between tries. */
|
|
44
|
+
/** Above this a file goes up in parts rather than in one request. */
|
|
45
|
+
const IN_PARTS_ABOVE_BYTES = 8 * 1024 * 1024;
|
|
46
|
+
const DOWNLOAD_ATTEMPTS = 6;
|
|
47
|
+
const DOWNLOAD_BACKOFF_MS = [2000, 4000, 8000, 15000, 30000];
|
|
43
48
|
async function sha256Hex(bytes) {
|
|
44
49
|
const digest = await crypto.subtle.digest('SHA-256', asBufferSource(bytes));
|
|
45
50
|
const hex = [];
|
|
@@ -80,6 +85,8 @@ class AttachmentClient {
|
|
|
80
85
|
body.retentionSeconds = opts.retentionSeconds;
|
|
81
86
|
if (opts.deleteWhenEveryoneHasIt)
|
|
82
87
|
body.deleteWhenEveryoneHasIt = true;
|
|
88
|
+
if (opts.inParts)
|
|
89
|
+
body.inParts = true;
|
|
83
90
|
const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/upload-session`, {
|
|
84
91
|
method: 'POST',
|
|
85
92
|
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
@@ -164,8 +171,16 @@ class AttachmentClient {
|
|
|
164
171
|
recipientUserIds,
|
|
165
172
|
retentionSeconds: opts.retentionSeconds,
|
|
166
173
|
deleteWhenEveryoneHasIt: opts.deleteWhenEveryoneHasIt,
|
|
174
|
+
// A file big enough that losing the connection near the end would mean
|
|
175
|
+
// sending all of it again goes up in parts instead.
|
|
176
|
+
inParts: storedBytes.length > IN_PARTS_ABOVE_BYTES,
|
|
167
177
|
});
|
|
168
|
-
|
|
178
|
+
if (session.uploadId) {
|
|
179
|
+
await this.uploadInParts(session, storedBytes, opts.onUploadProgress);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
await this.uploadBytes(session, storedBytes, opts.onUploadProgress);
|
|
183
|
+
}
|
|
169
184
|
await this.finalize(session.attachmentId, sha256);
|
|
170
185
|
let wrappedKeys = [];
|
|
171
186
|
if (encryptionType === 'E2EE' && fileKey) {
|
|
@@ -225,18 +240,49 @@ class AttachmentClient {
|
|
|
225
240
|
* current device, unwraps it, then decrypts the stored bytes.
|
|
226
241
|
*/
|
|
227
242
|
async downloadAttachment(ref, opts = {}) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
// What has already arrived is kept between attempts, and the rest asked for by
|
|
244
|
+
// range, so a download that breaks does not start the file again. A fresh link
|
|
245
|
+
// is asked for each attempt, since a link lasts minutes.
|
|
246
|
+
let had = new Uint8Array(0);
|
|
247
|
+
let info;
|
|
248
|
+
for (let attempt = 1;; attempt++) {
|
|
249
|
+
try {
|
|
250
|
+
info = await this.getDownloadUrl(ref.attachmentId);
|
|
251
|
+
const headers = { ...info.headers };
|
|
252
|
+
if (had.length > 0)
|
|
253
|
+
headers.Range = `bytes=${had.length}-`;
|
|
254
|
+
const resp = await this.deps.fetchFn(info.url, { method: info.method, headers });
|
|
255
|
+
if (resp.status === 416 && had.length > 0)
|
|
256
|
+
break;
|
|
257
|
+
if (!resp.ok) {
|
|
258
|
+
const text = await resp.text().catch(() => '');
|
|
259
|
+
const message = `Failed to download attachment bytes (HTTP ${resp.status}): ${text}`;
|
|
260
|
+
// The link was good and the bytes are not there: deleted from storage
|
|
261
|
+
// underneath a record that still existed.
|
|
262
|
+
if (resp.status === 404)
|
|
263
|
+
throw new attachment_types_1.AttachmentUnavailableError(ref.attachmentId, 404, message);
|
|
264
|
+
throw new Error(message);
|
|
265
|
+
}
|
|
266
|
+
const resuming = resp.status === 206 && had.length > 0;
|
|
267
|
+
const base = resuming ? had : new Uint8Array(0);
|
|
268
|
+
const rest = await readWithProgress(resp, Math.max(0, info.sizeBytes - base.length), opts.onProgress
|
|
269
|
+
? (received, total) => opts.onProgress(base.length + received, base.length + total)
|
|
270
|
+
: undefined);
|
|
271
|
+
const joined = new Uint8Array(base.length + rest.length);
|
|
272
|
+
joined.set(base, 0);
|
|
273
|
+
joined.set(rest, base.length);
|
|
274
|
+
had = joined;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (error instanceof attachment_types_1.AttachmentUnavailableError)
|
|
279
|
+
throw error;
|
|
280
|
+
if (attempt >= DOWNLOAD_ATTEMPTS)
|
|
281
|
+
throw error;
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_BACKOFF_MS[Math.min(attempt - 1, DOWNLOAD_BACKOFF_MS.length - 1)]));
|
|
283
|
+
}
|
|
238
284
|
}
|
|
239
|
-
let stored =
|
|
285
|
+
let stored = had;
|
|
240
286
|
if (ref.encryptionType === 'CLEARTEXT') {
|
|
241
287
|
await this.confirmReceived(ref.attachmentId);
|
|
242
288
|
return { attachmentId: ref.attachmentId, mimeType: info.mimeType, sizeBytes: info.sizeBytes, sha256: info.sha256, bytes: stored };
|
|
@@ -318,6 +364,83 @@ class AttachmentClient {
|
|
|
318
364
|
};
|
|
319
365
|
}
|
|
320
366
|
// ─── internals ───
|
|
367
|
+
/**
|
|
368
|
+
* Sends a file a part at a time, skipping the parts the storage already has.
|
|
369
|
+
*
|
|
370
|
+
* Each part is asked for a link of its own, sent, and its tag kept; when they are
|
|
371
|
+
* all there the storage puts them together. An upload that broke leaves its parts
|
|
372
|
+
* behind, so trying again sends only what is missing.
|
|
373
|
+
*/
|
|
374
|
+
async uploadInParts(session, bytes, onProgress) {
|
|
375
|
+
const partSize = session.partSizeBytes ?? 8 * 1024 * 1024;
|
|
376
|
+
const count = Math.max(1, Math.ceil(bytes.length / partSize));
|
|
377
|
+
const already = new Map();
|
|
378
|
+
for (const part of await this.partsAlreadyStored(session.attachmentId)) {
|
|
379
|
+
already.set(part.partNumber, { etag: part.etag, sizeBytes: part.sizeBytes });
|
|
380
|
+
}
|
|
381
|
+
const tags = new Map();
|
|
382
|
+
let sent = 0;
|
|
383
|
+
onProgress?.(0, bytes.length);
|
|
384
|
+
for (let number = 1; number <= count; number++) {
|
|
385
|
+
const from = (number - 1) * partSize;
|
|
386
|
+
const size = Math.min(partSize, bytes.length - from);
|
|
387
|
+
const had = already.get(number);
|
|
388
|
+
if (had && had.sizeBytes === size) {
|
|
389
|
+
tags.set(number, had.etag);
|
|
390
|
+
sent += size;
|
|
391
|
+
onProgress?.(sent, bytes.length);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const link = await this.partUrl(session.attachmentId, number);
|
|
395
|
+
const resp = await this.deps.fetchFn(link.uploadUrl, {
|
|
396
|
+
method: 'PUT',
|
|
397
|
+
headers: link.uploadHeaders ?? {},
|
|
398
|
+
body: asBufferSource(bytes.subarray(from, from + size)),
|
|
399
|
+
});
|
|
400
|
+
if (!resp.ok) {
|
|
401
|
+
const text = await resp.text().catch(() => '');
|
|
402
|
+
throw new Error(`Part ${number} failed (HTTP ${resp.status}): ${text.slice(0, 400)}`);
|
|
403
|
+
}
|
|
404
|
+
tags.set(number, resp.headers?.get?.('etag') ?? '');
|
|
405
|
+
sent += size;
|
|
406
|
+
onProgress?.(sent, bytes.length);
|
|
407
|
+
}
|
|
408
|
+
await this.finishParts(session.attachmentId, tags, partSize, bytes.length);
|
|
409
|
+
}
|
|
410
|
+
/** What the storage already holds for this upload. */
|
|
411
|
+
async partsAlreadyStored(attachmentId) {
|
|
412
|
+
const jwt = await this.deps.getValidDropOnAirJwt();
|
|
413
|
+
const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/${encodeURIComponent(attachmentId)}/parts`, { method: 'GET', headers: { Authorization: `Bearer ${jwt}` } });
|
|
414
|
+
if (!resp.ok)
|
|
415
|
+
return [];
|
|
416
|
+
return (await resp.json());
|
|
417
|
+
}
|
|
418
|
+
async partUrl(attachmentId, partNumber) {
|
|
419
|
+
const jwt = await this.deps.getValidDropOnAirJwt();
|
|
420
|
+
const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/${encodeURIComponent(attachmentId)}/parts/${partNumber}/url`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } });
|
|
421
|
+
if (!resp.ok) {
|
|
422
|
+
const text = await resp.text().catch(() => '');
|
|
423
|
+
throw new Error(`No link for part ${partNumber} (HTTP ${resp.status}): ${text.slice(0, 400)}`);
|
|
424
|
+
}
|
|
425
|
+
return (await resp.json());
|
|
426
|
+
}
|
|
427
|
+
async finishParts(attachmentId, tags, partSize, totalBytes) {
|
|
428
|
+
const parts = [...tags.keys()].sort((a, b) => a - b).map((number) => ({
|
|
429
|
+
partNumber: number,
|
|
430
|
+
etag: tags.get(number) ?? '',
|
|
431
|
+
sizeBytes: Math.min(partSize, totalBytes - (number - 1) * partSize),
|
|
432
|
+
}));
|
|
433
|
+
const jwt = await this.deps.getValidDropOnAirJwt();
|
|
434
|
+
const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/${encodeURIComponent(attachmentId)}/parts/finish`, {
|
|
435
|
+
method: 'POST',
|
|
436
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
437
|
+
body: JSON.stringify({ parts }),
|
|
438
|
+
});
|
|
439
|
+
if (!resp.ok) {
|
|
440
|
+
const text = await resp.text().catch(() => '');
|
|
441
|
+
throw new Error(`The parts could not be put together (HTTP ${resp.status}): ${text.slice(0, 400)}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
321
444
|
async uploadBytes(session, bytes, onProgress) {
|
|
322
445
|
// In a browser an XMLHttpRequest can say how far the body has got; fetch cannot.
|
|
323
446
|
if (onProgress && typeof XMLHttpRequest !== 'undefined') {
|
|
@@ -36,6 +36,11 @@ export interface AttachmentRef {
|
|
|
36
36
|
}
|
|
37
37
|
/** Optional metadata for {@link DropOnAirClient.createUploadSession}. */
|
|
38
38
|
export interface CreateUploadSessionOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Send the file in parts, so an upload broken by a dead connection carries on
|
|
41
|
+
* with the parts that are missing. The SDK asks for this itself for big files.
|
|
42
|
+
*/
|
|
43
|
+
inParts?: boolean;
|
|
39
44
|
mimeType?: string;
|
|
40
45
|
sizeBytes: number;
|
|
41
46
|
encryptionType?: AttachmentEncryptionType;
|
|
@@ -58,6 +63,10 @@ export interface CreateUploadSessionOptions {
|
|
|
58
63
|
}
|
|
59
64
|
/** Result of reserving an upload session. */
|
|
60
65
|
export interface UploadSession {
|
|
66
|
+
/** Set when the file goes up in parts: what the storage calls this upload. */
|
|
67
|
+
uploadId?: string;
|
|
68
|
+
/** How big each part is, the last one apart. */
|
|
69
|
+
partSizeBytes?: number;
|
|
61
70
|
attachmentId: string;
|
|
62
71
|
storageHint: string;
|
|
63
72
|
uploadUrl: string;
|
package/dist/version.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* MINOR, additive feature (e.g. multi-device payloads, new call event type)
|
|
8
8
|
* PATCH, bug-fix / perf improvement with no wire or API change
|
|
9
9
|
*/
|
|
10
|
-
export declare const SDK_VERSION = "0.
|
|
10
|
+
export declare const SDK_VERSION = "0.37.0";
|
|
11
11
|
/**
|
|
12
12
|
* Binary encrypted-payload format version.
|
|
13
13
|
* Included as the first byte of every encrypted payload so receivers can
|
package/dist/version.js
CHANGED
|
@@ -10,7 +10,7 @@ exports.PROTOCOL_VERSION = exports.PAYLOAD_FORMAT_VERSION = exports.SDK_VERSION
|
|
|
10
10
|
* MINOR, additive feature (e.g. multi-device payloads, new call event type)
|
|
11
11
|
* PATCH, bug-fix / perf improvement with no wire or API change
|
|
12
12
|
*/
|
|
13
|
-
exports.SDK_VERSION = '0.
|
|
13
|
+
exports.SDK_VERSION = '0.37.0';
|
|
14
14
|
/**
|
|
15
15
|
* Binary encrypted-payload format version.
|
|
16
16
|
* Included as the first byte of every encrypted payload so receivers can
|
package/package.json
CHANGED