@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 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
- /** Error thrown when an SDK request fails. */
29
- declare class StowError extends Error {
30
- readonly status: number;
31
- readonly code?: string;
32
- constructor(message: string, status: number, code?: string);
33
- }
34
- /** Configuration for creating a {@link StowClient} instance. */
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
- /** Result returned after a successful upload or dedupe short-circuit. */
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
- /** Progress payload emitted during `uploadFile`. */
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
- /** Optional behavior for a single upload operation. */
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 attach to the file */
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 upload route (server-side configured route/path policy). */
94
+ /** Optional route or folder hint forwarded to your server's presign endpoint. */
69
95
  route?: string;
70
96
  }
71
- /** Browser SDK for direct-to-R2 uploads via your server's presign/confirm endpoints. */
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 file directly to R2 using presigned URL with progress tracking
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
- /** Error thrown when an SDK request fails. */
29
- declare class StowError extends Error {
30
- readonly status: number;
31
- readonly code?: string;
32
- constructor(message: string, status: number, code?: string);
33
- }
34
- /** Configuration for creating a {@link StowClient} instance. */
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
- /** Result returned after a successful upload or dedupe short-circuit. */
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
- /** Progress payload emitted during `uploadFile`. */
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
- /** Optional behavior for a single upload operation. */
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 attach to the file */
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 upload route (server-side configured route/path policy). */
94
+ /** Optional route or folder hint forwarded to your server's presign endpoint. */
69
95
  route?: string;
70
96
  }
71
- /** Browser SDK for direct-to-R2 uploads via your server's presign/confirm endpoints. */
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 file directly to R2 using presigned URL with progress tracking
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
- if (typeof config === "string") {
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 this.uploadToR2(presign.uploadUrl, file, (loaded) => {
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
- this.resolveConfirmUrl(presign.confirmUrl),
143
- {
144
- method: "POST",
145
- headers: { "Content-Type": "application/json" },
146
- body: JSON.stringify({
147
- fileKey: presign.fileKey,
148
- size: file.size,
149
- contentType: file.type || "application/octet-stream",
150
- ...metadata ? { metadata } : {}
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 file directly to R2 using presigned URL with progress tracking
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(fileArray[i], options);
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
- fileArray[i],
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 = new Array(fileArray.length);
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(fileArray[i], options);
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, fileArray[i], i);
253
+ options?.onFileError?.(err, file, i);
248
254
  if (!firstError) {
249
- firstError = { error: err, file: fileArray[i], index: i };
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/index.ts
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
- if (typeof config === "string") {
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 this.uploadToR2(presign.uploadUrl, file, (loaded) => {
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
- this.resolveConfirmUrl(presign.confirmUrl),
118
- {
119
- method: "POST",
120
- headers: { "Content-Type": "application/json" },
121
- body: JSON.stringify({
122
- fileKey: presign.fileKey,
123
- size: file.size,
124
- contentType: file.type || "application/octet-stream",
125
- ...metadata ? { metadata } : {}
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 file directly to R2 using presigned URL with progress tracking
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(fileArray[i], options);
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
- fileArray[i],
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 = new Array(fileArray.length);
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(fileArray[i], options);
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, fileArray[i], i);
226
+ options?.onFileError?.(err, file, i);
223
227
  if (!firstError) {
224
- firstError = { error: err, file: fileArray[i], index: i };
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.1.0",
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
- "homepage": "https://stow.sh",
12
- "keywords": [
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
- "vitest": "^4.1.0",
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": "vitest run",
43
- "test:watch": "vitest"
42
+ "test": "vp test run",
43
+ "test:watch": "vp test"
44
44
  }
45
45
  }