@transloadit/convex 0.0.3 → 0.0.4

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 (81) hide show
  1. package/README.md +114 -134
  2. package/dist/client/index.d.ts +24 -13
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +14 -3
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +2 -2
  7. package/dist/component/_generated/dataModel.d.ts +1 -1
  8. package/dist/component/_generated/server.d.ts +1 -1
  9. package/dist/component/apiUtils.d.ts +26 -6
  10. package/dist/component/apiUtils.d.ts.map +1 -1
  11. package/dist/component/apiUtils.js +48 -38
  12. package/dist/component/apiUtils.js.map +1 -1
  13. package/dist/component/lib.d.ts +7 -9
  14. package/dist/component/lib.d.ts.map +1 -1
  15. package/dist/component/lib.js +74 -18
  16. package/dist/component/lib.js.map +1 -1
  17. package/dist/component/schema.d.ts +4 -6
  18. package/dist/component/schema.d.ts.map +1 -1
  19. package/dist/component/schema.js +0 -7
  20. package/dist/component/schema.js.map +1 -1
  21. package/dist/debug/index.d.ts +19 -0
  22. package/dist/debug/index.d.ts.map +1 -0
  23. package/dist/debug/index.js +49 -0
  24. package/dist/debug/index.js.map +1 -0
  25. package/dist/react/index.d.ts +201 -3
  26. package/dist/react/index.d.ts.map +1 -1
  27. package/dist/react/index.js +674 -94
  28. package/dist/react/index.js.map +1 -1
  29. package/dist/shared/assemblyUrls.d.ts +10 -0
  30. package/dist/shared/assemblyUrls.d.ts.map +1 -0
  31. package/dist/shared/assemblyUrls.js +26 -0
  32. package/dist/shared/assemblyUrls.js.map +1 -0
  33. package/dist/shared/errors.d.ts +7 -0
  34. package/dist/shared/errors.d.ts.map +1 -0
  35. package/dist/shared/errors.js +10 -0
  36. package/dist/shared/errors.js.map +1 -0
  37. package/dist/shared/pollAssembly.d.ts +12 -0
  38. package/dist/shared/pollAssembly.d.ts.map +1 -0
  39. package/dist/shared/pollAssembly.js +50 -0
  40. package/dist/shared/pollAssembly.js.map +1 -0
  41. package/dist/shared/resultTypes.d.ts +37 -0
  42. package/dist/shared/resultTypes.d.ts.map +1 -0
  43. package/dist/shared/resultTypes.js +2 -0
  44. package/dist/shared/resultTypes.js.map +1 -0
  45. package/dist/shared/resultUtils.d.ts +4 -0
  46. package/dist/shared/resultUtils.d.ts.map +1 -0
  47. package/dist/shared/resultUtils.js +69 -0
  48. package/dist/shared/resultUtils.js.map +1 -0
  49. package/dist/shared/tusUpload.d.ts +13 -0
  50. package/dist/shared/tusUpload.d.ts.map +1 -0
  51. package/dist/shared/tusUpload.js +32 -0
  52. package/dist/shared/tusUpload.js.map +1 -0
  53. package/dist/test/index.d.ts +4 -4
  54. package/dist/test/nodeModules.d.ts +2 -0
  55. package/dist/test/nodeModules.d.ts.map +1 -0
  56. package/dist/test/nodeModules.js +19 -0
  57. package/dist/test/nodeModules.js.map +1 -0
  58. package/package.json +36 -6
  59. package/src/client/index.ts +73 -7
  60. package/src/component/_generated/api.ts +2 -2
  61. package/src/component/_generated/dataModel.ts +1 -1
  62. package/src/component/_generated/server.ts +1 -1
  63. package/src/component/apiUtils.test.ts +166 -2
  64. package/src/component/apiUtils.ts +96 -64
  65. package/src/component/lib.test.ts +170 -4
  66. package/src/component/lib.ts +113 -25
  67. package/src/component/schema.ts +0 -10
  68. package/src/debug/index.ts +84 -0
  69. package/src/react/index.test.tsx +340 -0
  70. package/src/react/index.tsx +1089 -179
  71. package/src/react/uploadWithTus.test.tsx +192 -0
  72. package/src/shared/assemblyUrls.test.ts +71 -0
  73. package/src/shared/assemblyUrls.ts +59 -0
  74. package/src/shared/errors.ts +23 -0
  75. package/src/shared/pollAssembly.ts +65 -0
  76. package/src/shared/resultTypes.ts +44 -0
  77. package/src/shared/resultUtils.test.ts +29 -0
  78. package/src/shared/resultUtils.ts +71 -0
  79. package/src/shared/tusUpload.ts +59 -0
  80. package/src/test/index.ts +1 -1
  81. package/src/test/nodeModules.ts +19 -0
@@ -1,7 +1,19 @@
1
+ import {
2
+ type AssemblyStatus,
3
+ isAssemblyTerminal,
4
+ } from "@transloadit/zod/v3/assemblyStatus";
1
5
  import { useAction, useQuery } from "convex/react";
2
6
  import type { FunctionReference } from "convex/server";
3
- import { useCallback, useEffect, useMemo, useState } from "react";
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
8
  import { Upload } from "tus-js-client";
9
+ import {
10
+ type AssemblyStage,
11
+ getAssemblyStage,
12
+ parseAssemblyStatus,
13
+ } from "../shared/assemblyUrls.ts";
14
+ import { transloaditError } from "../shared/errors.ts";
15
+ import { pollAssembly } from "../shared/pollAssembly.ts";
16
+ import { buildTusUploadConfig } from "../shared/tusUpload.ts";
5
17
 
6
18
  export type CreateAssemblyFn = FunctionReference<
7
19
  "action",
@@ -19,6 +31,26 @@ export type CreateAssemblyFn = FunctionReference<
19
31
  { assemblyId: string; data: Record<string, unknown> }
20
32
  >;
21
33
 
34
+ export type CreateAssemblyArgs = {
35
+ templateId?: string;
36
+ steps?: unknown;
37
+ fields?: unknown;
38
+ notifyUrl?: string;
39
+ numExpectedUploadFiles?: number;
40
+ expires?: string;
41
+ additionalParams?: unknown;
42
+ userId?: string;
43
+ };
44
+
45
+ export type CreateAssemblyResponse = {
46
+ assemblyId: string;
47
+ data: Record<string, unknown>;
48
+ };
49
+
50
+ export type CreateAssemblyHandler = (
51
+ args: CreateAssemblyArgs,
52
+ ) => Promise<CreateAssemblyResponse>;
53
+
22
54
  export type GetAssemblyStatusFn = FunctionReference<
23
55
  "query",
24
56
  "public",
@@ -75,6 +107,395 @@ export interface TusUploadOptions extends UploadOptions {
75
107
  }) => void;
76
108
  }
77
109
 
110
+ export type TusUploadEvents = {
111
+ onStateChange?: (state: UploadState) => void;
112
+ };
113
+
114
+ export type MultiFileTusUploadOptions = Omit<
115
+ TusUploadOptions,
116
+ "metadata" | "fieldName" | "onProgress"
117
+ > & {
118
+ concurrency?: number;
119
+ metadata?: Record<string, string> | ((file: File) => Record<string, string>);
120
+ fieldName?: string | ((file: File) => string);
121
+ onFileProgress?: (file: File, progress: number) => void;
122
+ onFileComplete?: (file: File) => void;
123
+ onFileError?: (file: File, error: Error) => void;
124
+ onOverallProgress?: (progress: number) => void;
125
+ onStateChange?: (state: UploadState) => void;
126
+ failFast?: boolean;
127
+ signal?: AbortSignal;
128
+ };
129
+
130
+ export type MultiFileTusUploadResult = {
131
+ assemblyId: string;
132
+ data: Record<string, unknown>;
133
+ files: Array<{
134
+ file: File;
135
+ status: "success" | "error" | "canceled";
136
+ error?: Error;
137
+ }>;
138
+ };
139
+
140
+ export type MultiFileTusUploadController = {
141
+ promise: Promise<MultiFileTusUploadResult>;
142
+ cancel: () => void;
143
+ };
144
+
145
+ export type UppyTusState = {
146
+ endpoint?: string;
147
+ addRequestId?: boolean;
148
+ };
149
+
150
+ export type UppyFile = {
151
+ id: string;
152
+ data?: unknown;
153
+ name?: string;
154
+ type?: string;
155
+ tus?: UppyTusState;
156
+ };
157
+
158
+ export type UppyUploadError = { message?: string } | string | null | undefined;
159
+
160
+ export type UppyUploadResult = {
161
+ successful?: Array<{ id: string; name?: string }>;
162
+ failed?: Array<{ id: string; name?: string; error?: UppyUploadError }>;
163
+ };
164
+
165
+ export type UppyLike = {
166
+ getFiles: () => UppyFile[];
167
+ setFileMeta: (fileId: string, metadata: Record<string, string>) => void;
168
+ setFileState: (fileId: string, state: { tus?: UppyTusState }) => void;
169
+ getPlugin: (name: string) =>
170
+ | {
171
+ setOptions?: (options: {
172
+ endpoint?: string;
173
+ addRequestId?: boolean;
174
+ }) => void;
175
+ }
176
+ | undefined
177
+ | null;
178
+ upload: () => Promise<UppyUploadResult | undefined>;
179
+ };
180
+
181
+ export type UploadWithAssemblyOptions<TArgs extends { fileCount: number }> = {
182
+ fileCount?: number;
183
+ fieldName?: string;
184
+ metadata?: Record<string, string>;
185
+ addRequestId?: boolean;
186
+ createAssemblyArgs?: Partial<TArgs>;
187
+ onAssemblyCreated?: (assembly: {
188
+ assemblyId: string;
189
+ data: Record<string, unknown>;
190
+ }) => void;
191
+ };
192
+
193
+ export type UploadWithAssemblyResult<TAssembly> = {
194
+ assembly: TAssembly;
195
+ uploadResult: UppyUploadResult;
196
+ };
197
+
198
+ export type UseTransloaditUploadOptions = {
199
+ createAssembly: CreateAssemblyFn;
200
+ getStatus: GetAssemblyStatusFn;
201
+ listResults: ListResultsFn;
202
+ refreshAssembly: RefreshAssemblyFn;
203
+ pollIntervalMs?: number;
204
+ stopOnTerminal?: boolean;
205
+ shouldContinue?: () => boolean;
206
+ onError?: (error: Error) => void;
207
+ };
208
+
209
+ export type UseTransloaditUploadResult = {
210
+ upload: (
211
+ files: File | File[] | FileList,
212
+ options: MultiFileTusUploadOptions,
213
+ ) => Promise<MultiFileTusUploadResult>;
214
+ cancel: () => void;
215
+ reset: () => void;
216
+ isUploading: boolean;
217
+ progress: number;
218
+ error: Error | null;
219
+ assemblyId: string | null;
220
+ assemblyData: Record<string, unknown> | null;
221
+ assembly: unknown;
222
+ status: AssemblyStatus | null;
223
+ results: Array<unknown> | undefined;
224
+ };
225
+
226
+ export type UseTransloaditUppyOptions<
227
+ TArgs extends { fileCount: number },
228
+ TAssembly extends { assemblyId: string; data: Record<string, unknown> },
229
+ > = {
230
+ uppy: UppyLike;
231
+ createAssembly: FunctionReference<"action", "public", TArgs, TAssembly>;
232
+ getStatus: GetAssemblyStatusFn;
233
+ listResults: ListResultsFn;
234
+ refreshAssembly: RefreshAssemblyFn;
235
+ pollIntervalMs?: number;
236
+ shouldContinue?: () => boolean;
237
+ onError?: (error: Error) => void;
238
+ createAssemblyArgs?: Partial<TArgs>;
239
+ fileCount?: number;
240
+ fieldName?: string;
241
+ metadata?: Record<string, string>;
242
+ addRequestId?: boolean;
243
+ onAssemblyCreated?: (assembly: TAssembly) => void;
244
+ onUploadResult?: (result: UppyUploadResult) => void;
245
+ };
246
+
247
+ export type UseTransloaditUppyResult<
248
+ TArgs extends { fileCount: number },
249
+ TAssembly,
250
+ > = {
251
+ startUpload: (
252
+ overrides?: Partial<UploadWithAssemblyOptions<TArgs>>,
253
+ ) => Promise<UploadWithAssemblyResult<TAssembly>>;
254
+ reset: () => void;
255
+ isUploading: boolean;
256
+ error: Error | null;
257
+ assemblyId: string | null;
258
+ assemblyData: Record<string, unknown> | null;
259
+ assembly: unknown;
260
+ status: AssemblyStatus | null;
261
+ results: Array<unknown> | undefined;
262
+ stage: AssemblyStage | "uploading" | "error" | null;
263
+ uploadResult: UppyUploadResult | null;
264
+ };
265
+
266
+ export async function uploadWithAssembly<
267
+ TArgs extends { fileCount: number },
268
+ TAssembly extends { assemblyId: string; data: Record<string, unknown> },
269
+ >(
270
+ createAssembly: (args: TArgs) => Promise<TAssembly>,
271
+ uppy: UppyLike,
272
+ options: UploadWithAssemblyOptions<TArgs>,
273
+ ): Promise<UploadWithAssemblyResult<TAssembly>> {
274
+ const files = uppy.getFiles();
275
+ if (files.length === 0) {
276
+ throw transloaditError("upload", "No files provided for upload");
277
+ }
278
+
279
+ const args = {
280
+ ...(options.createAssemblyArgs ?? {}),
281
+ fileCount: options.fileCount ?? files.length,
282
+ } as TArgs;
283
+ const assembly = await createAssembly(args);
284
+ options.onAssemblyCreated?.(assembly);
285
+
286
+ const tusPlugin = uppy.getPlugin("Tus");
287
+ if (!tusPlugin) {
288
+ throw transloaditError(
289
+ "upload",
290
+ 'Uppy Tus plugin is required. Call uppy.use(Tus, { endpoint: "" }) before uploadWithAssembly.',
291
+ );
292
+ }
293
+ let tusEndpoint: string | null = null;
294
+ const addRequestId = options.addRequestId ?? true;
295
+
296
+ for (const file of files) {
297
+ if (
298
+ !file.data ||
299
+ typeof Blob === "undefined" ||
300
+ !(file.data instanceof Blob)
301
+ ) {
302
+ throw transloaditError(
303
+ "upload",
304
+ "Uppy file is missing binary data for upload",
305
+ );
306
+ }
307
+ const uploadFile =
308
+ file.data instanceof File
309
+ ? file.data
310
+ : new File([file.data], file.name ?? "file", {
311
+ type: file.data.type || file.type,
312
+ });
313
+ const { endpoint, metadata } = buildTusUploadConfig(
314
+ assembly.data,
315
+ uploadFile,
316
+ {
317
+ fieldName: options.fieldName,
318
+ metadata: options.metadata,
319
+ },
320
+ );
321
+ if (!tusEndpoint) {
322
+ tusEndpoint = endpoint;
323
+ }
324
+ uppy.setFileMeta(file.id, metadata);
325
+ uppy.setFileState(file.id, {
326
+ tus: {
327
+ ...(file.tus ?? {}),
328
+ endpoint,
329
+ addRequestId,
330
+ },
331
+ });
332
+ }
333
+
334
+ if (tusPlugin && "setOptions" in tusPlugin && tusEndpoint) {
335
+ tusPlugin.setOptions?.({ endpoint: tusEndpoint, addRequestId });
336
+ }
337
+
338
+ const uploadResult = await uppy.upload();
339
+ if (!uploadResult) {
340
+ throw transloaditError("upload", "Uppy upload did not return a result");
341
+ }
342
+ return { assembly, uploadResult };
343
+ }
344
+
345
+ /**
346
+ * Low-level tus upload helper. Prefer `useTransloaditUpload` for new code.
347
+ */
348
+ /**
349
+ * Low-level tus upload helper. Prefer `useTransloaditUpload` for new code.
350
+ */
351
+ export async function uploadWithTransloaditTus(
352
+ createAssembly: CreateAssemblyHandler,
353
+ file: File,
354
+ options: TusUploadOptions,
355
+ events: TusUploadEvents = {},
356
+ ): Promise<CreateAssemblyResponse> {
357
+ let currentState: UploadState = {
358
+ isUploading: true,
359
+ progress: 0,
360
+ error: null,
361
+ };
362
+
363
+ const emitState = (next: UploadState) => {
364
+ currentState = next;
365
+ events.onStateChange?.(next);
366
+ };
367
+
368
+ emitState(currentState);
369
+
370
+ try {
371
+ const assembly = await createAssembly({
372
+ templateId: options.templateId,
373
+ steps: options.steps,
374
+ fields: options.fields,
375
+ notifyUrl: options.notifyUrl,
376
+ numExpectedUploadFiles: options.numExpectedUploadFiles ?? 1,
377
+ expires: options.expires,
378
+ additionalParams: options.additionalParams,
379
+ userId: options.userId,
380
+ });
381
+
382
+ const data = assembly.data as Record<string, unknown>;
383
+ options.onAssemblyCreated?.(assembly);
384
+ const { endpoint, metadata } = buildTusUploadConfig(data, file, {
385
+ fieldName: options.fieldName,
386
+ metadata: options.metadata,
387
+ });
388
+
389
+ type RetryError = {
390
+ originalResponse?: {
391
+ getStatus?: () => number;
392
+ getHeader?: (header: string) => string | undefined;
393
+ } | null;
394
+ };
395
+
396
+ const getStatus = (error: RetryError) =>
397
+ error.originalResponse?.getStatus &&
398
+ typeof error.originalResponse.getStatus === "function"
399
+ ? error.originalResponse.getStatus()
400
+ : 0;
401
+
402
+ const retryDelays = options.retryDelays
403
+ ? [...options.retryDelays]
404
+ : [1000, 5000, 15000, 30000];
405
+ const rateLimitRetryDelays = options.rateLimitRetryDelays
406
+ ? [...options.rateLimitRetryDelays]
407
+ : [20_000, 40_000, 80_000];
408
+
409
+ const shouldRetry = (error: RetryError) => {
410
+ const status = getStatus(error);
411
+ if (!status) return true;
412
+ if (status === 409 || status === 423) return true;
413
+ return status < 400 || status >= 500;
414
+ };
415
+
416
+ let uploadUrl: string | null = null;
417
+ let rateLimitAttempt = 0;
418
+
419
+ const runUpload = () =>
420
+ new Promise<void>((resolve, reject) => {
421
+ let uploader: Upload;
422
+ const uploadOptions: ConstructorParameters<typeof Upload>[1] = {
423
+ endpoint,
424
+ metadata,
425
+ retryDelays,
426
+ uploadDataDuringCreation: options.uploadDataDuringCreation ?? false,
427
+ onUploadUrlAvailable: () => {
428
+ uploadUrl = uploader.url;
429
+ },
430
+ onShouldRetry: (error, retryAttempt) =>
431
+ options.onShouldRetry?.(error, retryAttempt) ?? shouldRetry(error),
432
+ onProgress: (bytesUploaded, bytesTotal) => {
433
+ const progress = Math.round((bytesUploaded / bytesTotal) * 100);
434
+ emitState({ isUploading: true, progress, error: null });
435
+ options.onProgress?.(progress);
436
+ },
437
+ onError: (error) => {
438
+ reject(error);
439
+ },
440
+ onSuccess: () => {
441
+ resolve();
442
+ },
443
+ };
444
+
445
+ if (options.chunkSize !== undefined) {
446
+ uploadOptions.chunkSize = options.chunkSize;
447
+ }
448
+ if (uploadUrl) {
449
+ uploadOptions.uploadUrl = uploadUrl;
450
+ }
451
+ if (options.overridePatchMethod !== undefined) {
452
+ uploadOptions.overridePatchMethod = options.overridePatchMethod;
453
+ }
454
+ if (options.storeFingerprintForResuming !== undefined) {
455
+ uploadOptions.storeFingerprintForResuming =
456
+ options.storeFingerprintForResuming;
457
+ }
458
+ if (options.removeFingerprintOnSuccess !== undefined) {
459
+ uploadOptions.removeFingerprintOnSuccess =
460
+ options.removeFingerprintOnSuccess;
461
+ }
462
+
463
+ uploader = new Upload(file, uploadOptions);
464
+
465
+ uploader.start();
466
+ });
467
+
468
+ while (true) {
469
+ try {
470
+ await runUpload();
471
+ break;
472
+ } catch (error) {
473
+ const status = getStatus(error as RetryError);
474
+ if (status === 429 && rateLimitAttempt < rateLimitRetryDelays.length) {
475
+ const delay = rateLimitRetryDelays[rateLimitAttempt] ?? 0;
476
+ rateLimitAttempt += 1;
477
+ await new Promise((resolve) => setTimeout(resolve, delay));
478
+ continue;
479
+ }
480
+ throw error;
481
+ }
482
+ }
483
+
484
+ emitState({ isUploading: false, progress: 100, error: null });
485
+ return assembly;
486
+ } catch (error) {
487
+ const err =
488
+ error instanceof Error
489
+ ? error
490
+ : transloaditError("upload", "Upload failed");
491
+ emitState({ isUploading: false, progress: 0, error: err });
492
+ throw err;
493
+ }
494
+ }
495
+
496
+ /**
497
+ * @deprecated Prefer `useTransloaditUpload` (single + multi-file) for new code.
498
+ */
78
499
  export function useTransloaditTusUpload(createAssembly: CreateAssemblyFn) {
79
500
  const create = useAction(createAssembly);
80
501
  const [state, setState] = useState<UploadState>({
@@ -84,186 +505,587 @@ export function useTransloaditTusUpload(createAssembly: CreateAssemblyFn) {
84
505
  });
85
506
 
86
507
  const upload = useCallback(
87
- async (file: File, options: TusUploadOptions) => {
88
- setState({ isUploading: true, progress: 0, error: null });
508
+ async (file: File, options: TusUploadOptions) =>
509
+ uploadWithTransloaditTus(create, file, options, {
510
+ onStateChange: setState,
511
+ }),
512
+ [create],
513
+ );
514
+
515
+ const reset = useCallback(() => {
516
+ setState({ isUploading: false, progress: 0, error: null });
517
+ }, []);
89
518
 
519
+ return useMemo(
520
+ () => ({
521
+ upload,
522
+ reset,
523
+ isUploading: state.isUploading,
524
+ progress: state.progress,
525
+ error: state.error,
526
+ }),
527
+ [state.error, state.isUploading, state.progress, upload, reset],
528
+ );
529
+ }
530
+
531
+ /**
532
+ * Low-level multi-file tus uploader. Prefer `useTransloaditUpload` for new code.
533
+ */
534
+ export function uploadFilesWithTransloaditTus(
535
+ createAssembly: CreateAssemblyHandler,
536
+ files: File[],
537
+ options: MultiFileTusUploadOptions,
538
+ ): MultiFileTusUploadController {
539
+ const concurrency = Math.max(1, options.concurrency ?? 3);
540
+ const state: UploadState = {
541
+ isUploading: true,
542
+ progress: 0,
543
+ error: null,
544
+ };
545
+ const results: MultiFileTusUploadResult["files"] = files.map((file) => ({
546
+ file,
547
+ status: "canceled",
548
+ }));
549
+ const inFlight = new Set<Upload>();
550
+ const abortController = new AbortController();
551
+ let cancelled = false;
552
+
553
+ const emitState = (next: UploadState) => {
554
+ state.isUploading = next.isUploading;
555
+ state.progress = next.progress;
556
+ state.error = next.error;
557
+ options.onStateChange?.(next);
558
+ };
559
+
560
+ const cancel = () => {
561
+ if (cancelled) return;
562
+ cancelled = true;
563
+ abortController.abort();
564
+ for (const uploader of inFlight) {
90
565
  try {
91
- const assembly = await create({
92
- templateId: options.templateId,
93
- steps: options.steps,
94
- fields: options.fields,
95
- notifyUrl: options.notifyUrl,
96
- numExpectedUploadFiles: options.numExpectedUploadFiles ?? 1,
97
- expires: options.expires,
98
- additionalParams: options.additionalParams,
99
- userId: options.userId,
100
- });
566
+ uploader.abort(true);
567
+ } catch {
568
+ // ignore abort errors
569
+ }
570
+ }
571
+ };
101
572
 
102
- const data = assembly.data as Record<string, unknown>;
103
- options.onAssemblyCreated?.(assembly);
104
- const tusUrl =
105
- (typeof data.tus_url === "string" && data.tus_url) ||
106
- (typeof data.tusUrl === "string" && data.tusUrl) ||
107
- "";
108
-
109
- if (!tusUrl) {
110
- throw new Error(
111
- "Transloadit response missing tus_url for resumable upload",
112
- );
113
- }
573
+ if (options.signal) {
574
+ if (options.signal.aborted) {
575
+ cancel();
576
+ } else {
577
+ options.signal.addEventListener("abort", cancel, { once: true });
578
+ }
579
+ }
114
580
 
115
- const assemblyUrl =
116
- (typeof data.assembly_ssl_url === "string" &&
117
- data.assembly_ssl_url) ||
118
- (typeof data.assembly_url === "string" && data.assembly_url) ||
119
- (typeof data.assemblyUrl === "string" && data.assemblyUrl) ||
120
- "";
121
-
122
- if (!assemblyUrl) {
123
- throw new Error(
124
- "Transloadit response missing assembly_url for resumable upload",
125
- );
126
- }
581
+ const promise = (async () => {
582
+ if (files.length === 0) {
583
+ throw transloaditError("upload", "No files provided for upload");
584
+ }
127
585
 
128
- const metadata: Record<string, string> = {
129
- filename: file.name,
130
- ...options.metadata,
131
- };
132
- if (file.type) {
133
- metadata.filetype = file.type;
134
- }
135
- if (!metadata.fieldname) {
136
- metadata.fieldname = options.fieldName ?? "file";
137
- }
138
- if (!metadata.assembly_url) {
139
- metadata.assembly_url = assemblyUrl;
140
- }
586
+ emitState({ ...state });
141
587
 
142
- type RetryError = {
143
- originalResponse?: {
144
- getStatus?: () => number;
145
- getHeader?: (header: string) => string | undefined;
146
- } | null;
147
- };
588
+ const assembly = await createAssembly({
589
+ templateId: options.templateId,
590
+ steps: options.steps,
591
+ fields: options.fields,
592
+ notifyUrl: options.notifyUrl,
593
+ numExpectedUploadFiles: options.numExpectedUploadFiles ?? files.length,
594
+ expires: options.expires,
595
+ additionalParams: options.additionalParams,
596
+ userId: options.userId,
597
+ });
148
598
 
149
- const getStatus = (error: RetryError) =>
150
- error.originalResponse?.getStatus &&
151
- typeof error.originalResponse.getStatus === "function"
152
- ? error.originalResponse.getStatus()
153
- : 0;
154
-
155
- const retryDelays = options.retryDelays
156
- ? [...options.retryDelays]
157
- : [1000, 5000, 15000, 30000];
158
- const rateLimitRetryDelays = options.rateLimitRetryDelays
159
- ? [...options.rateLimitRetryDelays]
160
- : [20_000, 40_000, 80_000];
161
-
162
- const shouldRetry = (error: RetryError) => {
163
- const status = getStatus(error);
164
- if (!status) return true;
165
- if (status === 409 || status === 423) return true;
166
- return status < 400 || status >= 500;
167
- };
599
+ options.onAssemblyCreated?.(assembly);
600
+
601
+ type RetryError = {
602
+ originalResponse?: {
603
+ getStatus?: () => number;
604
+ getHeader?: (header: string) => string | undefined;
605
+ } | null;
606
+ };
607
+
608
+ const getStatus = (error: RetryError) =>
609
+ error.originalResponse?.getStatus &&
610
+ typeof error.originalResponse.getStatus === "function"
611
+ ? error.originalResponse.getStatus()
612
+ : 0;
168
613
 
169
- let uploadUrl: string | null = null;
170
- let rateLimitAttempt = 0;
171
-
172
- const runUpload = () =>
173
- new Promise<void>((resolve, reject) => {
174
- let uploader: Upload;
175
- const uploadOptions: ConstructorParameters<typeof Upload>[1] = {
176
- endpoint: tusUrl,
177
- metadata,
178
- retryDelays,
179
- uploadDataDuringCreation:
180
- options.uploadDataDuringCreation ?? false,
181
- onUploadUrlAvailable: () => {
182
- uploadUrl = uploader.url;
183
- },
184
- onShouldRetry: (error, retryAttempt) =>
185
- options.onShouldRetry?.(error, retryAttempt) ??
186
- shouldRetry(error),
187
- onProgress: (bytesUploaded, bytesTotal) => {
188
- const progress = Math.round((bytesUploaded / bytesTotal) * 100);
189
- setState((prev) => ({ ...prev, progress }));
190
- options.onProgress?.(progress);
191
- },
192
- onError: (error) => {
193
- reject(error);
194
- },
195
- onSuccess: () => {
196
- resolve();
197
- },
198
- };
199
-
200
- if (options.chunkSize !== undefined) {
201
- uploadOptions.chunkSize = options.chunkSize;
202
- }
203
- if (uploadUrl) {
204
- uploadOptions.uploadUrl = uploadUrl;
205
- }
206
- if (options.overridePatchMethod !== undefined) {
207
- uploadOptions.overridePatchMethod = options.overridePatchMethod;
208
- }
209
- if (options.storeFingerprintForResuming !== undefined) {
210
- uploadOptions.storeFingerprintForResuming =
211
- options.storeFingerprintForResuming;
212
- }
213
- if (options.removeFingerprintOnSuccess !== undefined) {
214
- uploadOptions.removeFingerprintOnSuccess =
215
- options.removeFingerprintOnSuccess;
216
- }
217
-
218
- uploader = new Upload(file, uploadOptions);
219
-
220
- uploader.start();
614
+ const retryDelays = options.retryDelays
615
+ ? [...options.retryDelays]
616
+ : [1000, 5000, 15000, 30000];
617
+ const rateLimitRetryDelays = options.rateLimitRetryDelays
618
+ ? [...options.rateLimitRetryDelays]
619
+ : [20_000, 40_000, 80_000];
620
+
621
+ const shouldRetry = (error: RetryError) => {
622
+ const status = getStatus(error);
623
+ if (!status) return true;
624
+ if (status === 409 || status === 423) return true;
625
+ return status < 400 || status >= 500;
626
+ };
627
+
628
+ const perFileBytes = new Map<number, { uploaded: number; total: number }>();
629
+ files.forEach((file, index) => {
630
+ perFileBytes.set(index, { uploaded: 0, total: file.size });
631
+ });
632
+ const updateOverallProgress = () => {
633
+ let totalUploaded = 0;
634
+ let totalBytes = 0;
635
+ for (const { uploaded, total } of perFileBytes.values()) {
636
+ totalUploaded += uploaded;
637
+ totalBytes += total;
638
+ }
639
+ const overall =
640
+ totalBytes > 0 ? Math.round((totalUploaded / totalBytes) * 100) : 0;
641
+ emitState({ isUploading: true, progress: overall, error: null });
642
+ options.onOverallProgress?.(overall);
643
+ };
644
+
645
+ const resolveMetadata = (file: File) =>
646
+ typeof options.metadata === "function"
647
+ ? options.metadata(file)
648
+ : options.metadata;
649
+
650
+ const resolveFieldName = (file: File) =>
651
+ typeof options.fieldName === "function"
652
+ ? options.fieldName(file)
653
+ : options.fieldName;
654
+
655
+ const uploadFile = async (file: File, index: number) => {
656
+ const { endpoint, metadata } = buildTusUploadConfig(assembly.data, file, {
657
+ fieldName: resolveFieldName(file),
658
+ metadata: resolveMetadata(file),
659
+ });
660
+
661
+ let uploadUrl: string | null = null;
662
+ let rateLimitAttempt = 0;
663
+ let uploader: Upload | null = null;
664
+
665
+ const runUpload = () =>
666
+ new Promise<void>((resolve, reject) => {
667
+ if (cancelled) {
668
+ reject(transloaditError("upload", "Upload canceled"));
669
+ return;
670
+ }
671
+ const onAbort = () => {
672
+ reject(transloaditError("upload", "Upload canceled"));
673
+ };
674
+ abortController.signal.addEventListener("abort", onAbort, {
675
+ once: true,
221
676
  });
222
677
 
223
- while (true) {
224
- try {
225
- await runUpload();
226
- break;
227
- } catch (error) {
228
- const status = getStatus(error as RetryError);
229
- if (
230
- status === 429 &&
231
- rateLimitAttempt < rateLimitRetryDelays.length
232
- ) {
233
- const delay = rateLimitRetryDelays[rateLimitAttempt] ?? 0;
234
- rateLimitAttempt += 1;
235
- await new Promise((resolve) => setTimeout(resolve, delay));
236
- continue;
237
- }
678
+ let currentUploader: Upload;
679
+ const uploadOptions: ConstructorParameters<typeof Upload>[1] = {
680
+ endpoint,
681
+ metadata,
682
+ retryDelays,
683
+ uploadDataDuringCreation: options.uploadDataDuringCreation ?? false,
684
+ onUploadUrlAvailable: () => {
685
+ uploadUrl = currentUploader.url;
686
+ },
687
+ onShouldRetry: (error, retryAttempt) =>
688
+ options.onShouldRetry?.(error, retryAttempt) ??
689
+ shouldRetry(error),
690
+ onProgress: (bytesUploaded, bytesTotal) => {
691
+ perFileBytes.set(index, {
692
+ uploaded: bytesUploaded,
693
+ total: bytesTotal,
694
+ });
695
+ const progress = Math.round((bytesUploaded / bytesTotal) * 100);
696
+ options.onFileProgress?.(file, progress);
697
+ updateOverallProgress();
698
+ },
699
+ onError: (error) => {
700
+ abortController.signal.removeEventListener("abort", onAbort);
701
+ reject(error);
702
+ },
703
+ onSuccess: () => {
704
+ abortController.signal.removeEventListener("abort", onAbort);
705
+ resolve();
706
+ },
707
+ };
708
+
709
+ if (options.chunkSize !== undefined) {
710
+ uploadOptions.chunkSize = options.chunkSize;
711
+ }
712
+ if (uploadUrl) {
713
+ uploadOptions.uploadUrl = uploadUrl;
714
+ }
715
+ if (options.overridePatchMethod !== undefined) {
716
+ uploadOptions.overridePatchMethod = options.overridePatchMethod;
717
+ }
718
+ if (options.storeFingerprintForResuming !== undefined) {
719
+ uploadOptions.storeFingerprintForResuming =
720
+ options.storeFingerprintForResuming;
721
+ }
722
+ if (options.removeFingerprintOnSuccess !== undefined) {
723
+ uploadOptions.removeFingerprintOnSuccess =
724
+ options.removeFingerprintOnSuccess;
725
+ }
726
+
727
+ currentUploader = new Upload(file, uploadOptions);
728
+ uploader = currentUploader;
729
+ inFlight.add(currentUploader);
730
+
731
+ currentUploader.start();
732
+ }).finally(() => {
733
+ if (uploader) {
734
+ inFlight.delete(uploader);
735
+ }
736
+ });
737
+
738
+ while (true) {
739
+ try {
740
+ await runUpload();
741
+ break;
742
+ } catch (error) {
743
+ if (cancelled) {
238
744
  throw error;
239
745
  }
746
+ const status = getStatus(error as RetryError);
747
+ if (
748
+ status === 429 &&
749
+ rateLimitAttempt < rateLimitRetryDelays.length
750
+ ) {
751
+ const delay = rateLimitRetryDelays[rateLimitAttempt] ?? 0;
752
+ rateLimitAttempt += 1;
753
+ await new Promise((resolve) => setTimeout(resolve, delay));
754
+ continue;
755
+ }
756
+ throw error;
240
757
  }
758
+ }
759
+ };
241
760
 
242
- setState({ isUploading: false, progress: 100, error: null });
243
- return assembly;
761
+ let nextIndex = 0;
762
+ const errors: Error[] = [];
763
+
764
+ const worker = async () => {
765
+ while (true) {
766
+ if (cancelled) return;
767
+ const index = nextIndex;
768
+ nextIndex += 1;
769
+ if (index >= files.length) return;
770
+ const file = files[index];
771
+ try {
772
+ await uploadFile(file, index);
773
+ results[index] = { file, status: "success" };
774
+ options.onFileComplete?.(file);
775
+ } catch (error) {
776
+ if (cancelled) {
777
+ results[index] = { file, status: "canceled" };
778
+ return;
779
+ }
780
+ const err =
781
+ error instanceof Error
782
+ ? error
783
+ : transloaditError("upload", "Upload failed");
784
+ results[index] = { file, status: "error", error: err };
785
+ errors.push(err);
786
+ options.onFileError?.(file, err);
787
+ if (options.failFast ?? false) {
788
+ cancel();
789
+ return;
790
+ }
791
+ }
792
+ }
793
+ };
794
+
795
+ await Promise.all(
796
+ Array.from({ length: Math.min(concurrency, files.length) }, worker),
797
+ );
798
+
799
+ if (cancelled) {
800
+ const error = transloaditError("upload", "Upload canceled");
801
+ (error as Error & { results?: MultiFileTusUploadResult }).results = {
802
+ assemblyId: assembly.assemblyId,
803
+ data: assembly.data,
804
+ files: results,
805
+ };
806
+ throw error;
807
+ }
808
+
809
+ const hasErrors = results.some((result) => result.status === "error");
810
+ const resultPayload: MultiFileTusUploadResult = {
811
+ assemblyId: assembly.assemblyId,
812
+ data: assembly.data,
813
+ files: results,
814
+ };
815
+
816
+ if (hasErrors) {
817
+ const error = transloaditError(
818
+ "upload",
819
+ `Failed to upload ${errors.length} file${errors.length === 1 ? "" : "s"}`,
820
+ );
821
+ (error as Error & { results?: MultiFileTusUploadResult }).results =
822
+ resultPayload;
823
+ throw error;
824
+ }
825
+
826
+ emitState({ isUploading: false, progress: 100, error: null });
827
+ return resultPayload;
828
+ })();
829
+
830
+ return { promise, cancel };
831
+ }
832
+
833
+ export function useTransloaditUpload(
834
+ options: UseTransloaditUploadOptions,
835
+ ): UseTransloaditUploadResult {
836
+ const create = useAction(options.createAssembly);
837
+ const refresh = useAction(options.refreshAssembly);
838
+ const [state, setState] = useState<UploadState>({
839
+ isUploading: false,
840
+ progress: 0,
841
+ error: null,
842
+ });
843
+ const [assemblyId, setAssemblyId] = useState<string | null>(null);
844
+ const [assemblyData, setAssemblyData] = useState<Record<
845
+ string,
846
+ unknown
847
+ > | null>(null);
848
+ const cancelRef = useRef<(() => void) | null>(null);
849
+
850
+ const upload = useCallback(
851
+ async (
852
+ files: File | File[] | FileList,
853
+ uploadOptions: MultiFileTusUploadOptions,
854
+ ) => {
855
+ const resolved =
856
+ files instanceof FileList
857
+ ? Array.from(files)
858
+ : Array.isArray(files)
859
+ ? files
860
+ : [files];
861
+
862
+ const controller = uploadFilesWithTransloaditTus(create, resolved, {
863
+ ...uploadOptions,
864
+ onStateChange: setState,
865
+ onAssemblyCreated: (assembly) => {
866
+ setAssemblyId(assembly.assemblyId);
867
+ setAssemblyData(assembly.data);
868
+ uploadOptions.onAssemblyCreated?.(assembly);
869
+ },
870
+ });
871
+
872
+ cancelRef.current = controller.cancel;
873
+
874
+ try {
875
+ const result = await controller.promise;
876
+ setAssemblyId(result.assemblyId);
877
+ setAssemblyData(result.data);
878
+ return result;
244
879
  } catch (error) {
245
- const err = error instanceof Error ? error : new Error("Upload failed");
246
- setState({ isUploading: false, progress: 0, error: err });
247
- throw err;
880
+ const resolvedError =
881
+ error instanceof Error
882
+ ? error
883
+ : transloaditError("upload", "Upload failed");
884
+ setState({ isUploading: false, progress: 0, error: resolvedError });
885
+ throw error;
886
+ } finally {
887
+ cancelRef.current = null;
248
888
  }
249
889
  },
250
890
  [create],
251
891
  );
252
892
 
893
+ const cancel = useCallback(() => {
894
+ cancelRef.current?.();
895
+ }, []);
896
+
253
897
  const reset = useCallback(() => {
898
+ cancelRef.current?.();
899
+ cancelRef.current = null;
900
+ setAssemblyId(null);
901
+ setAssemblyData(null);
254
902
  setState({ isUploading: false, progress: 0, error: null });
255
903
  }, []);
256
904
 
257
- return useMemo(
258
- () => ({
259
- upload,
260
- reset,
261
- isUploading: state.isUploading,
262
- progress: state.progress,
263
- error: state.error,
264
- }),
265
- [state.error, state.isUploading, state.progress, upload, reset],
905
+ const assembly = useQuery(
906
+ options.getStatus,
907
+ assemblyId ? { assemblyId } : "skip",
908
+ );
909
+
910
+ const parsedStatus = useMemo(() => {
911
+ const candidate =
912
+ assembly && typeof assembly === "object"
913
+ ? ((assembly as { raw?: unknown }).raw ?? assembly)
914
+ : assembly;
915
+ return parseAssemblyStatus(candidate);
916
+ }, [assembly]);
917
+
918
+ const results = useQuery(
919
+ options.listResults,
920
+ assemblyId ? { assemblyId } : "skip",
921
+ );
922
+
923
+ useAssemblyPoller({
924
+ assemblyId,
925
+ status: parsedStatus,
926
+ refresh: async () => {
927
+ if (!assemblyId) return;
928
+ await refresh({ assemblyId });
929
+ },
930
+ intervalMs: options.pollIntervalMs ?? 5000,
931
+ shouldContinue: options.shouldContinue,
932
+ onError: options.onError,
933
+ });
934
+
935
+ return {
936
+ upload,
937
+ cancel,
938
+ reset,
939
+ isUploading: state.isUploading,
940
+ progress: state.progress,
941
+ error: state.error,
942
+ assemblyId,
943
+ assemblyData,
944
+ assembly,
945
+ status: parsedStatus,
946
+ results,
947
+ };
948
+ }
949
+
950
+ export function useTransloaditUppy<
951
+ TArgs extends { fileCount: number },
952
+ TAssembly extends { assemblyId: string; data: Record<string, unknown> },
953
+ >(
954
+ options: UseTransloaditUppyOptions<TArgs, TAssembly>,
955
+ ): UseTransloaditUppyResult<TArgs, TAssembly> {
956
+ const create = useAction(options.createAssembly) as unknown as (
957
+ args: TArgs,
958
+ ) => Promise<TAssembly>;
959
+ const refresh = useAction(options.refreshAssembly);
960
+ const [isUploading, setIsUploading] = useState(false);
961
+ const [error, setError] = useState<Error | null>(null);
962
+ const [assemblyId, setAssemblyId] = useState<string | null>(null);
963
+ const [assemblyData, setAssemblyData] = useState<Record<
964
+ string,
965
+ unknown
966
+ > | null>(null);
967
+ const [uploadResult, setUploadResult] = useState<UppyUploadResult | null>(
968
+ null,
266
969
  );
970
+
971
+ const assembly = useQuery(
972
+ options.getStatus,
973
+ assemblyId ? { assemblyId } : "skip",
974
+ );
975
+ const results = useQuery(
976
+ options.listResults,
977
+ assemblyId ? { assemblyId } : "skip",
978
+ );
979
+ const parsedStatus = useMemo(() => {
980
+ const candidate =
981
+ assembly && typeof assembly === "object"
982
+ ? ((assembly as { raw?: unknown }).raw ?? assembly)
983
+ : assembly;
984
+ return parseAssemblyStatus(candidate);
985
+ }, [assembly]);
986
+
987
+ useAssemblyPoller({
988
+ assemblyId,
989
+ status: parsedStatus,
990
+ refresh: async () => {
991
+ if (!assemblyId) return;
992
+ await refresh({ assemblyId });
993
+ },
994
+ intervalMs: options.pollIntervalMs ?? 5000,
995
+ shouldContinue: options.shouldContinue,
996
+ onError: options.onError,
997
+ });
998
+
999
+ const startUpload = useCallback(
1000
+ async (overrides?: Partial<UploadWithAssemblyOptions<TArgs>>) => {
1001
+ setError(null);
1002
+ setIsUploading(true);
1003
+
1004
+ try {
1005
+ const files = options.uppy.getFiles();
1006
+ if (files.length === 0) {
1007
+ throw transloaditError("upload", "No files provided for upload");
1008
+ }
1009
+
1010
+ const createAssemblyArgs = {
1011
+ ...(options.createAssemblyArgs ?? {}),
1012
+ ...(overrides?.createAssemblyArgs ?? {}),
1013
+ } as TArgs;
1014
+
1015
+ const { assembly, uploadResult: result } = await uploadWithAssembly<
1016
+ TArgs,
1017
+ TAssembly
1018
+ >(create, options.uppy, {
1019
+ fileCount: overrides?.fileCount ?? options.fileCount ?? files.length,
1020
+ fieldName: overrides?.fieldName ?? options.fieldName,
1021
+ metadata: overrides?.metadata ?? options.metadata,
1022
+ addRequestId: overrides?.addRequestId ?? options.addRequestId,
1023
+ createAssemblyArgs,
1024
+ onAssemblyCreated: (created) => {
1025
+ const typed = created as TAssembly;
1026
+ setAssemblyId(typed.assemblyId);
1027
+ setAssemblyData(typed.data);
1028
+ options.onAssemblyCreated?.(typed);
1029
+ overrides?.onAssemblyCreated?.(created);
1030
+ },
1031
+ });
1032
+
1033
+ setAssemblyId(assembly.assemblyId);
1034
+ setAssemblyData(assembly.data);
1035
+ setUploadResult(result);
1036
+ options.onUploadResult?.(result);
1037
+ setIsUploading(false);
1038
+ return { assembly, uploadResult: result };
1039
+ } catch (err) {
1040
+ const resolved =
1041
+ err instanceof Error
1042
+ ? err
1043
+ : transloaditError("upload", "Upload failed");
1044
+ setError(resolved);
1045
+ setIsUploading(false);
1046
+ throw resolved;
1047
+ }
1048
+ },
1049
+ [
1050
+ create,
1051
+ options.addRequestId,
1052
+ options.createAssemblyArgs,
1053
+ options.fieldName,
1054
+ options.fileCount,
1055
+ options.metadata,
1056
+ options.onAssemblyCreated,
1057
+ options.onUploadResult,
1058
+ options.uppy,
1059
+ ],
1060
+ );
1061
+
1062
+ const reset = useCallback(() => {
1063
+ setIsUploading(false);
1064
+ setError(null);
1065
+ setAssemblyId(null);
1066
+ setAssemblyData(null);
1067
+ setUploadResult(null);
1068
+ }, []);
1069
+
1070
+ const stage = useMemo(() => {
1071
+ if (error) return "error";
1072
+ if (isUploading) return "uploading";
1073
+ return parsedStatus ? getAssemblyStage(parsedStatus) : null;
1074
+ }, [error, isUploading, parsedStatus]);
1075
+
1076
+ return {
1077
+ startUpload,
1078
+ reset,
1079
+ isUploading,
1080
+ error,
1081
+ assemblyId,
1082
+ assemblyData,
1083
+ assembly,
1084
+ status: parsedStatus,
1085
+ results,
1086
+ stage,
1087
+ uploadResult,
1088
+ };
267
1089
  }
268
1090
 
269
1091
  export function useAssemblyStatus(
@@ -277,56 +1099,144 @@ export function useAssemblyStatusWithPolling(
277
1099
  getStatus: GetAssemblyStatusFn,
278
1100
  refreshAssembly: RefreshAssemblyFn,
279
1101
  assemblyId: string,
280
- options?: { pollIntervalMs?: number; stopOnTerminal?: boolean },
1102
+ options?: {
1103
+ pollIntervalMs?: number;
1104
+ stopOnTerminal?: boolean;
1105
+ shouldContinue?: () => boolean;
1106
+ onError?: (error: Error) => void;
1107
+ },
281
1108
  ) {
282
1109
  const status = useQuery(getStatus, { assemblyId });
283
1110
  const refresh = useAction(refreshAssembly);
1111
+ const statusRef = useRef(status);
1112
+ const shouldContinueRef = useRef(options?.shouldContinue);
1113
+ const onErrorRef = useRef(options?.onError);
1114
+
1115
+ useEffect(() => {
1116
+ statusRef.current = status;
1117
+ }, [status]);
1118
+
1119
+ useEffect(() => {
1120
+ shouldContinueRef.current = options?.shouldContinue;
1121
+ }, [options?.shouldContinue]);
1122
+
1123
+ useEffect(() => {
1124
+ onErrorRef.current = options?.onError;
1125
+ }, [options?.onError]);
284
1126
 
285
1127
  useEffect(() => {
286
1128
  if (!assemblyId) return;
287
1129
  const intervalMs = options?.pollIntervalMs ?? 5000;
288
1130
  if (intervalMs <= 0) return;
289
1131
 
290
- const isTerminal = () => {
291
- if (!options?.stopOnTerminal) return false;
292
- if (!status || typeof status !== "object") return false;
293
- const ok =
294
- "ok" in status && typeof status.ok === "string" ? status.ok : "";
295
- return (
296
- ok === "ASSEMBLY_COMPLETED" ||
297
- ok === "ASSEMBLY_FAILED" ||
298
- ok === "ASSEMBLY_CANCELED"
299
- );
1132
+ const shouldKeepPolling = () => {
1133
+ const shouldContinue = shouldContinueRef.current?.();
1134
+ if (shouldContinue === false) return false;
1135
+ if (!options?.stopOnTerminal) return true;
1136
+ const current = statusRef.current;
1137
+ const rawCandidate =
1138
+ current && typeof current === "object"
1139
+ ? ((current as { raw?: unknown }).raw ?? current)
1140
+ : current;
1141
+ const parsed = parseAssemblyStatus(rawCandidate);
1142
+ return !(parsed ? isAssemblyTerminal(parsed) : false);
300
1143
  };
301
1144
 
302
- if (isTerminal()) return;
1145
+ if (!shouldKeepPolling()) return;
303
1146
 
304
1147
  let cancelled = false;
1148
+ let intervalId: ReturnType<typeof setInterval> | null = null;
1149
+ let inFlight = false;
305
1150
  const tick = async () => {
306
1151
  if (cancelled) return;
307
- await refresh({ assemblyId });
1152
+ if (!shouldKeepPolling()) {
1153
+ if (intervalId) clearInterval(intervalId);
1154
+ cancelled = true;
1155
+ return;
1156
+ }
1157
+ if (inFlight) return;
1158
+ inFlight = true;
1159
+ try {
1160
+ await refresh({ assemblyId });
1161
+ } catch (error) {
1162
+ const resolved =
1163
+ error instanceof Error
1164
+ ? error
1165
+ : transloaditError("polling", "Refresh failed");
1166
+ onErrorRef.current?.(resolved);
1167
+ } finally {
1168
+ inFlight = false;
1169
+ }
308
1170
  };
309
1171
 
310
- void tick();
311
- const id = setInterval(() => {
1172
+ intervalId = setInterval(() => {
312
1173
  void tick();
313
1174
  }, intervalMs);
1175
+ void tick();
314
1176
 
315
1177
  return () => {
316
1178
  cancelled = true;
317
- clearInterval(id);
1179
+ if (intervalId) clearInterval(intervalId);
318
1180
  };
319
- }, [
320
- assemblyId,
321
- options?.pollIntervalMs,
322
- options?.stopOnTerminal,
323
- refresh,
324
- status,
325
- ]);
1181
+ }, [assemblyId, options?.pollIntervalMs, options?.stopOnTerminal, refresh]);
326
1182
 
327
1183
  return status;
328
1184
  }
329
1185
 
1186
+ /**
1187
+ * @deprecated Prefer `useAssemblyStatusWithPolling` for public usage.
1188
+ */
1189
+ export function useAssemblyPoller(options: {
1190
+ assemblyId: string | null;
1191
+ status: AssemblyStatus | null | undefined;
1192
+ refresh: () => Promise<void>;
1193
+ intervalMs: number;
1194
+ shouldContinue?: () => boolean;
1195
+ onError?: (error: Error) => void;
1196
+ }) {
1197
+ const refreshRef = useRef(options.refresh);
1198
+ const onErrorRef = useRef(options.onError);
1199
+ const shouldContinueRef = useRef(options.shouldContinue);
1200
+ const statusRef = useRef(options.status);
1201
+
1202
+ useEffect(() => {
1203
+ refreshRef.current = options.refresh;
1204
+ }, [options.refresh]);
1205
+
1206
+ useEffect(() => {
1207
+ onErrorRef.current = options.onError;
1208
+ }, [options.onError]);
1209
+
1210
+ useEffect(() => {
1211
+ shouldContinueRef.current = options.shouldContinue;
1212
+ }, [options.shouldContinue]);
1213
+
1214
+ useEffect(() => {
1215
+ statusRef.current = options.status;
1216
+ }, [options.status]);
1217
+
1218
+ useEffect(() => {
1219
+ if (!options.assemblyId) return;
1220
+
1221
+ const controller = pollAssembly({
1222
+ intervalMs: options.intervalMs,
1223
+ refresh: () => refreshRef.current(),
1224
+ shouldContinue: () => shouldContinueRef.current?.() ?? false,
1225
+ isTerminal: () => {
1226
+ const current = statusRef.current;
1227
+ return current ? isAssemblyTerminal(current) : false;
1228
+ },
1229
+ onError: (error) => {
1230
+ onErrorRef.current?.(error);
1231
+ },
1232
+ });
1233
+
1234
+ return () => {
1235
+ controller.stop();
1236
+ };
1237
+ }, [options.assemblyId, options.intervalMs]);
1238
+ }
1239
+
330
1240
  export function useTransloaditFiles(
331
1241
  listResults: ListResultsFn,
332
1242
  args: { assemblyId: string; stepName?: string; limit?: number },