@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 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 after the complete transfer is accepted by storage; applications own publication, attachment and UI.
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
- The callbacks adapt application endpoints. Multipart completion must be idempotent: the client retries a failed completion once without retransmitting the parts. Part transfers also retry once with a fresh signed target. Cancellation preserves the supplied `AbortSignal` reason and stops further signing, transfers and completion retries. Finalization or attachment of the resulting object is a separate application operation.
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
- For a direct upload, pass the server's `{ strategy: "direct-put", target }` action together with the blob and options. The core package has no React, router or API-client dependency.
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
- // Resolves after storage accepts the complete upload. Publishing and attaching
6
- // the object remain application operations.
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 uploadTarget(request.target, request.blob, request.blob.size, 0, request.onProgress, request.signal);
47
+ await put(request.target, blob, signal, report);
10
48
  return;
11
49
  }
12
- return uploadWithMultipartProgress(request);
50
+ await uploadParts(request.multipart, blob, signal, report);
13
51
  }
14
- async function uploadWithMultipartProgress(request) {
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 parts = [];
21
- let completedBytes = 0;
22
- for (let offset = 0, partNumber = 1; offset < blob.size; offset += partSize, partNumber++) {
23
- const chunk = blob.slice(offset, Math.min(offset + partSize, blob.size));
24
- const etag = await uploadPart(request.multipart, partNumber, chunk, blob.size, completedBytes, onProgress, signal);
25
- parts.push({ part_number: partNumber, etag });
26
- completedBytes += chunk.size;
27
- onProgress?.(uploadProgress(completedBytes, blob.size));
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 request.multipart.complete(parts, signal);
35
- return;
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
- signal?.throwIfAborted();
39
- if (attempt === 1)
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 uploadPart(multipart, partNumber, chunk, totalBytes, completedBytes, onProgress, signal) {
45
- let lastError;
46
- for (let attempt = 0; attempt < 2; attempt++) {
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
- try {
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 uploadTarget(target, body, totalBytes, completedBytes, onProgress, signal) {
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(xhr.getResponseHeader("ETag"));
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.2.0",
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.1",
38
- "tsx": "^4.23.13",
44
+ "@types/node": "^26.6.4",
45
+ "tsx": "^4.23.15",
39
46
  "typescript": "~7.0.2"
40
47
  }
41
48
  }