@howells/stow-client 2.1.0 → 2.4.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/dist/index.d.mts +78 -19
- package/dist/index.d.ts +78 -19
- package/dist/index.js +82 -79
- package/dist/index.mjs +81 -80
- package/package.json +14 -14
package/dist/index.d.mts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/** Error thrown when an SDK request fails. */
|
|
2
|
+
declare class StowError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly code?: string;
|
|
5
|
+
constructor(message: string, status: number, code?: string);
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
/**
|
|
2
9
|
* Stow Client SDK
|
|
3
10
|
*
|
|
@@ -25,13 +32,17 @@
|
|
|
25
32
|
* console.log(result.url);
|
|
26
33
|
* ```
|
|
27
34
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Configuration for creating a {@link StowClient} instance.
|
|
38
|
+
*
|
|
39
|
+
* `endpoint` must point at your own application endpoint, not Stow directly.
|
|
40
|
+
* That endpoint is expected to wrap `@howells/stow-server` and expose:
|
|
41
|
+
* - `POST {endpoint}/presign`
|
|
42
|
+
* - `POST {endpoint}/confirm`
|
|
43
|
+
*
|
|
44
|
+
* The browser package never handles API keys.
|
|
45
|
+
*/
|
|
35
46
|
interface StowClientConfig {
|
|
36
47
|
/**
|
|
37
48
|
* Your server endpoint that handles presign/confirm requests.
|
|
@@ -39,7 +50,12 @@ interface StowClientConfig {
|
|
|
39
50
|
*/
|
|
40
51
|
endpoint: string;
|
|
41
52
|
}
|
|
42
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Result returned after a successful upload or dedupe short-circuit.
|
|
55
|
+
*
|
|
56
|
+
* When `deduped` is `true`, no bytes were uploaded because the server matched
|
|
57
|
+
* the file to an existing object using content-hash deduplication.
|
|
58
|
+
*/
|
|
43
59
|
interface UploadResult {
|
|
44
60
|
contentType: string;
|
|
45
61
|
/** True when upload short-circuited to an existing file via dedupe. */
|
|
@@ -48,7 +64,13 @@ interface UploadResult {
|
|
|
48
64
|
size: number;
|
|
49
65
|
url: string | null;
|
|
50
66
|
}
|
|
51
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Progress payload emitted during `uploadFile()` and `uploadFiles()`.
|
|
69
|
+
*
|
|
70
|
+
* `phase` is useful for UI state because uploads have three distinct stages:
|
|
71
|
+
* presigning on your app server, uploading to object storage, and confirming
|
|
72
|
+
* the completed upload back through your app.
|
|
73
|
+
*/
|
|
52
74
|
interface UploadProgress {
|
|
53
75
|
/** Bytes uploaded so far */
|
|
54
76
|
loaded: number;
|
|
@@ -59,16 +81,40 @@ interface UploadProgress {
|
|
|
59
81
|
/** Total bytes to upload */
|
|
60
82
|
total: number;
|
|
61
83
|
}
|
|
62
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Optional behavior for a single upload operation.
|
|
86
|
+
*
|
|
87
|
+
* This object is reused by `uploadFile()` and `uploadFiles()`.
|
|
88
|
+
*/
|
|
63
89
|
interface UploadOptions {
|
|
64
|
-
/** Custom metadata to
|
|
90
|
+
/** Custom metadata to persist on the Stow file record during confirm. */
|
|
65
91
|
metadata?: Record<string, string>;
|
|
66
|
-
/** Progress callback */
|
|
92
|
+
/** Progress callback for presign, upload, and confirm phases. */
|
|
67
93
|
onProgress?: (progress: UploadProgress) => void;
|
|
68
|
-
/** Optional
|
|
94
|
+
/** Optional route or folder hint forwarded to your server's presign endpoint. */
|
|
69
95
|
route?: string;
|
|
70
96
|
}
|
|
71
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Browser SDK for direct-to-storage uploads via your app's presign/confirm endpoints.
|
|
99
|
+
*
|
|
100
|
+
* This package is intentionally narrow: it only handles the browser side of the
|
|
101
|
+
* upload handshake. Pair it with `@howells/stow-next` if you want ready-made
|
|
102
|
+
* Next.js route handlers, or with `@howells/stow-server` if you are building
|
|
103
|
+
* your own backend endpoints.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* const stow = new StowClient("/api/stow");
|
|
108
|
+
*
|
|
109
|
+
* const result = await stow.uploadFile(file, {
|
|
110
|
+
* route: "products/featured",
|
|
111
|
+
* metadata: { sku: "SKU-123" },
|
|
112
|
+
* onProgress: ({ phase, percent }) => {
|
|
113
|
+
* console.log(phase, percent);
|
|
114
|
+
* },
|
|
115
|
+
* });
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
72
118
|
declare class StowClient {
|
|
73
119
|
private readonly endpoint;
|
|
74
120
|
/**
|
|
@@ -77,6 +123,8 @@ declare class StowClient {
|
|
|
77
123
|
* The endpoint should expose:
|
|
78
124
|
* - `POST {endpoint}/presign`
|
|
79
125
|
* - `POST {endpoint}/confirm` (or a `confirmUrl` returned from presign)
|
|
126
|
+
*
|
|
127
|
+
* Both `"/api/stow"` and `{ endpoint: "/api/stow" }` are accepted.
|
|
80
128
|
*/
|
|
81
129
|
constructor(config: StowClientConfig | string);
|
|
82
130
|
private endpointUrl;
|
|
@@ -90,18 +138,29 @@ declare class StowClient {
|
|
|
90
138
|
* Dedup behavior:
|
|
91
139
|
* If presign responds with `{ dedupe: true, ... }`, upload skips R2 PUT and
|
|
92
140
|
* confirm entirely, returning the existing file metadata with `deduped: true`.
|
|
141
|
+
*
|
|
142
|
+
* This method rejects with {@link StowError} for presign, network, R2, and
|
|
143
|
+
* confirm failures.
|
|
93
144
|
*/
|
|
94
145
|
uploadFile(file: File, options?: UploadOptions): Promise<UploadResult>;
|
|
95
146
|
/**
|
|
96
|
-
* Upload
|
|
97
|
-
*/
|
|
98
|
-
private uploadToR2;
|
|
99
|
-
/**
|
|
100
|
-
* Upload multiple files with optional concurrency
|
|
147
|
+
* Upload multiple files with bounded concurrency.
|
|
101
148
|
*
|
|
102
149
|
* - `concurrency=1` preserves strict sequential ordering.
|
|
103
150
|
* - `concurrency>1` uses a bounded worker pool and stops scheduling new files
|
|
104
151
|
* after the first error.
|
|
152
|
+
*
|
|
153
|
+
* The returned array preserves the original input order.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```typescript
|
|
157
|
+
* const results = await stow.uploadFiles(input.files, {
|
|
158
|
+
* concurrency: 4,
|
|
159
|
+
* onFileComplete: (result, index) => {
|
|
160
|
+
* console.log("uploaded", index, result.key);
|
|
161
|
+
* },
|
|
162
|
+
* });
|
|
163
|
+
* ```
|
|
105
164
|
*/
|
|
106
165
|
uploadFiles(files: FileList | File[], options?: UploadOptions & {
|
|
107
166
|
/** Number of concurrent uploads (default: 3) */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/** Error thrown when an SDK request fails. */
|
|
2
|
+
declare class StowError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly code?: string;
|
|
5
|
+
constructor(message: string, status: number, code?: string);
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
/**
|
|
2
9
|
* Stow Client SDK
|
|
3
10
|
*
|
|
@@ -25,13 +32,17 @@
|
|
|
25
32
|
* console.log(result.url);
|
|
26
33
|
* ```
|
|
27
34
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Configuration for creating a {@link StowClient} instance.
|
|
38
|
+
*
|
|
39
|
+
* `endpoint` must point at your own application endpoint, not Stow directly.
|
|
40
|
+
* That endpoint is expected to wrap `@howells/stow-server` and expose:
|
|
41
|
+
* - `POST {endpoint}/presign`
|
|
42
|
+
* - `POST {endpoint}/confirm`
|
|
43
|
+
*
|
|
44
|
+
* The browser package never handles API keys.
|
|
45
|
+
*/
|
|
35
46
|
interface StowClientConfig {
|
|
36
47
|
/**
|
|
37
48
|
* Your server endpoint that handles presign/confirm requests.
|
|
@@ -39,7 +50,12 @@ interface StowClientConfig {
|
|
|
39
50
|
*/
|
|
40
51
|
endpoint: string;
|
|
41
52
|
}
|
|
42
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Result returned after a successful upload or dedupe short-circuit.
|
|
55
|
+
*
|
|
56
|
+
* When `deduped` is `true`, no bytes were uploaded because the server matched
|
|
57
|
+
* the file to an existing object using content-hash deduplication.
|
|
58
|
+
*/
|
|
43
59
|
interface UploadResult {
|
|
44
60
|
contentType: string;
|
|
45
61
|
/** True when upload short-circuited to an existing file via dedupe. */
|
|
@@ -48,7 +64,13 @@ interface UploadResult {
|
|
|
48
64
|
size: number;
|
|
49
65
|
url: string | null;
|
|
50
66
|
}
|
|
51
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Progress payload emitted during `uploadFile()` and `uploadFiles()`.
|
|
69
|
+
*
|
|
70
|
+
* `phase` is useful for UI state because uploads have three distinct stages:
|
|
71
|
+
* presigning on your app server, uploading to object storage, and confirming
|
|
72
|
+
* the completed upload back through your app.
|
|
73
|
+
*/
|
|
52
74
|
interface UploadProgress {
|
|
53
75
|
/** Bytes uploaded so far */
|
|
54
76
|
loaded: number;
|
|
@@ -59,16 +81,40 @@ interface UploadProgress {
|
|
|
59
81
|
/** Total bytes to upload */
|
|
60
82
|
total: number;
|
|
61
83
|
}
|
|
62
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Optional behavior for a single upload operation.
|
|
86
|
+
*
|
|
87
|
+
* This object is reused by `uploadFile()` and `uploadFiles()`.
|
|
88
|
+
*/
|
|
63
89
|
interface UploadOptions {
|
|
64
|
-
/** Custom metadata to
|
|
90
|
+
/** Custom metadata to persist on the Stow file record during confirm. */
|
|
65
91
|
metadata?: Record<string, string>;
|
|
66
|
-
/** Progress callback */
|
|
92
|
+
/** Progress callback for presign, upload, and confirm phases. */
|
|
67
93
|
onProgress?: (progress: UploadProgress) => void;
|
|
68
|
-
/** Optional
|
|
94
|
+
/** Optional route or folder hint forwarded to your server's presign endpoint. */
|
|
69
95
|
route?: string;
|
|
70
96
|
}
|
|
71
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Browser SDK for direct-to-storage uploads via your app's presign/confirm endpoints.
|
|
99
|
+
*
|
|
100
|
+
* This package is intentionally narrow: it only handles the browser side of the
|
|
101
|
+
* upload handshake. Pair it with `@howells/stow-next` if you want ready-made
|
|
102
|
+
* Next.js route handlers, or with `@howells/stow-server` if you are building
|
|
103
|
+
* your own backend endpoints.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* const stow = new StowClient("/api/stow");
|
|
108
|
+
*
|
|
109
|
+
* const result = await stow.uploadFile(file, {
|
|
110
|
+
* route: "products/featured",
|
|
111
|
+
* metadata: { sku: "SKU-123" },
|
|
112
|
+
* onProgress: ({ phase, percent }) => {
|
|
113
|
+
* console.log(phase, percent);
|
|
114
|
+
* },
|
|
115
|
+
* });
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
72
118
|
declare class StowClient {
|
|
73
119
|
private readonly endpoint;
|
|
74
120
|
/**
|
|
@@ -77,6 +123,8 @@ declare class StowClient {
|
|
|
77
123
|
* The endpoint should expose:
|
|
78
124
|
* - `POST {endpoint}/presign`
|
|
79
125
|
* - `POST {endpoint}/confirm` (or a `confirmUrl` returned from presign)
|
|
126
|
+
*
|
|
127
|
+
* Both `"/api/stow"` and `{ endpoint: "/api/stow" }` are accepted.
|
|
80
128
|
*/
|
|
81
129
|
constructor(config: StowClientConfig | string);
|
|
82
130
|
private endpointUrl;
|
|
@@ -90,18 +138,29 @@ declare class StowClient {
|
|
|
90
138
|
* Dedup behavior:
|
|
91
139
|
* If presign responds with `{ dedupe: true, ... }`, upload skips R2 PUT and
|
|
92
140
|
* confirm entirely, returning the existing file metadata with `deduped: true`.
|
|
141
|
+
*
|
|
142
|
+
* This method rejects with {@link StowError} for presign, network, R2, and
|
|
143
|
+
* confirm failures.
|
|
93
144
|
*/
|
|
94
145
|
uploadFile(file: File, options?: UploadOptions): Promise<UploadResult>;
|
|
95
146
|
/**
|
|
96
|
-
* Upload
|
|
97
|
-
*/
|
|
98
|
-
private uploadToR2;
|
|
99
|
-
/**
|
|
100
|
-
* Upload multiple files with optional concurrency
|
|
147
|
+
* Upload multiple files with bounded concurrency.
|
|
101
148
|
*
|
|
102
149
|
* - `concurrency=1` preserves strict sequential ordering.
|
|
103
150
|
* - `concurrency>1` uses a bounded worker pool and stops scheduling new files
|
|
104
151
|
* after the first error.
|
|
152
|
+
*
|
|
153
|
+
* The returned array preserves the original input order.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```typescript
|
|
157
|
+
* const results = await stow.uploadFiles(input.files, {
|
|
158
|
+
* concurrency: 4,
|
|
159
|
+
* onFileComplete: (result, index) => {
|
|
160
|
+
* console.log("uploaded", index, result.key);
|
|
161
|
+
* },
|
|
162
|
+
* });
|
|
163
|
+
* ```
|
|
105
164
|
*/
|
|
106
165
|
uploadFiles(files: FileList | File[], options?: UploadOptions & {
|
|
107
166
|
/** Number of concurrent uploads (default: 3) */
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,8 @@ __export(index_exports, {
|
|
|
24
24
|
StowError: () => StowError
|
|
25
25
|
});
|
|
26
26
|
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/stow-error.ts
|
|
27
29
|
var StowError = class extends Error {
|
|
28
30
|
status;
|
|
29
31
|
code;
|
|
@@ -34,6 +36,34 @@ var StowError = class extends Error {
|
|
|
34
36
|
this.code = code;
|
|
35
37
|
}
|
|
36
38
|
};
|
|
39
|
+
|
|
40
|
+
// src/index.ts
|
|
41
|
+
function uploadToR2(uploadUrl, file, onProgress) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const xhr = new XMLHttpRequest();
|
|
44
|
+
xhr.upload.addEventListener("progress", (event) => {
|
|
45
|
+
if (event.lengthComputable && onProgress) {
|
|
46
|
+
onProgress(event.loaded);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
xhr.addEventListener("load", () => {
|
|
50
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
51
|
+
resolve(void 0);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
reject(new StowError(`R2 upload failed with status ${xhr.status}`, xhr.status));
|
|
55
|
+
});
|
|
56
|
+
xhr.addEventListener("error", () => {
|
|
57
|
+
reject(new StowError("Network error during R2 upload", 0));
|
|
58
|
+
});
|
|
59
|
+
xhr.addEventListener("abort", () => {
|
|
60
|
+
reject(new StowError("R2 upload aborted", 0, "ABORTED"));
|
|
61
|
+
});
|
|
62
|
+
xhr.open("PUT", uploadUrl);
|
|
63
|
+
xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");
|
|
64
|
+
xhr.send(file);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
37
67
|
var StowClient = class {
|
|
38
68
|
endpoint;
|
|
39
69
|
/**
|
|
@@ -42,13 +72,11 @@ var StowClient = class {
|
|
|
42
72
|
* The endpoint should expose:
|
|
43
73
|
* - `POST {endpoint}/presign`
|
|
44
74
|
* - `POST {endpoint}/confirm` (or a `confirmUrl` returned from presign)
|
|
75
|
+
*
|
|
76
|
+
* Both `"/api/stow"` and `{ endpoint: "/api/stow" }` are accepted.
|
|
45
77
|
*/
|
|
46
78
|
constructor(config) {
|
|
47
|
-
|
|
48
|
-
this.endpoint = config.replace(/\/+$/, "");
|
|
49
|
-
} else {
|
|
50
|
-
this.endpoint = config.endpoint.replace(/\/+$/, "");
|
|
51
|
-
}
|
|
79
|
+
this.endpoint = typeof config === "string" ? config.replace(/\/+$/, "") : config.endpoint.replace(/\/+$/, "");
|
|
52
80
|
}
|
|
53
81
|
endpointUrl(path) {
|
|
54
82
|
if (!this.endpoint) {
|
|
@@ -74,6 +102,9 @@ var StowClient = class {
|
|
|
74
102
|
* Dedup behavior:
|
|
75
103
|
* If presign responds with `{ dedupe: true, ... }`, upload skips R2 PUT and
|
|
76
104
|
* confirm entirely, returning the existing file metadata with `deduped: true`.
|
|
105
|
+
*
|
|
106
|
+
* This method rejects with {@link StowError} for presign, network, R2, and
|
|
107
|
+
* confirm failures.
|
|
77
108
|
*/
|
|
78
109
|
async uploadFile(file, options) {
|
|
79
110
|
const { route, metadata, onProgress } = options || {};
|
|
@@ -96,11 +127,7 @@ var StowClient = class {
|
|
|
96
127
|
});
|
|
97
128
|
if (!presignResponse.ok) {
|
|
98
129
|
const error = await presignResponse.json().catch(() => ({ error: "Presign failed" }));
|
|
99
|
-
throw new StowError(
|
|
100
|
-
error.error || "Presign failed",
|
|
101
|
-
presignResponse.status,
|
|
102
|
-
error.code
|
|
103
|
-
);
|
|
130
|
+
throw new StowError(error.error || "Presign failed", presignResponse.status, error.code);
|
|
104
131
|
}
|
|
105
132
|
const presign = await presignResponse.json();
|
|
106
133
|
if ("dedupe" in presign && presign.dedupe) {
|
|
@@ -124,7 +151,7 @@ var StowClient = class {
|
|
|
124
151
|
percent: 0,
|
|
125
152
|
phase: "uploading"
|
|
126
153
|
});
|
|
127
|
-
await
|
|
154
|
+
await uploadToR2(presign.uploadUrl, file, (loaded) => {
|
|
128
155
|
onProgress?.({
|
|
129
156
|
loaded,
|
|
130
157
|
total: file.size,
|
|
@@ -138,26 +165,19 @@ var StowClient = class {
|
|
|
138
165
|
percent: 100,
|
|
139
166
|
phase: "confirming"
|
|
140
167
|
});
|
|
141
|
-
const confirmResponse = await fetch(
|
|
142
|
-
|
|
143
|
-
{
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
})
|
|
152
|
-
}
|
|
153
|
-
);
|
|
168
|
+
const confirmResponse = await fetch(this.resolveConfirmUrl(presign.confirmUrl), {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "Content-Type": "application/json" },
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
fileKey: presign.fileKey,
|
|
173
|
+
size: file.size,
|
|
174
|
+
contentType: file.type || "application/octet-stream",
|
|
175
|
+
...metadata ? { metadata } : {}
|
|
176
|
+
})
|
|
177
|
+
});
|
|
154
178
|
if (!confirmResponse.ok) {
|
|
155
179
|
const error = await confirmResponse.json().catch(() => ({ error: "Confirm failed" }));
|
|
156
|
-
throw new StowError(
|
|
157
|
-
error.error || "Confirm failed",
|
|
158
|
-
confirmResponse.status,
|
|
159
|
-
error.code
|
|
160
|
-
);
|
|
180
|
+
throw new StowError(error.error || "Confirm failed", confirmResponse.status, error.code);
|
|
161
181
|
}
|
|
162
182
|
const result = await confirmResponse.json();
|
|
163
183
|
return {
|
|
@@ -168,63 +188,42 @@ var StowClient = class {
|
|
|
168
188
|
};
|
|
169
189
|
}
|
|
170
190
|
/**
|
|
171
|
-
* Upload
|
|
172
|
-
*/
|
|
173
|
-
uploadToR2(uploadUrl, file, onProgress) {
|
|
174
|
-
return new Promise((resolve, reject) => {
|
|
175
|
-
const xhr = new XMLHttpRequest();
|
|
176
|
-
xhr.upload.addEventListener("progress", (event) => {
|
|
177
|
-
if (event.lengthComputable && onProgress) {
|
|
178
|
-
onProgress(event.loaded);
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
xhr.addEventListener("load", () => {
|
|
182
|
-
if (xhr.status >= 200 && xhr.status < 300) {
|
|
183
|
-
resolve();
|
|
184
|
-
} else {
|
|
185
|
-
reject(
|
|
186
|
-
new StowError(
|
|
187
|
-
`R2 upload failed with status ${xhr.status}`,
|
|
188
|
-
xhr.status
|
|
189
|
-
)
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
xhr.addEventListener("error", () => {
|
|
194
|
-
reject(new StowError("Network error during R2 upload", 0));
|
|
195
|
-
});
|
|
196
|
-
xhr.addEventListener("abort", () => {
|
|
197
|
-
reject(new StowError("R2 upload aborted", 0, "ABORTED"));
|
|
198
|
-
});
|
|
199
|
-
xhr.open("PUT", uploadUrl);
|
|
200
|
-
xhr.setRequestHeader(
|
|
201
|
-
"Content-Type",
|
|
202
|
-
file.type || "application/octet-stream"
|
|
203
|
-
);
|
|
204
|
-
xhr.send(file);
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
/**
|
|
208
|
-
* Upload multiple files with optional concurrency
|
|
191
|
+
* Upload multiple files with bounded concurrency.
|
|
209
192
|
*
|
|
210
193
|
* - `concurrency=1` preserves strict sequential ordering.
|
|
211
194
|
* - `concurrency>1` uses a bounded worker pool and stops scheduling new files
|
|
212
195
|
* after the first error.
|
|
196
|
+
*
|
|
197
|
+
* The returned array preserves the original input order.
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```typescript
|
|
201
|
+
* const results = await stow.uploadFiles(input.files, {
|
|
202
|
+
* concurrency: 4,
|
|
203
|
+
* onFileComplete: (result, index) => {
|
|
204
|
+
* console.log("uploaded", index, result.key);
|
|
205
|
+
* },
|
|
206
|
+
* });
|
|
207
|
+
* ```
|
|
213
208
|
*/
|
|
214
209
|
async uploadFiles(files, options) {
|
|
215
210
|
const fileArray = Array.from(files);
|
|
216
211
|
const concurrency = Math.max(1, options?.concurrency ?? 3);
|
|
217
212
|
if (concurrency === 1) {
|
|
218
213
|
const results2 = [];
|
|
219
|
-
for (let i = 0; i < fileArray.length; i
|
|
214
|
+
for (let i = 0; i < fileArray.length; i += 1) {
|
|
215
|
+
const file = fileArray[i];
|
|
216
|
+
if (!file) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
220
219
|
try {
|
|
221
|
-
const result = await this.uploadFile(
|
|
220
|
+
const result = await this.uploadFile(file, options);
|
|
222
221
|
results2.push(result);
|
|
223
222
|
options?.onFileComplete?.(result, i);
|
|
224
223
|
} catch (error) {
|
|
225
224
|
options?.onFileError?.(
|
|
226
225
|
error instanceof Error ? error : new Error("Upload failed"),
|
|
227
|
-
|
|
226
|
+
file,
|
|
228
227
|
i
|
|
229
228
|
);
|
|
230
229
|
throw error;
|
|
@@ -232,29 +231,33 @@ var StowClient = class {
|
|
|
232
231
|
}
|
|
233
232
|
return results2;
|
|
234
233
|
}
|
|
235
|
-
const results =
|
|
234
|
+
const results = Array.from({
|
|
235
|
+
length: fileArray.length
|
|
236
|
+
});
|
|
236
237
|
let nextIndex = 0;
|
|
237
238
|
let firstError = null;
|
|
238
239
|
const worker = async () => {
|
|
239
240
|
while (nextIndex < fileArray.length && !firstError) {
|
|
240
|
-
const i = nextIndex
|
|
241
|
+
const i = nextIndex;
|
|
242
|
+
nextIndex += 1;
|
|
243
|
+
const file = fileArray[i];
|
|
244
|
+
if (!file) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
241
247
|
try {
|
|
242
|
-
const result = await this.uploadFile(
|
|
248
|
+
const result = await this.uploadFile(file, options);
|
|
243
249
|
results[i] = result;
|
|
244
250
|
options?.onFileComplete?.(result, i);
|
|
245
251
|
} catch (error) {
|
|
246
252
|
const err = error instanceof Error ? error : new Error("Upload failed");
|
|
247
|
-
options?.onFileError?.(err,
|
|
253
|
+
options?.onFileError?.(err, file, i);
|
|
248
254
|
if (!firstError) {
|
|
249
|
-
firstError = { error: err, file
|
|
255
|
+
firstError = { error: err, file, index: i };
|
|
250
256
|
}
|
|
251
257
|
}
|
|
252
258
|
}
|
|
253
259
|
};
|
|
254
|
-
const workers = Array.from(
|
|
255
|
-
{ length: Math.min(concurrency, fileArray.length) },
|
|
256
|
-
() => worker()
|
|
257
|
-
);
|
|
260
|
+
const workers = Array.from({ length: Math.min(concurrency, fileArray.length) }, () => worker());
|
|
258
261
|
await Promise.all(workers);
|
|
259
262
|
if (firstError !== null) {
|
|
260
263
|
throw firstError.error;
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/
|
|
1
|
+
// src/stow-error.ts
|
|
2
2
|
var StowError = class extends Error {
|
|
3
3
|
status;
|
|
4
4
|
code;
|
|
@@ -9,6 +9,34 @@ var StowError = class extends Error {
|
|
|
9
9
|
this.code = code;
|
|
10
10
|
}
|
|
11
11
|
};
|
|
12
|
+
|
|
13
|
+
// src/index.ts
|
|
14
|
+
function uploadToR2(uploadUrl, file, onProgress) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const xhr = new XMLHttpRequest();
|
|
17
|
+
xhr.upload.addEventListener("progress", (event) => {
|
|
18
|
+
if (event.lengthComputable && onProgress) {
|
|
19
|
+
onProgress(event.loaded);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
xhr.addEventListener("load", () => {
|
|
23
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
24
|
+
resolve(void 0);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
reject(new StowError(`R2 upload failed with status ${xhr.status}`, xhr.status));
|
|
28
|
+
});
|
|
29
|
+
xhr.addEventListener("error", () => {
|
|
30
|
+
reject(new StowError("Network error during R2 upload", 0));
|
|
31
|
+
});
|
|
32
|
+
xhr.addEventListener("abort", () => {
|
|
33
|
+
reject(new StowError("R2 upload aborted", 0, "ABORTED"));
|
|
34
|
+
});
|
|
35
|
+
xhr.open("PUT", uploadUrl);
|
|
36
|
+
xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");
|
|
37
|
+
xhr.send(file);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
12
40
|
var StowClient = class {
|
|
13
41
|
endpoint;
|
|
14
42
|
/**
|
|
@@ -17,13 +45,11 @@ var StowClient = class {
|
|
|
17
45
|
* The endpoint should expose:
|
|
18
46
|
* - `POST {endpoint}/presign`
|
|
19
47
|
* - `POST {endpoint}/confirm` (or a `confirmUrl` returned from presign)
|
|
48
|
+
*
|
|
49
|
+
* Both `"/api/stow"` and `{ endpoint: "/api/stow" }` are accepted.
|
|
20
50
|
*/
|
|
21
51
|
constructor(config) {
|
|
22
|
-
|
|
23
|
-
this.endpoint = config.replace(/\/+$/, "");
|
|
24
|
-
} else {
|
|
25
|
-
this.endpoint = config.endpoint.replace(/\/+$/, "");
|
|
26
|
-
}
|
|
52
|
+
this.endpoint = typeof config === "string" ? config.replace(/\/+$/, "") : config.endpoint.replace(/\/+$/, "");
|
|
27
53
|
}
|
|
28
54
|
endpointUrl(path) {
|
|
29
55
|
if (!this.endpoint) {
|
|
@@ -49,6 +75,9 @@ var StowClient = class {
|
|
|
49
75
|
* Dedup behavior:
|
|
50
76
|
* If presign responds with `{ dedupe: true, ... }`, upload skips R2 PUT and
|
|
51
77
|
* confirm entirely, returning the existing file metadata with `deduped: true`.
|
|
78
|
+
*
|
|
79
|
+
* This method rejects with {@link StowError} for presign, network, R2, and
|
|
80
|
+
* confirm failures.
|
|
52
81
|
*/
|
|
53
82
|
async uploadFile(file, options) {
|
|
54
83
|
const { route, metadata, onProgress } = options || {};
|
|
@@ -71,11 +100,7 @@ var StowClient = class {
|
|
|
71
100
|
});
|
|
72
101
|
if (!presignResponse.ok) {
|
|
73
102
|
const error = await presignResponse.json().catch(() => ({ error: "Presign failed" }));
|
|
74
|
-
throw new StowError(
|
|
75
|
-
error.error || "Presign failed",
|
|
76
|
-
presignResponse.status,
|
|
77
|
-
error.code
|
|
78
|
-
);
|
|
103
|
+
throw new StowError(error.error || "Presign failed", presignResponse.status, error.code);
|
|
79
104
|
}
|
|
80
105
|
const presign = await presignResponse.json();
|
|
81
106
|
if ("dedupe" in presign && presign.dedupe) {
|
|
@@ -99,7 +124,7 @@ var StowClient = class {
|
|
|
99
124
|
percent: 0,
|
|
100
125
|
phase: "uploading"
|
|
101
126
|
});
|
|
102
|
-
await
|
|
127
|
+
await uploadToR2(presign.uploadUrl, file, (loaded) => {
|
|
103
128
|
onProgress?.({
|
|
104
129
|
loaded,
|
|
105
130
|
total: file.size,
|
|
@@ -113,26 +138,19 @@ var StowClient = class {
|
|
|
113
138
|
percent: 100,
|
|
114
139
|
phase: "confirming"
|
|
115
140
|
});
|
|
116
|
-
const confirmResponse = await fetch(
|
|
117
|
-
|
|
118
|
-
{
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
})
|
|
127
|
-
}
|
|
128
|
-
);
|
|
141
|
+
const confirmResponse = await fetch(this.resolveConfirmUrl(presign.confirmUrl), {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "Content-Type": "application/json" },
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
fileKey: presign.fileKey,
|
|
146
|
+
size: file.size,
|
|
147
|
+
contentType: file.type || "application/octet-stream",
|
|
148
|
+
...metadata ? { metadata } : {}
|
|
149
|
+
})
|
|
150
|
+
});
|
|
129
151
|
if (!confirmResponse.ok) {
|
|
130
152
|
const error = await confirmResponse.json().catch(() => ({ error: "Confirm failed" }));
|
|
131
|
-
throw new StowError(
|
|
132
|
-
error.error || "Confirm failed",
|
|
133
|
-
confirmResponse.status,
|
|
134
|
-
error.code
|
|
135
|
-
);
|
|
153
|
+
throw new StowError(error.error || "Confirm failed", confirmResponse.status, error.code);
|
|
136
154
|
}
|
|
137
155
|
const result = await confirmResponse.json();
|
|
138
156
|
return {
|
|
@@ -143,63 +161,42 @@ var StowClient = class {
|
|
|
143
161
|
};
|
|
144
162
|
}
|
|
145
163
|
/**
|
|
146
|
-
* Upload
|
|
147
|
-
*/
|
|
148
|
-
uploadToR2(uploadUrl, file, onProgress) {
|
|
149
|
-
return new Promise((resolve, reject) => {
|
|
150
|
-
const xhr = new XMLHttpRequest();
|
|
151
|
-
xhr.upload.addEventListener("progress", (event) => {
|
|
152
|
-
if (event.lengthComputable && onProgress) {
|
|
153
|
-
onProgress(event.loaded);
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
xhr.addEventListener("load", () => {
|
|
157
|
-
if (xhr.status >= 200 && xhr.status < 300) {
|
|
158
|
-
resolve();
|
|
159
|
-
} else {
|
|
160
|
-
reject(
|
|
161
|
-
new StowError(
|
|
162
|
-
`R2 upload failed with status ${xhr.status}`,
|
|
163
|
-
xhr.status
|
|
164
|
-
)
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
xhr.addEventListener("error", () => {
|
|
169
|
-
reject(new StowError("Network error during R2 upload", 0));
|
|
170
|
-
});
|
|
171
|
-
xhr.addEventListener("abort", () => {
|
|
172
|
-
reject(new StowError("R2 upload aborted", 0, "ABORTED"));
|
|
173
|
-
});
|
|
174
|
-
xhr.open("PUT", uploadUrl);
|
|
175
|
-
xhr.setRequestHeader(
|
|
176
|
-
"Content-Type",
|
|
177
|
-
file.type || "application/octet-stream"
|
|
178
|
-
);
|
|
179
|
-
xhr.send(file);
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Upload multiple files with optional concurrency
|
|
164
|
+
* Upload multiple files with bounded concurrency.
|
|
184
165
|
*
|
|
185
166
|
* - `concurrency=1` preserves strict sequential ordering.
|
|
186
167
|
* - `concurrency>1` uses a bounded worker pool and stops scheduling new files
|
|
187
168
|
* after the first error.
|
|
169
|
+
*
|
|
170
|
+
* The returned array preserves the original input order.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```typescript
|
|
174
|
+
* const results = await stow.uploadFiles(input.files, {
|
|
175
|
+
* concurrency: 4,
|
|
176
|
+
* onFileComplete: (result, index) => {
|
|
177
|
+
* console.log("uploaded", index, result.key);
|
|
178
|
+
* },
|
|
179
|
+
* });
|
|
180
|
+
* ```
|
|
188
181
|
*/
|
|
189
182
|
async uploadFiles(files, options) {
|
|
190
183
|
const fileArray = Array.from(files);
|
|
191
184
|
const concurrency = Math.max(1, options?.concurrency ?? 3);
|
|
192
185
|
if (concurrency === 1) {
|
|
193
186
|
const results2 = [];
|
|
194
|
-
for (let i = 0; i < fileArray.length; i
|
|
187
|
+
for (let i = 0; i < fileArray.length; i += 1) {
|
|
188
|
+
const file = fileArray[i];
|
|
189
|
+
if (!file) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
195
192
|
try {
|
|
196
|
-
const result = await this.uploadFile(
|
|
193
|
+
const result = await this.uploadFile(file, options);
|
|
197
194
|
results2.push(result);
|
|
198
195
|
options?.onFileComplete?.(result, i);
|
|
199
196
|
} catch (error) {
|
|
200
197
|
options?.onFileError?.(
|
|
201
198
|
error instanceof Error ? error : new Error("Upload failed"),
|
|
202
|
-
|
|
199
|
+
file,
|
|
203
200
|
i
|
|
204
201
|
);
|
|
205
202
|
throw error;
|
|
@@ -207,29 +204,33 @@ var StowClient = class {
|
|
|
207
204
|
}
|
|
208
205
|
return results2;
|
|
209
206
|
}
|
|
210
|
-
const results =
|
|
207
|
+
const results = Array.from({
|
|
208
|
+
length: fileArray.length
|
|
209
|
+
});
|
|
211
210
|
let nextIndex = 0;
|
|
212
211
|
let firstError = null;
|
|
213
212
|
const worker = async () => {
|
|
214
213
|
while (nextIndex < fileArray.length && !firstError) {
|
|
215
|
-
const i = nextIndex
|
|
214
|
+
const i = nextIndex;
|
|
215
|
+
nextIndex += 1;
|
|
216
|
+
const file = fileArray[i];
|
|
217
|
+
if (!file) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
216
220
|
try {
|
|
217
|
-
const result = await this.uploadFile(
|
|
221
|
+
const result = await this.uploadFile(file, options);
|
|
218
222
|
results[i] = result;
|
|
219
223
|
options?.onFileComplete?.(result, i);
|
|
220
224
|
} catch (error) {
|
|
221
225
|
const err = error instanceof Error ? error : new Error("Upload failed");
|
|
222
|
-
options?.onFileError?.(err,
|
|
226
|
+
options?.onFileError?.(err, file, i);
|
|
223
227
|
if (!firstError) {
|
|
224
|
-
firstError = { error: err, file
|
|
228
|
+
firstError = { error: err, file, index: i };
|
|
225
229
|
}
|
|
226
230
|
}
|
|
227
231
|
}
|
|
228
232
|
};
|
|
229
|
-
const workers = Array.from(
|
|
230
|
-
{ length: Math.min(concurrency, fileArray.length) },
|
|
231
|
-
() => worker()
|
|
232
|
-
);
|
|
233
|
+
const workers = Array.from({ length: Math.min(concurrency, fileArray.length) }, () => worker());
|
|
233
234
|
await Promise.all(workers);
|
|
234
235
|
if (firstError !== null) {
|
|
235
236
|
throw firstError.error;
|
package/package.json
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@howells/stow-client",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Client-side SDK for Stow file storage",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"file-storage",
|
|
7
|
+
"presigned-url",
|
|
8
|
+
"sdk",
|
|
9
|
+
"stow",
|
|
10
|
+
"upload"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://stow.sh",
|
|
5
13
|
"license": "MIT",
|
|
6
14
|
"repository": {
|
|
7
15
|
"type": "git",
|
|
8
16
|
"url": "git+https://github.com/howells/stow.git",
|
|
9
17
|
"directory": "packages/stow-client"
|
|
10
18
|
},
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
"stow",
|
|
14
|
-
"file-storage",
|
|
15
|
-
"upload",
|
|
16
|
-
"presigned-url",
|
|
17
|
-
"sdk"
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
18
21
|
],
|
|
19
22
|
"main": "./dist/index.js",
|
|
20
23
|
"module": "./dist/index.mjs",
|
|
@@ -26,20 +29,17 @@
|
|
|
26
29
|
"require": "./dist/index.js"
|
|
27
30
|
}
|
|
28
31
|
},
|
|
29
|
-
"files": [
|
|
30
|
-
"dist"
|
|
31
|
-
],
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"jsdom": "^29.0.0",
|
|
34
34
|
"tsup": "^8.5.1",
|
|
35
35
|
"typescript": "^5.9.3",
|
|
36
|
-
"
|
|
36
|
+
"vite-plus": "latest",
|
|
37
37
|
"@stow/typescript-config": "0.0.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
41
41
|
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
42
|
-
"test": "
|
|
43
|
-
"test:watch": "
|
|
42
|
+
"test": "vp test run",
|
|
43
|
+
"test:watch": "vp test"
|
|
44
44
|
}
|
|
45
45
|
}
|