@woodleighschool/bloby-client 0.2.0 → 1.0.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,3 +1,20 @@
1
1
  # @woodleighschool/bloby-client
2
2
 
3
- Headless browser transfers for [Bloby](../../bloby). The server chooses direct or multipart ingestion; this package transfers the bytes and returns the completed parts. Applications retain resource creation, finalization, attachment, progress UI and request integration.
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.
4
+
5
+ ```ts
6
+ await upload({
7
+ strategy: "multipart",
8
+ blob: file,
9
+ signal,
10
+ onProgress,
11
+ multipart: {
12
+ signPart: (partNumber, signal) => signPartRequest(partNumber, signal),
13
+ complete: (parts, signal) => completeMultipartRequest(parts, signal),
14
+ },
15
+ });
16
+ ```
17
+
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.
19
+
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.
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface UploadTarget {
10
10
  }
11
11
  export interface MultipartUploadRequest {
12
12
  signPart: (partNumber: number, signal?: AbortSignal) => Promise<UploadTarget>;
13
+ complete: (parts: CompletedPart[], signal?: AbortSignal) => Promise<void>;
13
14
  }
14
15
  export type UploadAction = {
15
16
  strategy: "direct-put";
@@ -27,17 +28,9 @@ export interface CompletedPart {
27
28
  part_number: number;
28
29
  etag: string;
29
30
  }
30
- export type UploadResult = {
31
- strategy: "direct-put";
32
- } | {
33
- strategy: "multipart";
34
- parts: CompletedPart[];
35
- };
36
- type UploadContext = {
31
+ export interface UploadOptions {
37
32
  blob: Blob;
38
33
  signal?: AbortSignal;
39
34
  onProgress?: (progress: UploadProgress) => void;
40
- };
41
- export type UploadExecution = UploadRequest & UploadContext;
42
- export declare function upload(request: UploadExecution): Promise<UploadResult>;
43
- export {};
35
+ }
36
+ export declare function upload(request: UploadRequest & UploadOptions): Promise<void>;
package/dist/index.js CHANGED
@@ -2,16 +2,20 @@
2
2
  // multipart upload to 10,000 parts.
3
3
  const multipartPartSize = 64 * 1024 * 1024;
4
4
  const maximumMultipartParts = 10_000;
5
+ // Resolves after storage accepts the complete upload. Publishing and attaching
6
+ // the object remain application operations.
5
7
  export async function upload(request) {
6
8
  if (request.strategy === "direct-put") {
7
9
  await uploadTarget(request.target, request.blob, request.blob.size, 0, request.onProgress, request.signal);
8
- return { strategy: "direct-put" };
10
+ return;
9
11
  }
10
12
  return uploadWithMultipartProgress(request);
11
13
  }
12
14
  async function uploadWithMultipartProgress(request) {
13
15
  const { blob, signal, onProgress } = request;
14
- throwIfCancelled(signal);
16
+ signal?.throwIfAborted();
17
+ if (blob.size === 0)
18
+ throw new Error("Multipart uploads require a nonempty blob.");
15
19
  const partSize = Math.max(multipartPartSize, Math.ceil(blob.size / maximumMultipartParts));
16
20
  const parts = [];
17
21
  let completedBytes = 0;
@@ -22,14 +26,27 @@ async function uploadWithMultipartProgress(request) {
22
26
  completedBytes += chunk.size;
23
27
  onProgress?.(uploadProgress(completedBytes, blob.size));
24
28
  }
25
- return { strategy: "multipart", parts };
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();
33
+ try {
34
+ await request.multipart.complete(parts, signal);
35
+ return;
36
+ }
37
+ catch (error) {
38
+ signal?.throwIfAborted();
39
+ if (attempt === 1)
40
+ throw error;
41
+ }
42
+ }
26
43
  }
27
44
  async function uploadPart(multipart, partNumber, chunk, totalBytes, completedBytes, onProgress, signal) {
28
45
  let lastError;
29
46
  for (let attempt = 0; attempt < 2; attempt++) {
30
- throwIfCancelled(signal);
31
- const target = await multipart.signPart(partNumber, signal);
47
+ signal?.throwIfAborted();
32
48
  try {
49
+ const target = await multipart.signPart(partNumber, signal);
33
50
  const etag = await uploadTarget(target, chunk, totalBytes, completedBytes, onProgress, signal);
34
51
  if (!etag) {
35
52
  throw new Error(`Multipart part ${partNumber} did not return an ETag.`);
@@ -37,8 +54,7 @@ async function uploadPart(multipart, partNumber, chunk, totalBytes, completedByt
37
54
  return etag;
38
55
  }
39
56
  catch (error) {
40
- if (signal?.aborted)
41
- throw cancelledError();
57
+ signal?.throwIfAborted();
42
58
  lastError = error;
43
59
  onProgress?.(uploadProgress(completedBytes, totalBytes));
44
60
  }
@@ -48,7 +64,7 @@ async function uploadPart(multipart, partNumber, chunk, totalBytes, completedByt
48
64
  function uploadTarget(target, body, totalBytes, completedBytes, onProgress, signal) {
49
65
  return new Promise((resolve, reject) => {
50
66
  if (signal?.aborted) {
51
- reject(cancelledError());
67
+ reject(signal.reason);
52
68
  return;
53
69
  }
54
70
  const xhr = new XMLHttpRequest();
@@ -71,14 +87,20 @@ function uploadTarget(target, body, totalBytes, completedBytes, onProgress, sign
71
87
  });
72
88
  xhr.addEventListener("abort", () => {
73
89
  finish();
74
- reject(cancelledError());
90
+ reject(signal?.reason ?? new DOMException("Upload aborted", "AbortError"));
75
91
  });
76
92
  signal?.addEventListener("abort", abort, { once: true });
77
- xhr.open(target.method, target.url);
78
- for (const [key, value] of Object.entries(target.headers ?? {})) {
79
- xhr.setRequestHeader(key, value);
93
+ try {
94
+ xhr.open(target.method, target.url);
95
+ for (const [key, value] of Object.entries(target.headers ?? {})) {
96
+ xhr.setRequestHeader(key, value);
97
+ }
98
+ xhr.send(body);
99
+ }
100
+ catch (error) {
101
+ finish();
102
+ reject(error);
80
103
  }
81
- xhr.send(body);
82
104
  });
83
105
  }
84
106
  function uploadProgress(loaded, total) {
@@ -88,10 +110,3 @@ function uploadProgress(loaded, total) {
88
110
  percent: total > 0 ? Math.round((loaded / total) * 100) : 0,
89
111
  };
90
112
  }
91
- function throwIfCancelled(signal) {
92
- if (signal?.aborted)
93
- throw cancelledError();
94
- }
95
- function cancelledError() {
96
- return new Error("Upload cancelled.");
97
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@woodleighschool/bloby-client",
3
- "version": "0.2.0",
3
+ "version": "1.0.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",