🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@howells/stow-client

Package Overview
Dependencies
Maintainers
1
Versions
15
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@howells/stow-client - npm Package Compare versions

Comparing version
2.2.0
to
2.4.0
+78
-19
dist/index.d.mts

@@ -0,1 +1,8 @@

/** Error thrown when an SDK request fails. */
declare class StowError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status: number, code?: string);
}
/**

@@ -28,9 +35,13 @@ * Stow Client SDK

*/
/** Error thrown when an SDK request fails. */
declare class StowError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status: number, code?: string);
}
/** Configuration for creating a {@link StowClient} instance. */
/**
* Configuration for creating a {@link StowClient} instance.
*
* `endpoint` must point at your own application endpoint, not Stow directly.
* That endpoint is expected to wrap `@howells/stow-server` and expose:
* - `POST {endpoint}/presign`
* - `POST {endpoint}/confirm`
*
* The browser package never handles API keys.
*/
interface StowClientConfig {

@@ -43,3 +54,8 @@ /**

}
/** Result returned after a successful upload or dedupe short-circuit. */
/**
* Result returned after a successful upload or dedupe short-circuit.
*
* When `deduped` is `true`, no bytes were uploaded because the server matched
* the file to an existing object using content-hash deduplication.
*/
interface UploadResult {

@@ -53,3 +69,9 @@ contentType: string;

}
/** Progress payload emitted during `uploadFile`. */
/**
* Progress payload emitted during `uploadFile()` and `uploadFiles()`.
*
* `phase` is useful for UI state because uploads have three distinct stages:
* presigning on your app server, uploading to object storage, and confirming
* the completed upload back through your app.
*/
interface UploadProgress {

@@ -65,12 +87,36 @@ /** Bytes uploaded so far */

}
/** Optional behavior for a single upload operation. */
/**
* Optional behavior for a single upload operation.
*
* This object is reused by `uploadFile()` and `uploadFiles()`.
*/
interface UploadOptions {
/** Custom metadata to attach to the file */
/** Custom metadata to persist on the Stow file record during confirm. */
metadata?: Record<string, string>;
/** Progress callback */
/** Progress callback for presign, upload, and confirm phases. */
onProgress?: (progress: UploadProgress) => void;
/** Optional upload route (server-side configured route/path policy). */
/** Optional route or folder hint forwarded to your server's presign endpoint. */
route?: string;
}
/** Browser SDK for direct-to-R2 uploads via your server's presign/confirm endpoints. */
/**
* Browser SDK for direct-to-storage uploads via your app's presign/confirm endpoints.
*
* This package is intentionally narrow: it only handles the browser side of the
* upload handshake. Pair it with `@howells/stow-next` if you want ready-made
* Next.js route handlers, or with `@howells/stow-server` if you are building
* your own backend endpoints.
*
* @example
* ```typescript
* const stow = new StowClient("/api/stow");
*
* const result = await stow.uploadFile(file, {
* route: "products/featured",
* metadata: { sku: "SKU-123" },
* onProgress: ({ phase, percent }) => {
* console.log(phase, percent);
* },
* });
* ```
*/
declare class StowClient {

@@ -84,2 +130,4 @@ private readonly endpoint;

* - `POST {endpoint}/confirm` (or a `confirmUrl` returned from presign)
*
* Both `"/api/stow"` and `{ endpoint: "/api/stow" }` are accepted.
*/

@@ -98,10 +146,9 @@ constructor(config: StowClientConfig | string);

* confirm entirely, returning the existing file metadata with `deduped: true`.
*
* This method rejects with {@link StowError} for presign, network, R2, and
* confirm failures.
*/
uploadFile(file: File, options?: UploadOptions): Promise<UploadResult>;
/**
* Upload file directly to R2 using presigned URL with progress tracking
*/
private uploadToR2;
/**
* Upload multiple files with optional concurrency
* Upload multiple files with bounded concurrency.
*

@@ -111,2 +158,14 @@ * - `concurrency=1` preserves strict sequential ordering.

* after the first error.
*
* The returned array preserves the original input order.
*
* @example
* ```typescript
* const results = await stow.uploadFiles(input.files, {
* concurrency: 4,
* onFileComplete: (result, index) => {
* console.log("uploaded", index, result.key);
* },
* });
* ```
*/

@@ -113,0 +172,0 @@ uploadFiles(files: FileList | File[], options?: UploadOptions & {

@@ -0,1 +1,8 @@

/** Error thrown when an SDK request fails. */
declare class StowError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status: number, code?: string);
}
/**

@@ -28,9 +35,13 @@ * Stow Client SDK

*/
/** Error thrown when an SDK request fails. */
declare class StowError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status: number, code?: string);
}
/** Configuration for creating a {@link StowClient} instance. */
/**
* Configuration for creating a {@link StowClient} instance.
*
* `endpoint` must point at your own application endpoint, not Stow directly.
* That endpoint is expected to wrap `@howells/stow-server` and expose:
* - `POST {endpoint}/presign`
* - `POST {endpoint}/confirm`
*
* The browser package never handles API keys.
*/
interface StowClientConfig {

@@ -43,3 +54,8 @@ /**

}
/** Result returned after a successful upload or dedupe short-circuit. */
/**
* Result returned after a successful upload or dedupe short-circuit.
*
* When `deduped` is `true`, no bytes were uploaded because the server matched
* the file to an existing object using content-hash deduplication.
*/
interface UploadResult {

@@ -53,3 +69,9 @@ contentType: string;

}
/** Progress payload emitted during `uploadFile`. */
/**
* Progress payload emitted during `uploadFile()` and `uploadFiles()`.
*
* `phase` is useful for UI state because uploads have three distinct stages:
* presigning on your app server, uploading to object storage, and confirming
* the completed upload back through your app.
*/
interface UploadProgress {

@@ -65,12 +87,36 @@ /** Bytes uploaded so far */

}
/** Optional behavior for a single upload operation. */
/**
* Optional behavior for a single upload operation.
*
* This object is reused by `uploadFile()` and `uploadFiles()`.
*/
interface UploadOptions {
/** Custom metadata to attach to the file */
/** Custom metadata to persist on the Stow file record during confirm. */
metadata?: Record<string, string>;
/** Progress callback */
/** Progress callback for presign, upload, and confirm phases. */
onProgress?: (progress: UploadProgress) => void;
/** Optional upload route (server-side configured route/path policy). */
/** Optional route or folder hint forwarded to your server's presign endpoint. */
route?: string;
}
/** Browser SDK for direct-to-R2 uploads via your server's presign/confirm endpoints. */
/**
* Browser SDK for direct-to-storage uploads via your app's presign/confirm endpoints.
*
* This package is intentionally narrow: it only handles the browser side of the
* upload handshake. Pair it with `@howells/stow-next` if you want ready-made
* Next.js route handlers, or with `@howells/stow-server` if you are building
* your own backend endpoints.
*
* @example
* ```typescript
* const stow = new StowClient("/api/stow");
*
* const result = await stow.uploadFile(file, {
* route: "products/featured",
* metadata: { sku: "SKU-123" },
* onProgress: ({ phase, percent }) => {
* console.log(phase, percent);
* },
* });
* ```
*/
declare class StowClient {

@@ -84,2 +130,4 @@ private readonly endpoint;

* - `POST {endpoint}/confirm` (or a `confirmUrl` returned from presign)
*
* Both `"/api/stow"` and `{ endpoint: "/api/stow" }` are accepted.
*/

@@ -98,10 +146,9 @@ constructor(config: StowClientConfig | string);

* confirm entirely, returning the existing file metadata with `deduped: true`.
*
* This method rejects with {@link StowError} for presign, network, R2, and
* confirm failures.
*/
uploadFile(file: File, options?: UploadOptions): Promise<UploadResult>;
/**
* Upload file directly to R2 using presigned URL with progress tracking
*/
private uploadToR2;
/**
* Upload multiple files with optional concurrency
* Upload multiple files with bounded concurrency.
*

@@ -111,2 +158,14 @@ * - `concurrency=1` preserves strict sequential ordering.

* after the first error.
*
* The returned array preserves the original input order.
*
* @example
* ```typescript
* const results = await stow.uploadFiles(input.files, {
* concurrency: 4,
* onFileComplete: (result, index) => {
* console.log("uploaded", index, result.key);
* },
* });
* ```
*/

@@ -113,0 +172,0 @@ uploadFiles(files: FileList | File[], options?: UploadOptions & {

+82
-79

@@ -27,2 +27,4 @@ "use strict";

module.exports = __toCommonJS(index_exports);
// src/stow-error.ts
var StowError = class extends Error {

@@ -38,2 +40,30 @@ status;

};
// src/index.ts
function uploadToR2(uploadUrl, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && onProgress) {
onProgress(event.loaded);
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(void 0);
return;
}
reject(new StowError(`R2 upload failed with status ${xhr.status}`, xhr.status));
});
xhr.addEventListener("error", () => {
reject(new StowError("Network error during R2 upload", 0));
});
xhr.addEventListener("abort", () => {
reject(new StowError("R2 upload aborted", 0, "ABORTED"));
});
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");
xhr.send(file);
});
}
var StowClient = class {

@@ -47,9 +77,7 @@ endpoint;

* - `POST {endpoint}/confirm` (or a `confirmUrl` returned from presign)
*
* Both `"/api/stow"` and `{ endpoint: "/api/stow" }` are accepted.
*/
constructor(config) {
if (typeof config === "string") {
this.endpoint = config.replace(/\/+$/, "");
} else {
this.endpoint = config.endpoint.replace(/\/+$/, "");
}
this.endpoint = typeof config === "string" ? config.replace(/\/+$/, "") : config.endpoint.replace(/\/+$/, "");
}

@@ -80,2 +108,5 @@ endpointUrl(path) {

* confirm entirely, returning the existing file metadata with `deduped: true`.
*
* This method rejects with {@link StowError} for presign, network, R2, and
* confirm failures.
*/

@@ -103,7 +134,3 @@ async uploadFile(file, options) {

const error = await presignResponse.json().catch(() => ({ error: "Presign failed" }));
throw new StowError(
error.error || "Presign failed",
presignResponse.status,
error.code
);
throw new StowError(error.error || "Presign failed", presignResponse.status, error.code);
}

@@ -132,3 +159,3 @@ const presign = await presignResponse.json();

});
await this.uploadToR2(presign.uploadUrl, file, (loaded) => {
await uploadToR2(presign.uploadUrl, file, (loaded) => {
onProgress?.({

@@ -147,22 +174,15 @@ loaded,

});
const confirmResponse = await fetch(
this.resolveConfirmUrl(presign.confirmUrl),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileKey: presign.fileKey,
size: file.size,
contentType: file.type || "application/octet-stream",
...metadata ? { metadata } : {}
})
}
);
const confirmResponse = await fetch(this.resolveConfirmUrl(presign.confirmUrl), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileKey: presign.fileKey,
size: file.size,
contentType: file.type || "application/octet-stream",
...metadata ? { metadata } : {}
})
});
if (!confirmResponse.ok) {
const error = await confirmResponse.json().catch(() => ({ error: "Confirm failed" }));
throw new StowError(
error.error || "Confirm failed",
confirmResponse.status,
error.code
);
throw new StowError(error.error || "Confirm failed", confirmResponse.status, error.code);
}

@@ -178,40 +198,3 @@ const result = await confirmResponse.json();

/**
* Upload file directly to R2 using presigned URL with progress tracking
*/
uploadToR2(uploadUrl, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && onProgress) {
onProgress(event.loaded);
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(
new StowError(
`R2 upload failed with status ${xhr.status}`,
xhr.status
)
);
}
});
xhr.addEventListener("error", () => {
reject(new StowError("Network error during R2 upload", 0));
});
xhr.addEventListener("abort", () => {
reject(new StowError("R2 upload aborted", 0, "ABORTED"));
});
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader(
"Content-Type",
file.type || "application/octet-stream"
);
xhr.send(file);
});
}
/**
* Upload multiple files with optional concurrency
* Upload multiple files with bounded concurrency.
*

@@ -221,2 +204,14 @@ * - `concurrency=1` preserves strict sequential ordering.

* after the first error.
*
* The returned array preserves the original input order.
*
* @example
* ```typescript
* const results = await stow.uploadFiles(input.files, {
* concurrency: 4,
* onFileComplete: (result, index) => {
* console.log("uploaded", index, result.key);
* },
* });
* ```
*/

@@ -228,5 +223,9 @@ async uploadFiles(files, options) {

const results2 = [];
for (let i = 0; i < fileArray.length; i++) {
for (let i = 0; i < fileArray.length; i += 1) {
const file = fileArray[i];
if (!file) {
continue;
}
try {
const result = await this.uploadFile(fileArray[i], options);
const result = await this.uploadFile(file, options);
results2.push(result);

@@ -237,3 +236,3 @@ options?.onFileComplete?.(result, i);

error instanceof Error ? error : new Error("Upload failed"),
fileArray[i],
file,
i

@@ -246,3 +245,5 @@ );

}
const results = new Array(fileArray.length);
const results = Array.from({
length: fileArray.length
});
let nextIndex = 0;

@@ -252,5 +253,10 @@ let firstError = null;

while (nextIndex < fileArray.length && !firstError) {
const i = nextIndex++;
const i = nextIndex;
nextIndex += 1;
const file = fileArray[i];
if (!file) {
continue;
}
try {
const result = await this.uploadFile(fileArray[i], options);
const result = await this.uploadFile(file, options);
results[i] = result;

@@ -260,5 +266,5 @@ options?.onFileComplete?.(result, i);

const err = error instanceof Error ? error : new Error("Upload failed");
options?.onFileError?.(err, fileArray[i], i);
options?.onFileError?.(err, file, i);
if (!firstError) {
firstError = { error: err, file: fileArray[i], index: i };
firstError = { error: err, file, index: i };
}

@@ -268,6 +274,3 @@ }

};
const workers = Array.from(
{ length: Math.min(concurrency, fileArray.length) },
() => worker()
);
const workers = Array.from({ length: Math.min(concurrency, fileArray.length) }, () => worker());
await Promise.all(workers);

@@ -274,0 +277,0 @@ if (firstError !== null) {

@@ -1,2 +0,2 @@

// src/index.ts
// src/stow-error.ts
var StowError = class extends Error {

@@ -12,2 +12,30 @@ status;

};
// src/index.ts
function uploadToR2(uploadUrl, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && onProgress) {
onProgress(event.loaded);
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(void 0);
return;
}
reject(new StowError(`R2 upload failed with status ${xhr.status}`, xhr.status));
});
xhr.addEventListener("error", () => {
reject(new StowError("Network error during R2 upload", 0));
});
xhr.addEventListener("abort", () => {
reject(new StowError("R2 upload aborted", 0, "ABORTED"));
});
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");
xhr.send(file);
});
}
var StowClient = class {

@@ -21,9 +49,7 @@ endpoint;

* - `POST {endpoint}/confirm` (or a `confirmUrl` returned from presign)
*
* Both `"/api/stow"` and `{ endpoint: "/api/stow" }` are accepted.
*/
constructor(config) {
if (typeof config === "string") {
this.endpoint = config.replace(/\/+$/, "");
} else {
this.endpoint = config.endpoint.replace(/\/+$/, "");
}
this.endpoint = typeof config === "string" ? config.replace(/\/+$/, "") : config.endpoint.replace(/\/+$/, "");
}

@@ -54,2 +80,5 @@ endpointUrl(path) {

* confirm entirely, returning the existing file metadata with `deduped: true`.
*
* This method rejects with {@link StowError} for presign, network, R2, and
* confirm failures.
*/

@@ -77,7 +106,3 @@ async uploadFile(file, options) {

const error = await presignResponse.json().catch(() => ({ error: "Presign failed" }));
throw new StowError(
error.error || "Presign failed",
presignResponse.status,
error.code
);
throw new StowError(error.error || "Presign failed", presignResponse.status, error.code);
}

@@ -106,3 +131,3 @@ const presign = await presignResponse.json();

});
await this.uploadToR2(presign.uploadUrl, file, (loaded) => {
await uploadToR2(presign.uploadUrl, file, (loaded) => {
onProgress?.({

@@ -121,22 +146,15 @@ loaded,

});
const confirmResponse = await fetch(
this.resolveConfirmUrl(presign.confirmUrl),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileKey: presign.fileKey,
size: file.size,
contentType: file.type || "application/octet-stream",
...metadata ? { metadata } : {}
})
}
);
const confirmResponse = await fetch(this.resolveConfirmUrl(presign.confirmUrl), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileKey: presign.fileKey,
size: file.size,
contentType: file.type || "application/octet-stream",
...metadata ? { metadata } : {}
})
});
if (!confirmResponse.ok) {
const error = await confirmResponse.json().catch(() => ({ error: "Confirm failed" }));
throw new StowError(
error.error || "Confirm failed",
confirmResponse.status,
error.code
);
throw new StowError(error.error || "Confirm failed", confirmResponse.status, error.code);
}

@@ -152,40 +170,3 @@ const result = await confirmResponse.json();

/**
* Upload file directly to R2 using presigned URL with progress tracking
*/
uploadToR2(uploadUrl, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && onProgress) {
onProgress(event.loaded);
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(
new StowError(
`R2 upload failed with status ${xhr.status}`,
xhr.status
)
);
}
});
xhr.addEventListener("error", () => {
reject(new StowError("Network error during R2 upload", 0));
});
xhr.addEventListener("abort", () => {
reject(new StowError("R2 upload aborted", 0, "ABORTED"));
});
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader(
"Content-Type",
file.type || "application/octet-stream"
);
xhr.send(file);
});
}
/**
* Upload multiple files with optional concurrency
* Upload multiple files with bounded concurrency.
*

@@ -195,2 +176,14 @@ * - `concurrency=1` preserves strict sequential ordering.

* after the first error.
*
* The returned array preserves the original input order.
*
* @example
* ```typescript
* const results = await stow.uploadFiles(input.files, {
* concurrency: 4,
* onFileComplete: (result, index) => {
* console.log("uploaded", index, result.key);
* },
* });
* ```
*/

@@ -202,5 +195,9 @@ async uploadFiles(files, options) {

const results2 = [];
for (let i = 0; i < fileArray.length; i++) {
for (let i = 0; i < fileArray.length; i += 1) {
const file = fileArray[i];
if (!file) {
continue;
}
try {
const result = await this.uploadFile(fileArray[i], options);
const result = await this.uploadFile(file, options);
results2.push(result);

@@ -211,3 +208,3 @@ options?.onFileComplete?.(result, i);

error instanceof Error ? error : new Error("Upload failed"),
fileArray[i],
file,
i

@@ -220,3 +217,5 @@ );

}
const results = new Array(fileArray.length);
const results = Array.from({
length: fileArray.length
});
let nextIndex = 0;

@@ -226,5 +225,10 @@ let firstError = null;

while (nextIndex < fileArray.length && !firstError) {
const i = nextIndex++;
const i = nextIndex;
nextIndex += 1;
const file = fileArray[i];
if (!file) {
continue;
}
try {
const result = await this.uploadFile(fileArray[i], options);
const result = await this.uploadFile(file, options);
results[i] = result;

@@ -234,5 +238,5 @@ options?.onFileComplete?.(result, i);

const err = error instanceof Error ? error : new Error("Upload failed");
options?.onFileError?.(err, fileArray[i], i);
options?.onFileError?.(err, file, i);
if (!firstError) {
firstError = { error: err, file: fileArray[i], index: i };
firstError = { error: err, file, index: i };
}

@@ -242,6 +246,3 @@ }

};
const workers = Array.from(
{ length: Math.min(concurrency, fileArray.length) },
() => worker()
);
const workers = Array.from({ length: Math.min(concurrency, fileArray.length) }, () => worker());
await Promise.all(workers);

@@ -248,0 +249,0 @@ if (firstError !== null) {

{
"name": "@howells/stow-client",
"version": "2.2.0",
"version": "2.4.0",
"description": "Client-side SDK for Stow file storage",
"keywords": [
"file-storage",
"presigned-url",
"sdk",
"stow",
"upload"
],
"homepage": "https://stow.sh",
"license": "MIT",

@@ -11,9 +19,4 @@ "repository": {

},
"homepage": "https://stow.sh",
"keywords": [
"stow",
"file-storage",
"upload",
"presigned-url",
"sdk"
"files": [
"dist"
],

@@ -30,5 +33,2 @@ "main": "./dist/index.js",

},
"files": [
"dist"
],
"devDependencies": {

@@ -38,3 +38,3 @@ "jsdom": "^29.0.0",

"typescript": "^5.9.3",
"vitest": "^4.1.0",
"vite-plus": "latest",
"@stow/typescript-config": "0.0.0"

@@ -45,5 +45,5 @@ },

"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
"test": "vitest run",
"test:watch": "vitest"
"test": "vp test run",
"test:watch": "vp test"
}
}