uploader-sdk 1.0.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.
@@ -0,0 +1,344 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { addFiles as sdkAddFiles, cancelFileUpload, clearFiles as sdkClearFiles, createUploadClient, listFiles as sdkListFiles, pauseFileUpload, registerStorageAdapter, resumeFileUpload, setFileExtensionRules, uploadFiles as sdkUploadFiles, uploadSelectedFiles as sdkUploadSelectedFiles, } from './index';
3
+ function mergeUploadOptions(defaults, overrides) {
4
+ return {
5
+ parallel: overrides?.parallel ?? defaults?.parallel ?? 3,
6
+ retry: overrides?.retry ?? defaults?.retry ?? 2,
7
+ retryDelayMs: overrides?.retryDelayMs ?? defaults?.retryDelayMs ?? 300,
8
+ };
9
+ }
10
+ function buildStatus(state, progress, totalBytes, error, retryMeta) {
11
+ const bounded = Math.max(0, Math.min(100, Number.isFinite(progress) ? progress : 0));
12
+ const safeTotal = Math.max(0, Number.isFinite(totalBytes) ? totalBytes : 0);
13
+ const uploadedBytes = safeTotal > 0 ? Math.round((bounded / 100) * safeTotal) : 0;
14
+ return {
15
+ state,
16
+ progress: bounded,
17
+ uploadedBytes,
18
+ totalBytes: safeTotal,
19
+ error,
20
+ retryAttempt: retryMeta?.attempt,
21
+ maxRetries: retryMeta?.maxRetries,
22
+ };
23
+ }
24
+ export function useUploader(config) {
25
+ const [files, setFiles] = useState([]);
26
+ const [statusById, setStatusById] = useState({});
27
+ const [logs, setLogs] = useState([]);
28
+ const [isUploading, setIsUploading] = useState(false);
29
+ const [pausedById, setPausedById] = useState({});
30
+ const pausedByIdRef = useRef({});
31
+ const client = useMemo(() => createUploadClient({
32
+ dbName: config.dbName,
33
+ storeName: config.storeName,
34
+ }), [config.dbName, config.storeName]);
35
+ const log = useCallback((message) => {
36
+ setLogs((prev) => [...prev, message]);
37
+ }, []);
38
+ useEffect(() => {
39
+ pausedByIdRef.current = pausedById;
40
+ }, [pausedById]);
41
+ useEffect(() => {
42
+ if (config.adapter) {
43
+ registerStorageAdapter(client, config.adapterName, config.adapter);
44
+ }
45
+ }, [client, config.adapterName, config.adapter]);
46
+ useEffect(() => {
47
+ if (config.rules) {
48
+ setFileExtensionRules(client, {
49
+ allowExtensions: config.rules.allowExtensions,
50
+ });
51
+ }
52
+ }, [client, config.rules]);
53
+ const refresh = useCallback(async () => {
54
+ const stored = (await sdkListFiles(client));
55
+ setFiles(stored);
56
+ setStatusById((prev) => {
57
+ const next = { ...prev };
58
+ for (const f of stored) {
59
+ if (!next[f.id]) {
60
+ next[f.id] = buildStatus('pending', 0, f.size);
61
+ }
62
+ else {
63
+ next[f.id] = {
64
+ ...next[f.id],
65
+ totalBytes: f.size,
66
+ };
67
+ }
68
+ }
69
+ for (const id of Object.keys(next)) {
70
+ if (!stored.find((f) => f.id === id)) {
71
+ delete next[id];
72
+ }
73
+ }
74
+ return next;
75
+ });
76
+ setPausedById((prev) => {
77
+ const next = { ...prev };
78
+ for (const id of Object.keys(next)) {
79
+ if (!stored.find((f) => f.id === id)) {
80
+ delete next[id];
81
+ }
82
+ }
83
+ return next;
84
+ });
85
+ return stored;
86
+ }, [client]);
87
+ useEffect(() => {
88
+ if (config.autoRefreshOnMount === false) {
89
+ return;
90
+ }
91
+ void refresh();
92
+ }, [refresh, config.autoRefreshOnMount]);
93
+ const addFiles = useCallback(async (selected) => {
94
+ const result = await sdkAddFiles(client, selected);
95
+ await refresh();
96
+ return result;
97
+ }, [client, refresh]);
98
+ const clearFiles = useCallback(async () => {
99
+ await sdkClearFiles(client);
100
+ setStatusById({});
101
+ setPausedById({});
102
+ await refresh();
103
+ }, [client, refresh]);
104
+ const setRules = useCallback((rules) => {
105
+ setFileExtensionRules(client, {
106
+ allowExtensions: rules.allowExtensions,
107
+ });
108
+ }, [client]);
109
+ const pause = useCallback((fileId) => {
110
+ pauseFileUpload(client, fileId);
111
+ setPausedById((prev) => ({ ...prev, [fileId]: true }));
112
+ setStatusById((prev) => {
113
+ const current = prev[fileId] ?? buildStatus('pending', 0, 0);
114
+ return {
115
+ ...prev,
116
+ [fileId]: {
117
+ ...current,
118
+ state: 'paused',
119
+ },
120
+ };
121
+ });
122
+ }, [client]);
123
+ const resume = useCallback((fileId) => {
124
+ resumeFileUpload(client, fileId);
125
+ setPausedById((prev) => ({ ...prev, [fileId]: false }));
126
+ setStatusById((prev) => {
127
+ const current = prev[fileId] ?? buildStatus('pending', 0, 0);
128
+ const nextState = current.progress > 0 && current.progress < 100 ? 'uploading' : current.progress >= 100 ? 'uploaded' : 'queued';
129
+ return {
130
+ ...prev,
131
+ [fileId]: {
132
+ ...current,
133
+ state: nextState,
134
+ },
135
+ };
136
+ });
137
+ }, [client]);
138
+ const cancel = useCallback(async (fileId) => {
139
+ await cancelFileUpload(client, fileId);
140
+ setPausedById((prev) => {
141
+ const next = { ...prev };
142
+ delete next[fileId];
143
+ return next;
144
+ });
145
+ setStatusById((prev) => {
146
+ const next = { ...prev };
147
+ delete next[fileId];
148
+ return next;
149
+ });
150
+ await refresh();
151
+ }, [client, refresh]);
152
+ const uploadAll = useCallback(async (options) => {
153
+ const current = (await sdkListFiles(client));
154
+ if (!current.length) {
155
+ return;
156
+ }
157
+ setIsUploading(true);
158
+ const fileById = new Map(current.map((f) => [f.id, f]));
159
+ setStatusById((prev) => {
160
+ const next = { ...prev };
161
+ for (const file of current) {
162
+ const isPaused = pausedByIdRef.current[file.id];
163
+ next[file.id] = buildStatus(isPaused ? 'paused' : 'queued', 0, file.size);
164
+ }
165
+ return next;
166
+ });
167
+ const merged = mergeUploadOptions(config.defaultUploadOptions, options);
168
+ try {
169
+ await sdkUploadFiles(client, config.adapterName, {
170
+ ...merged,
171
+ onProgress: (progress, _fileName, meta) => {
172
+ const id = meta?.fileId;
173
+ if (!id) {
174
+ return;
175
+ }
176
+ const total = fileById.get(id)?.size ?? 0;
177
+ const paused = !!pausedByIdRef.current[id];
178
+ setStatusById((prev) => ({
179
+ ...prev,
180
+ [id]: {
181
+ ...(prev[id] ?? buildStatus('pending', 0, total)),
182
+ ...buildStatus(paused ? 'paused' : progress < 100 ? 'uploading' : 'uploaded', progress, total),
183
+ },
184
+ }));
185
+ },
186
+ onRetry: (error, fileName, meta) => {
187
+ const id = meta?.fileId;
188
+ if (!id) {
189
+ return;
190
+ }
191
+ const total = fileById.get(id)?.size ?? 0;
192
+ setStatusById((prev) => ({
193
+ ...prev,
194
+ [id]: {
195
+ ...(prev[id] ?? buildStatus('pending', 0, total)),
196
+ ...buildStatus('retrying', prev[id]?.progress ?? 0, total, error.message, {
197
+ attempt: meta.attempt,
198
+ maxRetries: meta.maxRetries,
199
+ }),
200
+ },
201
+ }));
202
+ log(`Retrying: ${fileName} (${meta.attempt}/${meta.maxRetries}) - ${error.message}`);
203
+ },
204
+ onError: (error, fileName, meta) => {
205
+ const id = meta?.fileId;
206
+ if (id) {
207
+ const total = fileById.get(id)?.size ?? 0;
208
+ setStatusById((prev) => ({
209
+ ...prev,
210
+ [id]: {
211
+ ...(prev[id] ?? buildStatus('pending', 0, total)),
212
+ ...buildStatus('failed', prev[id]?.progress ?? 0, total, error.message),
213
+ },
214
+ }));
215
+ }
216
+ log(`Failed after retries: ${fileName} (${error.message})`);
217
+ },
218
+ onComplete: (fileName, url, meta) => {
219
+ const id = meta?.fileId;
220
+ if (id) {
221
+ const total = fileById.get(id)?.size ?? 0;
222
+ setStatusById((prev) => ({
223
+ ...prev,
224
+ [id]: {
225
+ ...(prev[id] ?? buildStatus('pending', 0, total)),
226
+ ...buildStatus('uploaded', 100, total),
227
+ },
228
+ }));
229
+ }
230
+ log(`Uploaded: ${fileName} -> ${url}`);
231
+ },
232
+ });
233
+ }
234
+ finally {
235
+ setIsUploading(false);
236
+ await refresh();
237
+ }
238
+ }, [client, config.adapterName, config.defaultUploadOptions, refresh, log]);
239
+ const uploadSelected = useCallback(async (fileIds, options) => {
240
+ if (!fileIds.length) {
241
+ return;
242
+ }
243
+ const current = (await sdkListFiles(client));
244
+ const fileById = new Map(current.map((f) => [f.id, f]));
245
+ const idSet = new Set(fileIds);
246
+ setIsUploading(true);
247
+ setStatusById((prev) => {
248
+ const next = { ...prev };
249
+ for (const id of idSet) {
250
+ const total = fileById.get(id)?.size ?? 0;
251
+ const paused = !!pausedByIdRef.current[id];
252
+ next[id] = buildStatus(paused ? 'paused' : 'queued', 0, total);
253
+ }
254
+ return next;
255
+ });
256
+ const merged = mergeUploadOptions(config.defaultUploadOptions, options);
257
+ try {
258
+ await sdkUploadSelectedFiles(client, config.adapterName, fileIds, {
259
+ ...merged,
260
+ onProgress: (progress, _fileName, meta) => {
261
+ const id = meta?.fileId;
262
+ if (!id) {
263
+ return;
264
+ }
265
+ const total = fileById.get(id)?.size ?? 0;
266
+ const paused = !!pausedByIdRef.current[id];
267
+ setStatusById((prev) => ({
268
+ ...prev,
269
+ [id]: {
270
+ ...(prev[id] ?? buildStatus('pending', 0, total)),
271
+ ...buildStatus(paused ? 'paused' : progress < 100 ? 'uploading' : 'uploaded', progress, total),
272
+ },
273
+ }));
274
+ },
275
+ onRetry: (error, fileName, meta) => {
276
+ const id = meta?.fileId;
277
+ if (!id) {
278
+ return;
279
+ }
280
+ const total = fileById.get(id)?.size ?? 0;
281
+ setStatusById((prev) => ({
282
+ ...prev,
283
+ [id]: {
284
+ ...(prev[id] ?? buildStatus('pending', 0, total)),
285
+ ...buildStatus('retrying', prev[id]?.progress ?? 0, total, error.message, {
286
+ attempt: meta.attempt,
287
+ maxRetries: meta.maxRetries,
288
+ }),
289
+ },
290
+ }));
291
+ log(`Retrying: ${fileName} (${meta.attempt}/${meta.maxRetries}) - ${error.message}`);
292
+ },
293
+ onError: (error, fileName, meta) => {
294
+ const id = meta?.fileId;
295
+ if (id) {
296
+ const total = fileById.get(id)?.size ?? 0;
297
+ setStatusById((prev) => ({
298
+ ...prev,
299
+ [id]: {
300
+ ...(prev[id] ?? buildStatus('pending', 0, total)),
301
+ ...buildStatus('failed', prev[id]?.progress ?? 0, total, error.message),
302
+ },
303
+ }));
304
+ }
305
+ log(`Failed after retries: ${fileName} (${error.message})`);
306
+ },
307
+ onComplete: (fileName, url, meta) => {
308
+ const id = meta?.fileId;
309
+ if (id) {
310
+ const total = fileById.get(id)?.size ?? 0;
311
+ setStatusById((prev) => ({
312
+ ...prev,
313
+ [id]: {
314
+ ...(prev[id] ?? buildStatus('pending', 0, total)),
315
+ ...buildStatus('uploaded', 100, total),
316
+ },
317
+ }));
318
+ }
319
+ log(`Uploaded: ${fileName} -> ${url}`);
320
+ },
321
+ });
322
+ }
323
+ finally {
324
+ setIsUploading(false);
325
+ await refresh();
326
+ }
327
+ }, [client, config.adapterName, config.defaultUploadOptions, refresh, log]);
328
+ return {
329
+ client,
330
+ files,
331
+ statusById,
332
+ logs,
333
+ isUploading,
334
+ refresh,
335
+ addFiles,
336
+ clearFiles,
337
+ uploadAll,
338
+ uploadSelected,
339
+ pause,
340
+ resume,
341
+ cancel,
342
+ setRules,
343
+ };
344
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "uploader-sdk",
3
+ "version": "1.0.0",
4
+ "description": "Browser upload SDK with IndexedDB queue, parallel uploads, retries, pause/resume, and adapter-based storage integration.",
5
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./react": {
16
+ "types": "./dist/react-hooks.d.ts",
17
+ "import": "./dist/react-hooks.js",
18
+ "default": "./dist/react-hooks.js"
19
+ },
20
+ "./adapters/presigned-s3": {
21
+ "types": "./dist/presigned-s3-adapter.d.ts",
22
+ "import": "./dist/presigned-s3-adapter.js",
23
+ "default": "./dist/presigned-s3-adapter.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md"
29
+ ],
30
+ "sideEffects": false,
31
+ "scripts": {
32
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
33
+ "build": "npm run clean && tsc",
34
+ "prepack": "npm run build",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "test:ci": "npm run build && npm run test",
38
+ "test:live": "npm run build && node scripts/live-integration.mjs",
39
+ "release:check": "npm run test:ci && npm pack --dry-run",
40
+ "dev:backend": "node backend/server.js",
41
+ "dev:frontend": "vite --config app/vite.config.js",
42
+ "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\""
43
+ },
44
+ "dependencies": {},
45
+ "peerDependencies": {
46
+ "react": ">=18"
47
+ },
48
+ "devDependencies": {
49
+ "@aws-sdk/client-s3": "^3.888.0",
50
+ "@aws-sdk/s3-request-presigner": "^3.888.0",
51
+ "@types/react": "^19.2.2",
52
+ "@types/react-dom": "^19.2.2",
53
+ "@types/node": "^25.5.0",
54
+ "concurrently": "^9.2.1",
55
+ "cors": "^2.8.5",
56
+ "dotenv": "^17.2.3",
57
+ "express": "^5.1.0",
58
+ "react": "^19.2.0",
59
+ "react-dom": "^19.2.0",
60
+ "fake-indexeddb": "^6.2.2",
61
+ "happy-dom": "^18.0.1",
62
+ "vitest": "^3.2.4",
63
+ "vite": "^7.1.7",
64
+ "typescript": "^5.9.3"
65
+ },
66
+ "keywords": [
67
+ "upload",
68
+ "sdk",
69
+ "indexeddb",
70
+ "multipart",
71
+ "presigned-url",
72
+ "react"
73
+ ]
74
+ }