@droponair/sdk-js 0.36.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,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.36.0
|
|
4
14
|
|
|
5
15
|
### 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;
|
|
@@ -41,6 +41,8 @@ async function readWithProgress(resp, expectedBytes, onProgress) {
|
|
|
41
41
|
return out;
|
|
42
42
|
}
|
|
43
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;
|
|
44
46
|
const DOWNLOAD_ATTEMPTS = 6;
|
|
45
47
|
const DOWNLOAD_BACKOFF_MS = [2000, 4000, 8000, 15000, 30000];
|
|
46
48
|
async function sha256Hex(bytes) {
|
|
@@ -83,6 +85,8 @@ class AttachmentClient {
|
|
|
83
85
|
body.retentionSeconds = opts.retentionSeconds;
|
|
84
86
|
if (opts.deleteWhenEveryoneHasIt)
|
|
85
87
|
body.deleteWhenEveryoneHasIt = true;
|
|
88
|
+
if (opts.inParts)
|
|
89
|
+
body.inParts = true;
|
|
86
90
|
const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/upload-session`, {
|
|
87
91
|
method: 'POST',
|
|
88
92
|
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
@@ -167,8 +171,16 @@ class AttachmentClient {
|
|
|
167
171
|
recipientUserIds,
|
|
168
172
|
retentionSeconds: opts.retentionSeconds,
|
|
169
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,
|
|
170
177
|
});
|
|
171
|
-
|
|
178
|
+
if (session.uploadId) {
|
|
179
|
+
await this.uploadInParts(session, storedBytes, opts.onUploadProgress);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
await this.uploadBytes(session, storedBytes, opts.onUploadProgress);
|
|
183
|
+
}
|
|
172
184
|
await this.finalize(session.attachmentId, sha256);
|
|
173
185
|
let wrappedKeys = [];
|
|
174
186
|
if (encryptionType === 'E2EE' && fileKey) {
|
|
@@ -352,6 +364,83 @@ class AttachmentClient {
|
|
|
352
364
|
};
|
|
353
365
|
}
|
|
354
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
|
+
}
|
|
355
444
|
async uploadBytes(session, bytes, onProgress) {
|
|
356
445
|
// In a browser an XMLHttpRequest can say how far the body has got; fetch cannot.
|
|
357
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