rdash-sdk 0.0.6
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 +119 -0
- package/chunk-uploader/index.d.ts +29 -0
- package/chunk-uploader/index.js +280 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# rdash-sdk
|
|
2
|
+
|
|
3
|
+
Official JavaScript SDK for rdash — chunked uploads, and more.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **ChunkUpload** — parallel chunked file uploads with retries and progress tracking
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install rdash-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
### Browser
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { ChunkedUploader } from "rdash-sdk";
|
|
21
|
+
|
|
22
|
+
const uploader = new ChunkedUploader({
|
|
23
|
+
backendBaseUrl: "https://api.rdash.io/v1/core",
|
|
24
|
+
authToken: "your_token",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const url = await uploader.upload(
|
|
28
|
+
file, // File or Blob
|
|
29
|
+
{ path: "invoice-file" },
|
|
30
|
+
(progress) => console.log(`${progress}%`)
|
|
31
|
+
);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Node.js
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { ChunkedUploader } from "rdash-sdk";
|
|
38
|
+
import { readFile } from "node:fs/promises";
|
|
39
|
+
|
|
40
|
+
const uploader = new ChunkedUploader({
|
|
41
|
+
backendBaseUrl: "https://api.rdash.io/v1/core",
|
|
42
|
+
authToken: "your_token",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const buffer = await readFile("./report.pdf");
|
|
46
|
+
const blob = new Blob([buffer], { type: "application/pdf" });
|
|
47
|
+
|
|
48
|
+
const url = await uploader.upload(blob, { path: "reports" });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Subpath Import
|
|
52
|
+
|
|
53
|
+
You can also import features directly:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import { ChunkedUploader } from "rdash-sdk/ChunkUpload";
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Config Options
|
|
60
|
+
|
|
61
|
+
| Option | Type | Default | Description |
|
|
62
|
+
|--------|------|---------|-------------|
|
|
63
|
+
| `backendBaseUrl` | `string` | *required* | Base URL for the upload API |
|
|
64
|
+
| `multipleUpload` | `number` | `3` | Parallel chunk uploads (1-6) |
|
|
65
|
+
| `authToken` | `string \| null` | `null` | Bearer token for authentication |
|
|
66
|
+
| `keepAlive` | `boolean` | `true` | Use HTTP keep-alive |
|
|
67
|
+
| `chunkTimeout` | `number` | `30000` | Timeout per chunk in milliseconds |
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
### `upload(fileOrUrl, options, onProgress?)`
|
|
72
|
+
|
|
73
|
+
Main method. Accepts a `File`, `Blob`, or URL string. Downloads URL inputs automatically, splits the file into chunks, uploads in parallel, and returns the final upload path.
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
const path = await uploader.upload(file, { path: "documents" }, (pct) => {});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `startUpload(file, options)`
|
|
80
|
+
|
|
81
|
+
Initiates a chunked upload session with the backend. Returns upload metadata including part URLs.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
const uploadInfo = await uploader.startUpload(file, { path: "documents" });
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### `uploadChunks(file, uploadInfo, onProgress?)`
|
|
88
|
+
|
|
89
|
+
Uploads all chunks in parallel with automatic retries (up to 3 attempts per chunk with exponential backoff). Returns an array of part ETags.
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
const etags = await uploader.uploadChunks(file, uploadInfo, (pct) => {});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### `completeUpload(upload_id, object_key, etags)`
|
|
96
|
+
|
|
97
|
+
Finalizes the multipart upload after all chunks are uploaded.
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
const result = await uploader.completeUpload(uploadId, objectKey, etags);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Progress Tracking
|
|
104
|
+
|
|
105
|
+
The `onProgress` callback receives a number from 0-100 representing the upload percentage. Updates are throttled to ~20 reports per upload to avoid excessive callbacks.
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
await uploader.upload(file, { path: "data" }, (progress) => {
|
|
109
|
+
progressBar.style.width = `${progress}%`;
|
|
110
|
+
progressLabel.textContent = `${progress}%`;
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Node.js Compatibility
|
|
115
|
+
|
|
116
|
+
- **Node 18+**: Supported via `Blob`. Pass a `Blob` constructed from a `Buffer`.
|
|
117
|
+
- **Node 20+**: Full support including `File` objects and URL-to-file downloads.
|
|
118
|
+
|
|
119
|
+
The `fetch` API is available globally in Node 18+, which this library relies on. No polyfills are needed.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ChunkedUploaderConfig {
|
|
2
|
+
/** Base URL for the upload API */
|
|
3
|
+
backendBaseUrl: string;
|
|
4
|
+
/** Number of parallel chunks to upload (max 6, default 3) */
|
|
5
|
+
multipleUpload?: number;
|
|
6
|
+
/** Authentication token */
|
|
7
|
+
authToken?: string | null;
|
|
8
|
+
/** Use HTTP keep-alive (default true) */
|
|
9
|
+
keepAlive?: boolean;
|
|
10
|
+
/** Timeout per chunk in milliseconds (default 30000) */
|
|
11
|
+
chunkTimeout?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UploadOptions {
|
|
15
|
+
/** Upload path */
|
|
16
|
+
path: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ProgressCallback = (progress: number) => void;
|
|
20
|
+
|
|
21
|
+
export declare class ChunkedUploader {
|
|
22
|
+
constructor(config: ChunkedUploaderConfig);
|
|
23
|
+
|
|
24
|
+
upload(
|
|
25
|
+
fileOrUrl: File | Blob | string,
|
|
26
|
+
options: UploadOptions,
|
|
27
|
+
onProgress?: ProgressCallback
|
|
28
|
+
): Promise<string>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChunkedUploader.js
|
|
3
|
+
* Usage:
|
|
4
|
+
* const uploader = new ChunkedUploader({ backendBaseUrl: "https://api.rdash.io/v1/core", authToken: "token_here" });
|
|
5
|
+
* uploader.upload(file, { path: "invoice-file" }, progress => console.log(progress)).then(url => ...)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} ChunkedUploaderConfig
|
|
10
|
+
* @property {string} backendBaseUrl - Base URL for the upload API
|
|
11
|
+
* @property {number} [multipleUpload=3] - Number of parallel chunks to upload (max 6)
|
|
12
|
+
* @property {string|null} [authToken=null] - Authentication token
|
|
13
|
+
* @property {boolean} [keepAlive=true] - Use HTTP keep-alive
|
|
14
|
+
* @property {number} [chunkTimeout=30000] - Timeout per chunk in milliseconds
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} UploadOptions
|
|
19
|
+
* @property {string} path - Upload path
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {function(number): void} ProgressCallback
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} ChunkInfo
|
|
28
|
+
* @property {number} partNumber
|
|
29
|
+
* @property {string} eTag
|
|
30
|
+
* @property {string} status
|
|
31
|
+
* @property {string} url
|
|
32
|
+
* @property {number} retry
|
|
33
|
+
* @property {number} size
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export class ChunkedUploader {
|
|
37
|
+
constructor({ backendBaseUrl, multipleUpload = 3, authToken = null, keepAlive = true, chunkTimeout = 30000 }) {
|
|
38
|
+
this.backendBaseUrl = backendBaseUrl.replace(/\/+$/, ""); // remove trailing slash
|
|
39
|
+
this.multipleUpload = Math.min(Math.max(multipleUpload, 1), 6);
|
|
40
|
+
this.authToken = authToken;
|
|
41
|
+
this.chunkTimeout = chunkTimeout;
|
|
42
|
+
|
|
43
|
+
this._cachedHeaders = this._buildHeaders();
|
|
44
|
+
|
|
45
|
+
this.fetchOptions = {
|
|
46
|
+
keepalive: keepAlive,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_buildHeaders() {
|
|
51
|
+
const headers = { "Content-Type": "application/json" };
|
|
52
|
+
if (this.authToken) {
|
|
53
|
+
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
54
|
+
}
|
|
55
|
+
return headers;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get _headers() {
|
|
59
|
+
return this._cachedHeaders;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async _downloadUrlToFile(url) {
|
|
63
|
+
const filename = url.split("/").pop()?.split("?")[0] || "downloaded_file";
|
|
64
|
+
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timeoutId = setTimeout(() => controller.abort(), this.chunkTimeout);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const resp = await fetch(url, {
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
...this.fetchOptions,
|
|
72
|
+
});
|
|
73
|
+
clearTimeout(timeoutId);
|
|
74
|
+
|
|
75
|
+
if (!resp.ok) throw new Error(`Failed to download file from URL: ${resp.status}`);
|
|
76
|
+
|
|
77
|
+
const blob = await resp.blob();
|
|
78
|
+
return new File([blob], filename, { type: blob.type });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
clearTimeout(timeoutId);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async startUpload(file, { path }) {
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const timeoutId = setTimeout(() => controller.abort(), this.chunkTimeout);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const headers = {
|
|
91
|
+
...this.fetchOptions.headers,
|
|
92
|
+
...this._headers,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const resp = await fetch(`${this.backendBaseUrl}/file-upload/initiate`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers,
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
file_name: file.name || "upload",
|
|
100
|
+
file_size: file.size,
|
|
101
|
+
path,
|
|
102
|
+
}),
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
keepalive: this.fetchOptions.keepalive,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
clearTimeout(timeoutId);
|
|
108
|
+
|
|
109
|
+
if (!resp.ok) throw new Error(`Failed to initiate upload: ${resp.status}`);
|
|
110
|
+
return await resp.json();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
clearTimeout(timeoutId);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async uploadChunks(file, uploadInfo, onProgress) {
|
|
118
|
+
const parts = uploadInfo.data ? uploadInfo.data.parts : uploadInfo.parts;
|
|
119
|
+
const totalChunks = parts.length;
|
|
120
|
+
const fileSize = file.size;
|
|
121
|
+
const chunkSize = Math.ceil(fileSize / totalChunks);
|
|
122
|
+
|
|
123
|
+
const chunkProgress = new Map();
|
|
124
|
+
const isLargeFile = fileSize > 100 * 1024 * 1024;
|
|
125
|
+
const effectiveParallelism = isLargeFile ? Math.min(this.multipleUpload, 2) : this.multipleUpload;
|
|
126
|
+
|
|
127
|
+
/** @type {ChunkInfo[]} */
|
|
128
|
+
const chunkInfo = parts.map((part, i) => ({
|
|
129
|
+
partNumber: i,
|
|
130
|
+
eTag: "",
|
|
131
|
+
status: "not-started",
|
|
132
|
+
url: part.part_url || part.partUrl,
|
|
133
|
+
retry: 0,
|
|
134
|
+
size: 0,
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
let completed = 0;
|
|
138
|
+
let progressUpdateCount = 0;
|
|
139
|
+
const maxRetries = 3;
|
|
140
|
+
|
|
141
|
+
const uploadChunk = async (chunkNumber) => {
|
|
142
|
+
const chunk = chunkInfo[chunkNumber];
|
|
143
|
+
const start = chunkNumber * chunkSize;
|
|
144
|
+
const end = Math.min(fileSize, (chunkNumber + 1) * chunkSize);
|
|
145
|
+
|
|
146
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
const timeoutId = setTimeout(() => controller.abort(), this.chunkTimeout);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
chunk.status = "in-progress";
|
|
152
|
+
chunk.retry = attempt;
|
|
153
|
+
const payload = file.slice(start, end);
|
|
154
|
+
chunk.size = payload.size;
|
|
155
|
+
|
|
156
|
+
const res = await fetch(chunk.url, {
|
|
157
|
+
method: "PUT",
|
|
158
|
+
body: payload,
|
|
159
|
+
signal: controller.signal,
|
|
160
|
+
keepalive: this.fetchOptions.keepalive,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
clearTimeout(timeoutId);
|
|
164
|
+
|
|
165
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: Failed chunk ${chunkNumber + 1}`);
|
|
166
|
+
|
|
167
|
+
chunk.status = "completed";
|
|
168
|
+
chunk.eTag = (res.headers.get("etag") || "").replace(/"/g, "");
|
|
169
|
+
|
|
170
|
+
chunkProgress.set(chunkNumber, payload.size);
|
|
171
|
+
completed++;
|
|
172
|
+
|
|
173
|
+
const shouldUpdateProgress = onProgress && (
|
|
174
|
+
completed === totalChunks ||
|
|
175
|
+
++progressUpdateCount % Math.max(1, Math.ceil(totalChunks / 20)) === 0
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (shouldUpdateProgress) {
|
|
179
|
+
const loaded = Array.from(chunkProgress.values()).reduce((a, b) => a + b, 0);
|
|
180
|
+
onProgress(Math.round((loaded * 100) / fileSize));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
part_number: chunkNumber + 1,
|
|
185
|
+
part_e_tag: chunk.eTag,
|
|
186
|
+
};
|
|
187
|
+
} catch (err) {
|
|
188
|
+
clearTimeout(timeoutId);
|
|
189
|
+
chunk.status = attempt < maxRetries - 1 ? "retrying" : "failed";
|
|
190
|
+
|
|
191
|
+
if (attempt === maxRetries - 1) {
|
|
192
|
+
throw new Error(`Chunk ${chunkNumber + 1} failed after ${maxRetries} attempts: ${err.message}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const results = new Array(totalChunks);
|
|
202
|
+
const workers = [];
|
|
203
|
+
let nextChunkIndex = 0;
|
|
204
|
+
|
|
205
|
+
for (let i = 0; i < Math.min(effectiveParallelism, totalChunks); i++) {
|
|
206
|
+
workers.push(
|
|
207
|
+
(async () => {
|
|
208
|
+
while (nextChunkIndex < totalChunks) {
|
|
209
|
+
const chunkIndex = nextChunkIndex++;
|
|
210
|
+
results[chunkIndex] = await uploadChunk(chunkIndex);
|
|
211
|
+
}
|
|
212
|
+
})()
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await Promise.all(workers);
|
|
217
|
+
|
|
218
|
+
const failedChunks = chunkInfo.filter((chunk) => chunk.status !== "completed");
|
|
219
|
+
if (failedChunks.length > 0) {
|
|
220
|
+
throw new Error(`${failedChunks.length} chunks failed to upload`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return results;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async completeUpload(upload_id, object_key, etags) {
|
|
227
|
+
const controller = new AbortController();
|
|
228
|
+
const timeoutId = setTimeout(() => controller.abort(), this.chunkTimeout);
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const headers = {
|
|
232
|
+
...this.fetchOptions.headers,
|
|
233
|
+
...this._headers,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const res = await fetch(`${this.backendBaseUrl}/file-upload/complete`, {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers,
|
|
239
|
+
body: JSON.stringify({ upload_id, object_key, parts: etags }),
|
|
240
|
+
signal: controller.signal,
|
|
241
|
+
keepalive: this.fetchOptions.keepalive,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
clearTimeout(timeoutId);
|
|
245
|
+
|
|
246
|
+
if (!res.ok) throw new Error(`Failed to complete upload: ${res.status}`);
|
|
247
|
+
return await res.json();
|
|
248
|
+
} catch (error) {
|
|
249
|
+
clearTimeout(timeoutId);
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async upload(fileOrUrl, { path }, onProgress) {
|
|
255
|
+
let fileObj = fileOrUrl;
|
|
256
|
+
|
|
257
|
+
if (typeof fileOrUrl === "string" && fileOrUrl.startsWith("http")) {
|
|
258
|
+
fileObj = await this._downloadUrlToFile(fileOrUrl);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!fileObj || typeof fileObj.size !== "number") {
|
|
262
|
+
throw new Error("Invalid file object provided");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const uploadInfo = await this.startUpload(fileObj, { path });
|
|
267
|
+
const etags = await this.uploadChunks(fileObj, uploadInfo, onProgress);
|
|
268
|
+
|
|
269
|
+
const finalResult = await this.completeUpload(
|
|
270
|
+
uploadInfo.data ? uploadInfo.data.upload_id : uploadInfo.upload_id,
|
|
271
|
+
uploadInfo.data ? uploadInfo.data.object_key : uploadInfo.object_key,
|
|
272
|
+
etags
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return finalResult.data ? finalResult.data.path : finalResult.path;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
throw new Error(`Upload failed: ${error.message}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./chunk-uploader/index.d.ts";
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ChunkedUploader } from "./chunk-uploader/index.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rdash-sdk",
|
|
3
|
+
"version": "0.0.6",
|
|
4
|
+
"description": "Official JavaScript SDK for rdash — chunked uploads, and more",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"types": "./index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"import": "./index.js"
|
|
12
|
+
},
|
|
13
|
+
"./ChunkUpload": {
|
|
14
|
+
"types": "./chunk-uploader/index.d.ts",
|
|
15
|
+
"import": "./chunk-uploader/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.js",
|
|
20
|
+
"index.d.ts",
|
|
21
|
+
"chunk-uploader/**"
|
|
22
|
+
],
|
|
23
|
+
"keywords": ["rdash", "sdk", "upload", "chunk-uploader"],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"scripts": {},
|
|
26
|
+
"engines": { "node": ">=18.0.0" },
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/rdash-tech/rdash-sdk.git"
|
|
30
|
+
}
|
|
31
|
+
}
|