@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 +18 -1
- package/dist/index.d.ts +4 -11
- package/dist/index.js +35 -20
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
90
|
+
reject(signal?.reason ?? new DOMException("Upload aborted", "AbortError"));
|
|
75
91
|
});
|
|
76
92
|
signal?.addEventListener("abort", abort, { once: true });
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
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",
|