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 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
+ }