@woodleighschool/bloby-client 1.2.0 → 1.3.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/README.md +13 -5
- package/dist/index.d.ts +10 -6
- package/dist/index.js +83 -48
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
# @woodleighschool/bloby-client
|
|
2
2
|
|
|
3
|
-
Headless browser transfers for [Bloby](../../bloby). The server chooses direct or multipart ingestion. `upload` resolves
|
|
3
|
+
Headless browser transfers for [Bloby](../../bloby). `digest` produces what an application declares when it creates an upload. The server chooses direct or multipart ingestion. `upload` resolves once storage has accepted every byte; applications own creating and finalizing the upload, attachment and UI.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
+
const content = await digest(file, { signal, onProgress });
|
|
7
|
+
// { size_bytes, sha256, crc64nvme }, declared when creating the upload.
|
|
8
|
+
|
|
6
9
|
await upload({
|
|
7
10
|
strategy: "multipart",
|
|
8
11
|
blob: file,
|
|
9
12
|
signal,
|
|
10
13
|
onProgress,
|
|
11
14
|
multipart: {
|
|
12
|
-
signPart: (partNumber, signal) => signPartRequest(partNumber, signal),
|
|
13
|
-
complete: (parts, signal) => completeMultipartRequest(parts, signal),
|
|
15
|
+
signPart: (partNumber, crc64nvme, signal) => signPartRequest(partNumber, crc64nvme, signal),
|
|
14
16
|
},
|
|
15
17
|
});
|
|
16
18
|
```
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
`digest` reads the blob once, in bounded slices, without holding it in memory. It returns the size with the SHA-256 and CRC-64/NVME of the whole blob as lowercase hex, and reports progress at each whole percent. It runs on the main thread and returns to the event loop between slices.
|
|
21
|
+
|
|
22
|
+
For a direct upload, pass the server's `{ strategy: "direct-put", target }` action together with the blob and options. The client sends `target.headers` as given; storage accepts only the declared bytes.
|
|
23
|
+
|
|
24
|
+
A multipart upload sends 64 MiB parts, four at a time; parts grow when a blob would otherwise need more than 10,000. For each part the client computes its CRC-64/NVME, asks `signPart` for a target signed for that checksum, and sends the part with the returned headers. A failed part is attempted three times in total, each with a fresh target, after a short backoff. The first part to exhaust its attempts stops the others and rejects `upload` with its error. Progress is the sum of finished parts and bytes in flight, so it steps back when a part is retried.
|
|
25
|
+
|
|
26
|
+
`signPart` adapts an application endpoint. Cancellation preserves the supplied `AbortSignal` reason and stops further signing and transfers. Completing the multipart upload and finalizing or attaching the object are server and application operations.
|
|
19
27
|
|
|
20
|
-
|
|
28
|
+
The package has no React, router or API-client dependency, and uses no WebAssembly, workers or `eval`, so it runs under `script-src 'self'`.
|
package/dist/index.d.ts
CHANGED
|
@@ -8,9 +8,13 @@ export interface UploadTarget {
|
|
|
8
8
|
method: "PUT";
|
|
9
9
|
headers?: Record<string, string>;
|
|
10
10
|
}
|
|
11
|
+
export interface Content {
|
|
12
|
+
size_bytes: number;
|
|
13
|
+
sha256: string;
|
|
14
|
+
crc64nvme: string;
|
|
15
|
+
}
|
|
11
16
|
export interface MultipartUploadRequest {
|
|
12
|
-
signPart: (partNumber: number, signal?: AbortSignal) => Promise<UploadTarget>;
|
|
13
|
-
complete: (parts: CompletedPart[], signal?: AbortSignal) => Promise<void>;
|
|
17
|
+
signPart: (partNumber: number, crc64nvme: string, signal?: AbortSignal) => Promise<UploadTarget>;
|
|
14
18
|
}
|
|
15
19
|
export type UploadAction = {
|
|
16
20
|
strategy: "direct-put";
|
|
@@ -24,13 +28,13 @@ export type UploadRequest = Extract<UploadAction, {
|
|
|
24
28
|
strategy: "multipart";
|
|
25
29
|
multipart: MultipartUploadRequest;
|
|
26
30
|
};
|
|
27
|
-
export interface CompletedPart {
|
|
28
|
-
part_number: number;
|
|
29
|
-
etag: string;
|
|
30
|
-
}
|
|
31
31
|
export interface UploadOptions {
|
|
32
32
|
blob: Blob;
|
|
33
33
|
signal?: AbortSignal;
|
|
34
34
|
onProgress?: (progress: UploadProgress) => void;
|
|
35
35
|
}
|
|
36
|
+
export declare function digest(blob: Blob, options?: {
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
onProgress?: (progress: UploadProgress) => void;
|
|
39
|
+
}): Promise<Content>;
|
|
36
40
|
export declare function upload(request: UploadRequest & UploadOptions): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -1,67 +1,104 @@
|
|
|
1
|
+
import { Crc64Nvme } from "@aws-sdk/crc64-nvme";
|
|
2
|
+
import { SHA256 } from "@stablelib/sha256";
|
|
3
|
+
import pMap from "p-map";
|
|
4
|
+
import pRetry from "p-retry";
|
|
5
|
+
import { uint8ArrayToHex } from "uint8array-extras";
|
|
6
|
+
// A blob is hashed one slice at a time. The slice bounds the memory held and
|
|
7
|
+
// how long a hashing step occupies the main thread; awaiting the next slice
|
|
8
|
+
// returns to the event loop. Smaller slices spend a growing share of the pass
|
|
9
|
+
// on reads.
|
|
10
|
+
const readSize = 512 * 1024;
|
|
1
11
|
// Bloby chooses 64 MiB as its browser base part size. S3 separately limits a
|
|
2
12
|
// multipart upload to 10,000 parts.
|
|
3
13
|
const multipartPartSize = 64 * 1024 * 1024;
|
|
4
14
|
const maximumMultipartParts = 10_000;
|
|
5
|
-
|
|
6
|
-
|
|
15
|
+
const partConcurrency = 4;
|
|
16
|
+
const partAttempts = 3;
|
|
17
|
+
// Reads the blob once, without holding it in memory. Progress is reported at
|
|
18
|
+
// each whole percent, not at each slice, which would be hundreds of times a
|
|
19
|
+
// second.
|
|
20
|
+
export async function digest(blob, options = {}) {
|
|
21
|
+
const sha = new SHA256();
|
|
22
|
+
const crc = new Crc64Nvme();
|
|
23
|
+
let loaded = 0;
|
|
24
|
+
let reported = 0;
|
|
25
|
+
for await (const bytes of read(blob, options.signal)) {
|
|
26
|
+
sha.update(bytes);
|
|
27
|
+
crc.update(bytes);
|
|
28
|
+
loaded += bytes.length;
|
|
29
|
+
const progress = uploadProgress(loaded, blob.size);
|
|
30
|
+
if (progress.percent !== reported || loaded === blob.size) {
|
|
31
|
+
reported = progress.percent;
|
|
32
|
+
options.onProgress?.(progress);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
size_bytes: blob.size,
|
|
37
|
+
sha256: uint8ArrayToHex(sha.digest()),
|
|
38
|
+
crc64nvme: uint8ArrayToHex(await crc.digest()),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Resolves once storage has accepted every byte. Completing a multipart upload
|
|
42
|
+
// and finalizing the object remain server and application operations.
|
|
7
43
|
export async function upload(request) {
|
|
44
|
+
const { blob, signal, onProgress } = request;
|
|
45
|
+
const report = (loaded) => onProgress?.(uploadProgress(loaded, blob.size));
|
|
8
46
|
if (request.strategy === "direct-put") {
|
|
9
|
-
await
|
|
47
|
+
await put(request.target, blob, signal, report);
|
|
10
48
|
return;
|
|
11
49
|
}
|
|
12
|
-
|
|
50
|
+
await uploadParts(request.multipart, blob, signal, report);
|
|
13
51
|
}
|
|
14
|
-
async function
|
|
15
|
-
const { blob, signal, onProgress } = request;
|
|
16
|
-
signal?.throwIfAborted();
|
|
52
|
+
async function uploadParts(multipart, blob, external, report) {
|
|
17
53
|
if (blob.size === 0)
|
|
18
54
|
throw new Error("Multipart uploads require a nonempty blob.");
|
|
19
55
|
const partSize = Math.max(multipartPartSize, Math.ceil(blob.size / maximumMultipartParts));
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
// Completion is idempotent: a lost response must not require transferring
|
|
30
|
-
// the parts again. Only the storage completion callback is retried.
|
|
31
|
-
for (let attempt = 0;; attempt++) {
|
|
32
|
-
signal?.throwIfAborted();
|
|
56
|
+
const partCount = Math.ceil(blob.size / partSize);
|
|
57
|
+
// The first part to fail aborts the others with its error.
|
|
58
|
+
const failure = new AbortController();
|
|
59
|
+
const signal = external ? AbortSignal.any([external, failure.signal]) : failure.signal;
|
|
60
|
+
let loaded = 0;
|
|
61
|
+
await pMap(Array.from({ length: partCount }, (_, index) => index), async (index) => {
|
|
62
|
+
const part = blob.slice(index * partSize, (index + 1) * partSize);
|
|
63
|
+
let partLoaded = 0;
|
|
33
64
|
try {
|
|
34
|
-
await
|
|
35
|
-
|
|
65
|
+
await uploadPart(multipart, index + 1, part, signal, (bytes) => {
|
|
66
|
+
loaded += bytes - partLoaded;
|
|
67
|
+
partLoaded = bytes;
|
|
68
|
+
report(loaded);
|
|
69
|
+
});
|
|
36
70
|
}
|
|
37
71
|
catch (error) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
throw error;
|
|
72
|
+
failure.abort(error);
|
|
73
|
+
throw error;
|
|
41
74
|
}
|
|
42
|
-
}
|
|
75
|
+
}, { concurrency: partConcurrency, signal });
|
|
76
|
+
}
|
|
77
|
+
async function uploadPart(multipart, partNumber, part, signal, onLoaded) {
|
|
78
|
+
const crc = new Crc64Nvme();
|
|
79
|
+
for await (const bytes of read(part, signal))
|
|
80
|
+
crc.update(bytes);
|
|
81
|
+
const checksum = uint8ArrayToHex(await crc.digest());
|
|
82
|
+
// Every attempt asks for a fresh target; an earlier signature may have expired.
|
|
83
|
+
await pRetry(async () => put(await multipart.signPart(partNumber, checksum, signal), part, signal, onLoaded), {
|
|
84
|
+
retries: partAttempts - 1,
|
|
85
|
+
signal,
|
|
86
|
+
onFailedAttempt: () => {
|
|
87
|
+
if (!signal.aborted)
|
|
88
|
+
onLoaded(0);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
onLoaded(part.size);
|
|
43
92
|
}
|
|
44
|
-
async function
|
|
45
|
-
|
|
46
|
-
for (let
|
|
93
|
+
async function* read(blob, signal) {
|
|
94
|
+
signal?.throwIfAborted();
|
|
95
|
+
for (let offset = 0; offset < blob.size; offset += readSize) {
|
|
96
|
+
const bytes = new Uint8Array(await blob.slice(offset, offset + readSize).arrayBuffer());
|
|
47
97
|
signal?.throwIfAborted();
|
|
48
|
-
|
|
49
|
-
const target = await multipart.signPart(partNumber, signal);
|
|
50
|
-
const etag = await uploadTarget(target, chunk, totalBytes, completedBytes, onProgress, signal);
|
|
51
|
-
if (!etag) {
|
|
52
|
-
throw new Error(`Multipart part ${partNumber} did not return an ETag.`);
|
|
53
|
-
}
|
|
54
|
-
return etag;
|
|
55
|
-
}
|
|
56
|
-
catch (error) {
|
|
57
|
-
signal?.throwIfAborted();
|
|
58
|
-
lastError = error;
|
|
59
|
-
onProgress?.(uploadProgress(completedBytes, totalBytes));
|
|
60
|
-
}
|
|
98
|
+
yield bytes;
|
|
61
99
|
}
|
|
62
|
-
throw lastError;
|
|
63
100
|
}
|
|
64
|
-
function
|
|
101
|
+
function put(target, body, signal, onLoaded) {
|
|
65
102
|
return new Promise((resolve, reject) => {
|
|
66
103
|
if (signal?.aborted) {
|
|
67
104
|
reject(signal.reason);
|
|
@@ -70,13 +107,11 @@ function uploadTarget(target, body, totalBytes, completedBytes, onProgress, sign
|
|
|
70
107
|
const xhr = new XMLHttpRequest();
|
|
71
108
|
const finish = () => signal?.removeEventListener("abort", abort);
|
|
72
109
|
const abort = () => xhr.abort();
|
|
73
|
-
xhr.upload.addEventListener("progress", (event) =>
|
|
74
|
-
onProgress?.(uploadProgress(completedBytes + event.loaded, totalBytes));
|
|
75
|
-
});
|
|
110
|
+
xhr.upload.addEventListener("progress", (event) => onLoaded(event.loaded));
|
|
76
111
|
xhr.addEventListener("load", () => {
|
|
77
112
|
finish();
|
|
78
113
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
79
|
-
resolve(
|
|
114
|
+
resolve();
|
|
80
115
|
return;
|
|
81
116
|
}
|
|
82
117
|
reject(new Error(`Upload failed with HTTP ${xhr.status}.`));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@woodleighschool/bloby-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Headless browser transfers for Bloby direct and multipart uploads.",
|
|
5
5
|
"homepage": "https://github.com/woodleighschool/goodies/tree/main/packages/bloby-client",
|
|
6
6
|
"bugs": "https://github.com/woodleighschool/goodies/issues",
|
|
@@ -33,9 +33,16 @@
|
|
|
33
33
|
"prepublishOnly": "pnpm run typecheck && pnpm run test",
|
|
34
34
|
"prepare": "pnpm run build"
|
|
35
35
|
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@aws-sdk/crc64-nvme": "^3.972.41",
|
|
38
|
+
"@stablelib/sha256": "^2.0.1",
|
|
39
|
+
"p-map": "^7.0.8",
|
|
40
|
+
"p-retry": "^8.0.1",
|
|
41
|
+
"uint8array-extras": "^1.6.0"
|
|
42
|
+
},
|
|
36
43
|
"devDependencies": {
|
|
37
|
-
"@types/node": "^26.6.
|
|
38
|
-
"tsx": "^4.23.
|
|
44
|
+
"@types/node": "^26.6.4",
|
|
45
|
+
"tsx": "^4.23.15",
|
|
39
46
|
"typescript": "~7.0.2"
|
|
40
47
|
}
|
|
41
48
|
}
|