@transloadit/convex 0.0.3 → 0.0.5

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.
Files changed (85) hide show
  1. package/README.md +154 -122
  2. package/dist/client/index.d.ts +54 -13
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +48 -5
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +2 -2
  7. package/dist/component/_generated/component.d.ts +11 -0
  8. package/dist/component/_generated/component.d.ts.map +1 -1
  9. package/dist/component/_generated/dataModel.d.ts +1 -1
  10. package/dist/component/_generated/server.d.ts +1 -1
  11. package/dist/component/apiUtils.d.ts +26 -6
  12. package/dist/component/apiUtils.d.ts.map +1 -1
  13. package/dist/component/apiUtils.js +48 -38
  14. package/dist/component/apiUtils.js.map +1 -1
  15. package/dist/component/lib.d.ts +37 -8
  16. package/dist/component/lib.d.ts.map +1 -1
  17. package/dist/component/lib.js +145 -18
  18. package/dist/component/lib.js.map +1 -1
  19. package/dist/component/schema.d.ts +9 -6
  20. package/dist/component/schema.d.ts.map +1 -1
  21. package/dist/component/schema.js +4 -8
  22. package/dist/component/schema.js.map +1 -1
  23. package/dist/debug/index.d.ts +19 -0
  24. package/dist/debug/index.d.ts.map +1 -0
  25. package/dist/debug/index.js +49 -0
  26. package/dist/debug/index.js.map +1 -0
  27. package/dist/react/index.d.ts +201 -3
  28. package/dist/react/index.d.ts.map +1 -1
  29. package/dist/react/index.js +674 -94
  30. package/dist/react/index.js.map +1 -1
  31. package/dist/shared/assemblyUrls.d.ts +10 -0
  32. package/dist/shared/assemblyUrls.d.ts.map +1 -0
  33. package/dist/shared/assemblyUrls.js +26 -0
  34. package/dist/shared/assemblyUrls.js.map +1 -0
  35. package/dist/shared/errors.d.ts +7 -0
  36. package/dist/shared/errors.d.ts.map +1 -0
  37. package/dist/shared/errors.js +10 -0
  38. package/dist/shared/errors.js.map +1 -0
  39. package/dist/shared/pollAssembly.d.ts +12 -0
  40. package/dist/shared/pollAssembly.d.ts.map +1 -0
  41. package/dist/shared/pollAssembly.js +50 -0
  42. package/dist/shared/pollAssembly.js.map +1 -0
  43. package/dist/shared/resultTypes.d.ts +37 -0
  44. package/dist/shared/resultTypes.d.ts.map +1 -0
  45. package/dist/shared/resultTypes.js +2 -0
  46. package/dist/shared/resultTypes.js.map +1 -0
  47. package/dist/shared/resultUtils.d.ts +4 -0
  48. package/dist/shared/resultUtils.d.ts.map +1 -0
  49. package/dist/shared/resultUtils.js +69 -0
  50. package/dist/shared/resultUtils.js.map +1 -0
  51. package/dist/shared/tusUpload.d.ts +13 -0
  52. package/dist/shared/tusUpload.d.ts.map +1 -0
  53. package/dist/shared/tusUpload.js +32 -0
  54. package/dist/shared/tusUpload.js.map +1 -0
  55. package/dist/test/index.d.ts +9 -4
  56. package/dist/test/index.d.ts.map +1 -1
  57. package/dist/test/nodeModules.d.ts +2 -0
  58. package/dist/test/nodeModules.d.ts.map +1 -0
  59. package/dist/test/nodeModules.js +19 -0
  60. package/dist/test/nodeModules.js.map +1 -0
  61. package/package.json +40 -7
  62. package/src/client/index.ts +111 -9
  63. package/src/component/_generated/api.ts +2 -2
  64. package/src/component/_generated/component.ts +14 -0
  65. package/src/component/_generated/dataModel.ts +1 -1
  66. package/src/component/_generated/server.ts +1 -1
  67. package/src/component/apiUtils.test.ts +166 -2
  68. package/src/component/apiUtils.ts +96 -64
  69. package/src/component/lib.test.ts +213 -4
  70. package/src/component/lib.ts +192 -25
  71. package/src/component/schema.ts +4 -11
  72. package/src/debug/index.ts +84 -0
  73. package/src/react/index.test.tsx +340 -0
  74. package/src/react/index.tsx +1089 -179
  75. package/src/react/uploadWithTus.test.tsx +192 -0
  76. package/src/shared/assemblyUrls.test.ts +71 -0
  77. package/src/shared/assemblyUrls.ts +59 -0
  78. package/src/shared/errors.ts +23 -0
  79. package/src/shared/pollAssembly.ts +65 -0
  80. package/src/shared/resultTypes.ts +44 -0
  81. package/src/shared/resultUtils.test.ts +29 -0
  82. package/src/shared/resultUtils.ts +71 -0
  83. package/src/shared/tusUpload.ts +59 -0
  84. package/src/test/index.ts +1 -1
  85. package/src/test/nodeModules.ts +19 -0
@@ -1,6 +1,191 @@
1
+ import { isAssemblyTerminal, } from "@transloadit/zod/v3/assemblyStatus";
1
2
  import { useAction, useQuery } from "convex/react";
2
- import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
4
  import { Upload } from "tus-js-client";
5
+ import { getAssemblyStage, parseAssemblyStatus, } from "../shared/assemblyUrls.js";
6
+ import { transloaditError } from "../shared/errors.js";
7
+ import { pollAssembly } from "../shared/pollAssembly.js";
8
+ import { buildTusUploadConfig } from "../shared/tusUpload.js";
9
+ export async function uploadWithAssembly(createAssembly, uppy, options) {
10
+ const files = uppy.getFiles();
11
+ if (files.length === 0) {
12
+ throw transloaditError("upload", "No files provided for upload");
13
+ }
14
+ const args = {
15
+ ...(options.createAssemblyArgs ?? {}),
16
+ fileCount: options.fileCount ?? files.length,
17
+ };
18
+ const assembly = await createAssembly(args);
19
+ options.onAssemblyCreated?.(assembly);
20
+ const tusPlugin = uppy.getPlugin("Tus");
21
+ if (!tusPlugin) {
22
+ throw transloaditError("upload", 'Uppy Tus plugin is required. Call uppy.use(Tus, { endpoint: "" }) before uploadWithAssembly.');
23
+ }
24
+ let tusEndpoint = null;
25
+ const addRequestId = options.addRequestId ?? true;
26
+ for (const file of files) {
27
+ if (!file.data ||
28
+ typeof Blob === "undefined" ||
29
+ !(file.data instanceof Blob)) {
30
+ throw transloaditError("upload", "Uppy file is missing binary data for upload");
31
+ }
32
+ const uploadFile = file.data instanceof File
33
+ ? file.data
34
+ : new File([file.data], file.name ?? "file", {
35
+ type: file.data.type || file.type,
36
+ });
37
+ const { endpoint, metadata } = buildTusUploadConfig(assembly.data, uploadFile, {
38
+ fieldName: options.fieldName,
39
+ metadata: options.metadata,
40
+ });
41
+ if (!tusEndpoint) {
42
+ tusEndpoint = endpoint;
43
+ }
44
+ uppy.setFileMeta(file.id, metadata);
45
+ uppy.setFileState(file.id, {
46
+ tus: {
47
+ ...(file.tus ?? {}),
48
+ endpoint,
49
+ addRequestId,
50
+ },
51
+ });
52
+ }
53
+ if (tusPlugin && "setOptions" in tusPlugin && tusEndpoint) {
54
+ tusPlugin.setOptions?.({ endpoint: tusEndpoint, addRequestId });
55
+ }
56
+ const uploadResult = await uppy.upload();
57
+ if (!uploadResult) {
58
+ throw transloaditError("upload", "Uppy upload did not return a result");
59
+ }
60
+ return { assembly, uploadResult };
61
+ }
62
+ /**
63
+ * Low-level tus upload helper. Prefer `useTransloaditUpload` for new code.
64
+ */
65
+ /**
66
+ * Low-level tus upload helper. Prefer `useTransloaditUpload` for new code.
67
+ */
68
+ export async function uploadWithTransloaditTus(createAssembly, file, options, events = {}) {
69
+ let currentState = {
70
+ isUploading: true,
71
+ progress: 0,
72
+ error: null,
73
+ };
74
+ const emitState = (next) => {
75
+ currentState = next;
76
+ events.onStateChange?.(next);
77
+ };
78
+ emitState(currentState);
79
+ try {
80
+ const assembly = await createAssembly({
81
+ templateId: options.templateId,
82
+ steps: options.steps,
83
+ fields: options.fields,
84
+ notifyUrl: options.notifyUrl,
85
+ numExpectedUploadFiles: options.numExpectedUploadFiles ?? 1,
86
+ expires: options.expires,
87
+ additionalParams: options.additionalParams,
88
+ userId: options.userId,
89
+ });
90
+ const data = assembly.data;
91
+ options.onAssemblyCreated?.(assembly);
92
+ const { endpoint, metadata } = buildTusUploadConfig(data, file, {
93
+ fieldName: options.fieldName,
94
+ metadata: options.metadata,
95
+ });
96
+ const getStatus = (error) => error.originalResponse?.getStatus &&
97
+ typeof error.originalResponse.getStatus === "function"
98
+ ? error.originalResponse.getStatus()
99
+ : 0;
100
+ const retryDelays = options.retryDelays
101
+ ? [...options.retryDelays]
102
+ : [1000, 5000, 15000, 30000];
103
+ const rateLimitRetryDelays = options.rateLimitRetryDelays
104
+ ? [...options.rateLimitRetryDelays]
105
+ : [20_000, 40_000, 80_000];
106
+ const shouldRetry = (error) => {
107
+ const status = getStatus(error);
108
+ if (!status)
109
+ return true;
110
+ if (status === 409 || status === 423)
111
+ return true;
112
+ return status < 400 || status >= 500;
113
+ };
114
+ let uploadUrl = null;
115
+ let rateLimitAttempt = 0;
116
+ const runUpload = () => new Promise((resolve, reject) => {
117
+ let uploader;
118
+ const uploadOptions = {
119
+ endpoint,
120
+ metadata,
121
+ retryDelays,
122
+ uploadDataDuringCreation: options.uploadDataDuringCreation ?? false,
123
+ onUploadUrlAvailable: () => {
124
+ uploadUrl = uploader.url;
125
+ },
126
+ onShouldRetry: (error, retryAttempt) => options.onShouldRetry?.(error, retryAttempt) ?? shouldRetry(error),
127
+ onProgress: (bytesUploaded, bytesTotal) => {
128
+ const progress = Math.round((bytesUploaded / bytesTotal) * 100);
129
+ emitState({ isUploading: true, progress, error: null });
130
+ options.onProgress?.(progress);
131
+ },
132
+ onError: (error) => {
133
+ reject(error);
134
+ },
135
+ onSuccess: () => {
136
+ resolve();
137
+ },
138
+ };
139
+ if (options.chunkSize !== undefined) {
140
+ uploadOptions.chunkSize = options.chunkSize;
141
+ }
142
+ if (uploadUrl) {
143
+ uploadOptions.uploadUrl = uploadUrl;
144
+ }
145
+ if (options.overridePatchMethod !== undefined) {
146
+ uploadOptions.overridePatchMethod = options.overridePatchMethod;
147
+ }
148
+ if (options.storeFingerprintForResuming !== undefined) {
149
+ uploadOptions.storeFingerprintForResuming =
150
+ options.storeFingerprintForResuming;
151
+ }
152
+ if (options.removeFingerprintOnSuccess !== undefined) {
153
+ uploadOptions.removeFingerprintOnSuccess =
154
+ options.removeFingerprintOnSuccess;
155
+ }
156
+ uploader = new Upload(file, uploadOptions);
157
+ uploader.start();
158
+ });
159
+ while (true) {
160
+ try {
161
+ await runUpload();
162
+ break;
163
+ }
164
+ catch (error) {
165
+ const status = getStatus(error);
166
+ if (status === 429 && rateLimitAttempt < rateLimitRetryDelays.length) {
167
+ const delay = rateLimitRetryDelays[rateLimitAttempt] ?? 0;
168
+ rateLimitAttempt += 1;
169
+ await new Promise((resolve) => setTimeout(resolve, delay));
170
+ continue;
171
+ }
172
+ throw error;
173
+ }
174
+ }
175
+ emitState({ isUploading: false, progress: 100, error: null });
176
+ return assembly;
177
+ }
178
+ catch (error) {
179
+ const err = error instanceof Error
180
+ ? error
181
+ : transloaditError("upload", "Upload failed");
182
+ emitState({ isUploading: false, progress: 0, error: err });
183
+ throw err;
184
+ }
185
+ }
186
+ /**
187
+ * @deprecated Prefer `useTransloaditUpload` (single + multi-file) for new code.
188
+ */
4
189
  export function useTransloaditTusUpload(createAssembly) {
5
190
  const create = useAction(createAssembly);
6
191
  const [state, setState] = useState({
@@ -8,89 +193,165 @@ export function useTransloaditTusUpload(createAssembly) {
8
193
  progress: 0,
9
194
  error: null,
10
195
  });
11
- const upload = useCallback(async (file, options) => {
12
- setState({ isUploading: true, progress: 0, error: null });
13
- try {
14
- const assembly = await create({
15
- templateId: options.templateId,
16
- steps: options.steps,
17
- fields: options.fields,
18
- notifyUrl: options.notifyUrl,
19
- numExpectedUploadFiles: options.numExpectedUploadFiles ?? 1,
20
- expires: options.expires,
21
- additionalParams: options.additionalParams,
22
- userId: options.userId,
196
+ const upload = useCallback(async (file, options) => uploadWithTransloaditTus(create, file, options, {
197
+ onStateChange: setState,
198
+ }), [create]);
199
+ const reset = useCallback(() => {
200
+ setState({ isUploading: false, progress: 0, error: null });
201
+ }, []);
202
+ return useMemo(() => ({
203
+ upload,
204
+ reset,
205
+ isUploading: state.isUploading,
206
+ progress: state.progress,
207
+ error: state.error,
208
+ }), [state.error, state.isUploading, state.progress, upload, reset]);
209
+ }
210
+ /**
211
+ * Low-level multi-file tus uploader. Prefer `useTransloaditUpload` for new code.
212
+ */
213
+ export function uploadFilesWithTransloaditTus(createAssembly, files, options) {
214
+ const concurrency = Math.max(1, options.concurrency ?? 3);
215
+ const state = {
216
+ isUploading: true,
217
+ progress: 0,
218
+ error: null,
219
+ };
220
+ const results = files.map((file) => ({
221
+ file,
222
+ status: "canceled",
223
+ }));
224
+ const inFlight = new Set();
225
+ const abortController = new AbortController();
226
+ let cancelled = false;
227
+ const emitState = (next) => {
228
+ state.isUploading = next.isUploading;
229
+ state.progress = next.progress;
230
+ state.error = next.error;
231
+ options.onStateChange?.(next);
232
+ };
233
+ const cancel = () => {
234
+ if (cancelled)
235
+ return;
236
+ cancelled = true;
237
+ abortController.abort();
238
+ for (const uploader of inFlight) {
239
+ try {
240
+ uploader.abort(true);
241
+ }
242
+ catch {
243
+ // ignore abort errors
244
+ }
245
+ }
246
+ };
247
+ if (options.signal) {
248
+ if (options.signal.aborted) {
249
+ cancel();
250
+ }
251
+ else {
252
+ options.signal.addEventListener("abort", cancel, { once: true });
253
+ }
254
+ }
255
+ const promise = (async () => {
256
+ if (files.length === 0) {
257
+ throw transloaditError("upload", "No files provided for upload");
258
+ }
259
+ emitState({ ...state });
260
+ const assembly = await createAssembly({
261
+ templateId: options.templateId,
262
+ steps: options.steps,
263
+ fields: options.fields,
264
+ notifyUrl: options.notifyUrl,
265
+ numExpectedUploadFiles: options.numExpectedUploadFiles ?? files.length,
266
+ expires: options.expires,
267
+ additionalParams: options.additionalParams,
268
+ userId: options.userId,
269
+ });
270
+ options.onAssemblyCreated?.(assembly);
271
+ const getStatus = (error) => error.originalResponse?.getStatus &&
272
+ typeof error.originalResponse.getStatus === "function"
273
+ ? error.originalResponse.getStatus()
274
+ : 0;
275
+ const retryDelays = options.retryDelays
276
+ ? [...options.retryDelays]
277
+ : [1000, 5000, 15000, 30000];
278
+ const rateLimitRetryDelays = options.rateLimitRetryDelays
279
+ ? [...options.rateLimitRetryDelays]
280
+ : [20_000, 40_000, 80_000];
281
+ const shouldRetry = (error) => {
282
+ const status = getStatus(error);
283
+ if (!status)
284
+ return true;
285
+ if (status === 409 || status === 423)
286
+ return true;
287
+ return status < 400 || status >= 500;
288
+ };
289
+ const perFileBytes = new Map();
290
+ files.forEach((file, index) => {
291
+ perFileBytes.set(index, { uploaded: 0, total: file.size });
292
+ });
293
+ const updateOverallProgress = () => {
294
+ let totalUploaded = 0;
295
+ let totalBytes = 0;
296
+ for (const { uploaded, total } of perFileBytes.values()) {
297
+ totalUploaded += uploaded;
298
+ totalBytes += total;
299
+ }
300
+ const overall = totalBytes > 0 ? Math.round((totalUploaded / totalBytes) * 100) : 0;
301
+ emitState({ isUploading: true, progress: overall, error: null });
302
+ options.onOverallProgress?.(overall);
303
+ };
304
+ const resolveMetadata = (file) => typeof options.metadata === "function"
305
+ ? options.metadata(file)
306
+ : options.metadata;
307
+ const resolveFieldName = (file) => typeof options.fieldName === "function"
308
+ ? options.fieldName(file)
309
+ : options.fieldName;
310
+ const uploadFile = async (file, index) => {
311
+ const { endpoint, metadata } = buildTusUploadConfig(assembly.data, file, {
312
+ fieldName: resolveFieldName(file),
313
+ metadata: resolveMetadata(file),
23
314
  });
24
- const data = assembly.data;
25
- options.onAssemblyCreated?.(assembly);
26
- const tusUrl = (typeof data.tus_url === "string" && data.tus_url) ||
27
- (typeof data.tusUrl === "string" && data.tusUrl) ||
28
- "";
29
- if (!tusUrl) {
30
- throw new Error("Transloadit response missing tus_url for resumable upload");
31
- }
32
- const assemblyUrl = (typeof data.assembly_ssl_url === "string" &&
33
- data.assembly_ssl_url) ||
34
- (typeof data.assembly_url === "string" && data.assembly_url) ||
35
- (typeof data.assemblyUrl === "string" && data.assemblyUrl) ||
36
- "";
37
- if (!assemblyUrl) {
38
- throw new Error("Transloadit response missing assembly_url for resumable upload");
39
- }
40
- const metadata = {
41
- filename: file.name,
42
- ...options.metadata,
43
- };
44
- if (file.type) {
45
- metadata.filetype = file.type;
46
- }
47
- if (!metadata.fieldname) {
48
- metadata.fieldname = options.fieldName ?? "file";
49
- }
50
- if (!metadata.assembly_url) {
51
- metadata.assembly_url = assemblyUrl;
52
- }
53
- const getStatus = (error) => error.originalResponse?.getStatus &&
54
- typeof error.originalResponse.getStatus === "function"
55
- ? error.originalResponse.getStatus()
56
- : 0;
57
- const retryDelays = options.retryDelays
58
- ? [...options.retryDelays]
59
- : [1000, 5000, 15000, 30000];
60
- const rateLimitRetryDelays = options.rateLimitRetryDelays
61
- ? [...options.rateLimitRetryDelays]
62
- : [20_000, 40_000, 80_000];
63
- const shouldRetry = (error) => {
64
- const status = getStatus(error);
65
- if (!status)
66
- return true;
67
- if (status === 409 || status === 423)
68
- return true;
69
- return status < 400 || status >= 500;
70
- };
71
315
  let uploadUrl = null;
72
316
  let rateLimitAttempt = 0;
317
+ let uploader = null;
73
318
  const runUpload = () => new Promise((resolve, reject) => {
74
- let uploader;
319
+ if (cancelled) {
320
+ reject(transloaditError("upload", "Upload canceled"));
321
+ return;
322
+ }
323
+ const onAbort = () => {
324
+ reject(transloaditError("upload", "Upload canceled"));
325
+ };
326
+ abortController.signal.addEventListener("abort", onAbort, {
327
+ once: true,
328
+ });
329
+ let currentUploader;
75
330
  const uploadOptions = {
76
- endpoint: tusUrl,
331
+ endpoint,
77
332
  metadata,
78
333
  retryDelays,
79
334
  uploadDataDuringCreation: options.uploadDataDuringCreation ?? false,
80
335
  onUploadUrlAvailable: () => {
81
- uploadUrl = uploader.url;
336
+ uploadUrl = currentUploader.url;
82
337
  },
83
338
  onShouldRetry: (error, retryAttempt) => options.onShouldRetry?.(error, retryAttempt) ??
84
339
  shouldRetry(error),
85
340
  onProgress: (bytesUploaded, bytesTotal) => {
341
+ perFileBytes.set(index, {
342
+ uploaded: bytesUploaded,
343
+ total: bytesTotal,
344
+ });
86
345
  const progress = Math.round((bytesUploaded / bytesTotal) * 100);
87
- setState((prev) => ({ ...prev, progress }));
88
- options.onProgress?.(progress);
346
+ options.onFileProgress?.(file, progress);
347
+ updateOverallProgress();
89
348
  },
90
349
  onError: (error) => {
350
+ abortController.signal.removeEventListener("abort", onAbort);
91
351
  reject(error);
92
352
  },
93
353
  onSuccess: () => {
354
+ abortController.signal.removeEventListener("abort", onAbort);
94
355
  resolve();
95
356
  },
96
357
  };
@@ -111,8 +372,14 @@ export function useTransloaditTusUpload(createAssembly) {
111
372
  uploadOptions.removeFingerprintOnSuccess =
112
373
  options.removeFingerprintOnSuccess;
113
374
  }
114
- uploader = new Upload(file, uploadOptions);
115
- uploader.start();
375
+ currentUploader = new Upload(file, uploadOptions);
376
+ uploader = currentUploader;
377
+ inFlight.add(currentUploader);
378
+ currentUploader.start();
379
+ }).finally(() => {
380
+ if (uploader) {
381
+ inFlight.delete(uploader);
382
+ }
116
383
  });
117
384
  while (true) {
118
385
  try {
@@ -120,6 +387,9 @@ export function useTransloaditTusUpload(createAssembly) {
120
387
  break;
121
388
  }
122
389
  catch (error) {
390
+ if (cancelled) {
391
+ throw error;
392
+ }
123
393
  const status = getStatus(error);
124
394
  if (status === 429 &&
125
395
  rateLimitAttempt < rateLimitRetryDelays.length) {
@@ -131,25 +401,263 @@ export function useTransloaditTusUpload(createAssembly) {
131
401
  throw error;
132
402
  }
133
403
  }
134
- setState({ isUploading: false, progress: 100, error: null });
135
- return assembly;
404
+ };
405
+ let nextIndex = 0;
406
+ const errors = [];
407
+ const worker = async () => {
408
+ while (true) {
409
+ if (cancelled)
410
+ return;
411
+ const index = nextIndex;
412
+ nextIndex += 1;
413
+ if (index >= files.length)
414
+ return;
415
+ const file = files[index];
416
+ try {
417
+ await uploadFile(file, index);
418
+ results[index] = { file, status: "success" };
419
+ options.onFileComplete?.(file);
420
+ }
421
+ catch (error) {
422
+ if (cancelled) {
423
+ results[index] = { file, status: "canceled" };
424
+ return;
425
+ }
426
+ const err = error instanceof Error
427
+ ? error
428
+ : transloaditError("upload", "Upload failed");
429
+ results[index] = { file, status: "error", error: err };
430
+ errors.push(err);
431
+ options.onFileError?.(file, err);
432
+ if (options.failFast ?? false) {
433
+ cancel();
434
+ return;
435
+ }
436
+ }
437
+ }
438
+ };
439
+ await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
440
+ if (cancelled) {
441
+ const error = transloaditError("upload", "Upload canceled");
442
+ error.results = {
443
+ assemblyId: assembly.assemblyId,
444
+ data: assembly.data,
445
+ files: results,
446
+ };
447
+ throw error;
448
+ }
449
+ const hasErrors = results.some((result) => result.status === "error");
450
+ const resultPayload = {
451
+ assemblyId: assembly.assemblyId,
452
+ data: assembly.data,
453
+ files: results,
454
+ };
455
+ if (hasErrors) {
456
+ const error = transloaditError("upload", `Failed to upload ${errors.length} file${errors.length === 1 ? "" : "s"}`);
457
+ error.results =
458
+ resultPayload;
459
+ throw error;
460
+ }
461
+ emitState({ isUploading: false, progress: 100, error: null });
462
+ return resultPayload;
463
+ })();
464
+ return { promise, cancel };
465
+ }
466
+ export function useTransloaditUpload(options) {
467
+ const create = useAction(options.createAssembly);
468
+ const refresh = useAction(options.refreshAssembly);
469
+ const [state, setState] = useState({
470
+ isUploading: false,
471
+ progress: 0,
472
+ error: null,
473
+ });
474
+ const [assemblyId, setAssemblyId] = useState(null);
475
+ const [assemblyData, setAssemblyData] = useState(null);
476
+ const cancelRef = useRef(null);
477
+ const upload = useCallback(async (files, uploadOptions) => {
478
+ const resolved = files instanceof FileList
479
+ ? Array.from(files)
480
+ : Array.isArray(files)
481
+ ? files
482
+ : [files];
483
+ const controller = uploadFilesWithTransloaditTus(create, resolved, {
484
+ ...uploadOptions,
485
+ onStateChange: setState,
486
+ onAssemblyCreated: (assembly) => {
487
+ setAssemblyId(assembly.assemblyId);
488
+ setAssemblyData(assembly.data);
489
+ uploadOptions.onAssemblyCreated?.(assembly);
490
+ },
491
+ });
492
+ cancelRef.current = controller.cancel;
493
+ try {
494
+ const result = await controller.promise;
495
+ setAssemblyId(result.assemblyId);
496
+ setAssemblyData(result.data);
497
+ return result;
136
498
  }
137
499
  catch (error) {
138
- const err = error instanceof Error ? error : new Error("Upload failed");
139
- setState({ isUploading: false, progress: 0, error: err });
140
- throw err;
500
+ const resolvedError = error instanceof Error
501
+ ? error
502
+ : transloaditError("upload", "Upload failed");
503
+ setState({ isUploading: false, progress: 0, error: resolvedError });
504
+ throw error;
505
+ }
506
+ finally {
507
+ cancelRef.current = null;
141
508
  }
142
509
  }, [create]);
510
+ const cancel = useCallback(() => {
511
+ cancelRef.current?.();
512
+ }, []);
143
513
  const reset = useCallback(() => {
514
+ cancelRef.current?.();
515
+ cancelRef.current = null;
516
+ setAssemblyId(null);
517
+ setAssemblyData(null);
144
518
  setState({ isUploading: false, progress: 0, error: null });
145
519
  }, []);
146
- return useMemo(() => ({
520
+ const assembly = useQuery(options.getStatus, assemblyId ? { assemblyId } : "skip");
521
+ const parsedStatus = useMemo(() => {
522
+ const candidate = assembly && typeof assembly === "object"
523
+ ? (assembly.raw ?? assembly)
524
+ : assembly;
525
+ return parseAssemblyStatus(candidate);
526
+ }, [assembly]);
527
+ const results = useQuery(options.listResults, assemblyId ? { assemblyId } : "skip");
528
+ useAssemblyPoller({
529
+ assemblyId,
530
+ status: parsedStatus,
531
+ refresh: async () => {
532
+ if (!assemblyId)
533
+ return;
534
+ await refresh({ assemblyId });
535
+ },
536
+ intervalMs: options.pollIntervalMs ?? 5000,
537
+ shouldContinue: options.shouldContinue,
538
+ onError: options.onError,
539
+ });
540
+ return {
147
541
  upload,
542
+ cancel,
148
543
  reset,
149
544
  isUploading: state.isUploading,
150
545
  progress: state.progress,
151
546
  error: state.error,
152
- }), [state.error, state.isUploading, state.progress, upload, reset]);
547
+ assemblyId,
548
+ assemblyData,
549
+ assembly,
550
+ status: parsedStatus,
551
+ results,
552
+ };
553
+ }
554
+ export function useTransloaditUppy(options) {
555
+ const create = useAction(options.createAssembly);
556
+ const refresh = useAction(options.refreshAssembly);
557
+ const [isUploading, setIsUploading] = useState(false);
558
+ const [error, setError] = useState(null);
559
+ const [assemblyId, setAssemblyId] = useState(null);
560
+ const [assemblyData, setAssemblyData] = useState(null);
561
+ const [uploadResult, setUploadResult] = useState(null);
562
+ const assembly = useQuery(options.getStatus, assemblyId ? { assemblyId } : "skip");
563
+ const results = useQuery(options.listResults, assemblyId ? { assemblyId } : "skip");
564
+ const parsedStatus = useMemo(() => {
565
+ const candidate = assembly && typeof assembly === "object"
566
+ ? (assembly.raw ?? assembly)
567
+ : assembly;
568
+ return parseAssemblyStatus(candidate);
569
+ }, [assembly]);
570
+ useAssemblyPoller({
571
+ assemblyId,
572
+ status: parsedStatus,
573
+ refresh: async () => {
574
+ if (!assemblyId)
575
+ return;
576
+ await refresh({ assemblyId });
577
+ },
578
+ intervalMs: options.pollIntervalMs ?? 5000,
579
+ shouldContinue: options.shouldContinue,
580
+ onError: options.onError,
581
+ });
582
+ const startUpload = useCallback(async (overrides) => {
583
+ setError(null);
584
+ setIsUploading(true);
585
+ try {
586
+ const files = options.uppy.getFiles();
587
+ if (files.length === 0) {
588
+ throw transloaditError("upload", "No files provided for upload");
589
+ }
590
+ const createAssemblyArgs = {
591
+ ...(options.createAssemblyArgs ?? {}),
592
+ ...(overrides?.createAssemblyArgs ?? {}),
593
+ };
594
+ const { assembly, uploadResult: result } = await uploadWithAssembly(create, options.uppy, {
595
+ fileCount: overrides?.fileCount ?? options.fileCount ?? files.length,
596
+ fieldName: overrides?.fieldName ?? options.fieldName,
597
+ metadata: overrides?.metadata ?? options.metadata,
598
+ addRequestId: overrides?.addRequestId ?? options.addRequestId,
599
+ createAssemblyArgs,
600
+ onAssemblyCreated: (created) => {
601
+ const typed = created;
602
+ setAssemblyId(typed.assemblyId);
603
+ setAssemblyData(typed.data);
604
+ options.onAssemblyCreated?.(typed);
605
+ overrides?.onAssemblyCreated?.(created);
606
+ },
607
+ });
608
+ setAssemblyId(assembly.assemblyId);
609
+ setAssemblyData(assembly.data);
610
+ setUploadResult(result);
611
+ options.onUploadResult?.(result);
612
+ setIsUploading(false);
613
+ return { assembly, uploadResult: result };
614
+ }
615
+ catch (err) {
616
+ const resolved = err instanceof Error
617
+ ? err
618
+ : transloaditError("upload", "Upload failed");
619
+ setError(resolved);
620
+ setIsUploading(false);
621
+ throw resolved;
622
+ }
623
+ }, [
624
+ create,
625
+ options.addRequestId,
626
+ options.createAssemblyArgs,
627
+ options.fieldName,
628
+ options.fileCount,
629
+ options.metadata,
630
+ options.onAssemblyCreated,
631
+ options.onUploadResult,
632
+ options.uppy,
633
+ ]);
634
+ const reset = useCallback(() => {
635
+ setIsUploading(false);
636
+ setError(null);
637
+ setAssemblyId(null);
638
+ setAssemblyData(null);
639
+ setUploadResult(null);
640
+ }, []);
641
+ const stage = useMemo(() => {
642
+ if (error)
643
+ return "error";
644
+ if (isUploading)
645
+ return "uploading";
646
+ return parsedStatus ? getAssemblyStage(parsedStatus) : null;
647
+ }, [error, isUploading, parsedStatus]);
648
+ return {
649
+ startUpload,
650
+ reset,
651
+ isUploading,
652
+ error,
653
+ assemblyId,
654
+ assemblyData,
655
+ assembly,
656
+ status: parsedStatus,
657
+ results,
658
+ stage,
659
+ uploadResult,
660
+ };
153
661
  }
154
662
  export function useAssemblyStatus(getStatus, assemblyId) {
155
663
  return useQuery(getStatus, { assemblyId });
@@ -157,47 +665,119 @@ export function useAssemblyStatus(getStatus, assemblyId) {
157
665
  export function useAssemblyStatusWithPolling(getStatus, refreshAssembly, assemblyId, options) {
158
666
  const status = useQuery(getStatus, { assemblyId });
159
667
  const refresh = useAction(refreshAssembly);
668
+ const statusRef = useRef(status);
669
+ const shouldContinueRef = useRef(options?.shouldContinue);
670
+ const onErrorRef = useRef(options?.onError);
671
+ useEffect(() => {
672
+ statusRef.current = status;
673
+ }, [status]);
674
+ useEffect(() => {
675
+ shouldContinueRef.current = options?.shouldContinue;
676
+ }, [options?.shouldContinue]);
677
+ useEffect(() => {
678
+ onErrorRef.current = options?.onError;
679
+ }, [options?.onError]);
160
680
  useEffect(() => {
161
681
  if (!assemblyId)
162
682
  return;
163
683
  const intervalMs = options?.pollIntervalMs ?? 5000;
164
684
  if (intervalMs <= 0)
165
685
  return;
166
- const isTerminal = () => {
167
- if (!options?.stopOnTerminal)
686
+ const shouldKeepPolling = () => {
687
+ const shouldContinue = shouldContinueRef.current?.();
688
+ if (shouldContinue === false)
168
689
  return false;
169
- if (!status || typeof status !== "object")
170
- return false;
171
- const ok = "ok" in status && typeof status.ok === "string" ? status.ok : "";
172
- return (ok === "ASSEMBLY_COMPLETED" ||
173
- ok === "ASSEMBLY_FAILED" ||
174
- ok === "ASSEMBLY_CANCELED");
690
+ if (!options?.stopOnTerminal)
691
+ return true;
692
+ const current = statusRef.current;
693
+ const rawCandidate = current && typeof current === "object"
694
+ ? (current.raw ?? current)
695
+ : current;
696
+ const parsed = parseAssemblyStatus(rawCandidate);
697
+ return !(parsed ? isAssemblyTerminal(parsed) : false);
175
698
  };
176
- if (isTerminal())
699
+ if (!shouldKeepPolling())
177
700
  return;
178
701
  let cancelled = false;
702
+ let intervalId = null;
703
+ let inFlight = false;
179
704
  const tick = async () => {
180
705
  if (cancelled)
181
706
  return;
182
- await refresh({ assemblyId });
707
+ if (!shouldKeepPolling()) {
708
+ if (intervalId)
709
+ clearInterval(intervalId);
710
+ cancelled = true;
711
+ return;
712
+ }
713
+ if (inFlight)
714
+ return;
715
+ inFlight = true;
716
+ try {
717
+ await refresh({ assemblyId });
718
+ }
719
+ catch (error) {
720
+ const resolved = error instanceof Error
721
+ ? error
722
+ : transloaditError("polling", "Refresh failed");
723
+ onErrorRef.current?.(resolved);
724
+ }
725
+ finally {
726
+ inFlight = false;
727
+ }
183
728
  };
184
- void tick();
185
- const id = setInterval(() => {
729
+ intervalId = setInterval(() => {
186
730
  void tick();
187
731
  }, intervalMs);
732
+ void tick();
188
733
  return () => {
189
734
  cancelled = true;
190
- clearInterval(id);
735
+ if (intervalId)
736
+ clearInterval(intervalId);
191
737
  };
192
- }, [
193
- assemblyId,
194
- options?.pollIntervalMs,
195
- options?.stopOnTerminal,
196
- refresh,
197
- status,
198
- ]);
738
+ }, [assemblyId, options?.pollIntervalMs, options?.stopOnTerminal, refresh]);
199
739
  return status;
200
740
  }
741
+ /**
742
+ * @deprecated Prefer `useAssemblyStatusWithPolling` for public usage.
743
+ */
744
+ export function useAssemblyPoller(options) {
745
+ const refreshRef = useRef(options.refresh);
746
+ const onErrorRef = useRef(options.onError);
747
+ const shouldContinueRef = useRef(options.shouldContinue);
748
+ const statusRef = useRef(options.status);
749
+ useEffect(() => {
750
+ refreshRef.current = options.refresh;
751
+ }, [options.refresh]);
752
+ useEffect(() => {
753
+ onErrorRef.current = options.onError;
754
+ }, [options.onError]);
755
+ useEffect(() => {
756
+ shouldContinueRef.current = options.shouldContinue;
757
+ }, [options.shouldContinue]);
758
+ useEffect(() => {
759
+ statusRef.current = options.status;
760
+ }, [options.status]);
761
+ useEffect(() => {
762
+ if (!options.assemblyId)
763
+ return;
764
+ const controller = pollAssembly({
765
+ intervalMs: options.intervalMs,
766
+ refresh: () => refreshRef.current(),
767
+ shouldContinue: () => shouldContinueRef.current?.() ?? false,
768
+ isTerminal: () => {
769
+ const current = statusRef.current;
770
+ return current ? isAssemblyTerminal(current) : false;
771
+ },
772
+ onError: (error) => {
773
+ onErrorRef.current?.(error);
774
+ },
775
+ });
776
+ return () => {
777
+ controller.stop();
778
+ };
779
+ }, [options.assemblyId, options.intervalMs]);
780
+ }
201
781
  export function useTransloaditFiles(listResults, args) {
202
782
  return useQuery(listResults, args);
203
783
  }