@transloadit/convex 0.0.2 → 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 (87) hide show
  1. package/README.md +184 -121
  2. package/dist/client/index.d.ts +100 -60
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +69 -31
  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 +35 -15
  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 +36 -7
  12. package/dist/component/apiUtils.d.ts.map +1 -1
  13. package/dist/component/apiUtils.js +60 -40
  14. package/dist/component/apiUtils.js.map +1 -1
  15. package/dist/component/lib.d.ts +71 -49
  16. package/dist/component/lib.d.ts.map +1 -1
  17. package/dist/component/lib.js +206 -73
  18. package/dist/component/lib.js.map +1 -1
  19. package/dist/component/schema.d.ts +11 -13
  20. package/dist/component/schema.d.ts.map +1 -1
  21. package/dist/component/schema.js +3 -10
  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 +213 -17
  28. package/dist/react/index.d.ts.map +1 -1
  29. package/dist/react/index.js +726 -105
  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 +65 -0
  56. package/dist/test/index.d.ts.map +1 -0
  57. package/dist/test/index.js +8 -0
  58. package/dist/test/index.js.map +1 -0
  59. package/dist/test/nodeModules.d.ts +2 -0
  60. package/dist/test/nodeModules.d.ts.map +1 -0
  61. package/dist/test/nodeModules.js +19 -0
  62. package/dist/test/nodeModules.js.map +1 -0
  63. package/package.json +53 -15
  64. package/src/client/index.ts +141 -38
  65. package/src/component/_generated/api.ts +2 -2
  66. package/src/component/_generated/component.ts +44 -13
  67. package/src/component/_generated/dataModel.ts +1 -1
  68. package/src/component/_generated/server.ts +1 -1
  69. package/src/component/apiUtils.test.ts +195 -2
  70. package/src/component/apiUtils.ts +124 -66
  71. package/src/component/lib.test.ts +243 -7
  72. package/src/component/lib.ts +302 -90
  73. package/src/component/schema.ts +3 -13
  74. package/src/debug/index.ts +84 -0
  75. package/src/react/index.test.tsx +340 -0
  76. package/src/react/index.tsx +1105 -152
  77. package/src/react/uploadWithTus.test.tsx +192 -0
  78. package/src/shared/assemblyUrls.test.ts +71 -0
  79. package/src/shared/assemblyUrls.ts +59 -0
  80. package/src/shared/errors.ts +23 -0
  81. package/src/shared/pollAssembly.ts +65 -0
  82. package/src/shared/resultTypes.ts +44 -0
  83. package/src/shared/resultUtils.test.ts +29 -0
  84. package/src/shared/resultUtils.ts +71 -0
  85. package/src/shared/tusUpload.ts +59 -0
  86. package/src/test/index.ts +10 -0
  87. package/src/test/nodeModules.ts +19 -0
@@ -1,23 +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, useMemo, useState } from "react";
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
8
  import { Upload } from "tus-js-client";
5
-
6
- export type GenerateUploadParamsFn = FunctionReference<
7
- "action",
8
- "public",
9
- {
10
- templateId?: string;
11
- steps?: unknown;
12
- fields?: unknown;
13
- notifyUrl?: string;
14
- numExpectedUploadFiles?: number;
15
- expires?: string;
16
- additionalParams?: unknown;
17
- userId?: string;
18
- },
19
- { params: string; signature: string; url: string }
20
- >;
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";
21
17
 
22
18
  export type CreateAssemblyFn = FunctionReference<
23
19
  "action",
@@ -35,6 +31,26 @@ export type CreateAssemblyFn = FunctionReference<
35
31
  { assemblyId: string; data: Record<string, unknown> }
36
32
  >;
37
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
+
38
54
  export type GetAssemblyStatusFn = FunctionReference<
39
55
  "query",
40
56
  "public",
@@ -49,6 +65,13 @@ export type ListResultsFn = FunctionReference<
49
65
  Array<unknown>
50
66
  >;
51
67
 
68
+ export type RefreshAssemblyFn = FunctionReference<
69
+ "action",
70
+ "public",
71
+ { assemblyId: string },
72
+ { assemblyId: string; ok?: string; status?: string; resultCount: number }
73
+ >;
74
+
52
75
  export interface UploadOptions {
53
76
  templateId?: string;
54
77
  steps?: Record<string, unknown>;
@@ -66,70 +89,415 @@ export interface UploadState {
66
89
  error: Error | null;
67
90
  }
68
91
 
69
- export interface FormUploadOptions extends UploadOptions {
70
- fileField?: string;
71
- onProgress?: (progress: number) => void;
72
- }
73
-
74
92
  export interface TusUploadOptions extends UploadOptions {
75
93
  metadata?: Record<string, string>;
94
+ fieldName?: string;
76
95
  chunkSize?: number;
77
96
  retryDelays?: number[];
97
+ onShouldRetry?: (error: unknown, retryAttempt: number) => boolean;
98
+ rateLimitRetryDelays?: number[];
99
+ overridePatchMethod?: boolean;
100
+ uploadDataDuringCreation?: boolean;
101
+ storeFingerprintForResuming?: boolean;
102
+ removeFingerprintOnSuccess?: boolean;
78
103
  onProgress?: (progress: number) => void;
104
+ onAssemblyCreated?: (assembly: {
105
+ assemblyId: string;
106
+ data: Record<string, unknown>;
107
+ }) => void;
108
+ }
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 };
79
343
  }
80
344
 
81
- async function uploadViaForm(
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,
82
353
  file: File,
83
- params: { params: string; signature: string; url: string },
84
- options: FormUploadOptions,
85
- ): Promise<Record<string, unknown>> {
86
- const formData = new FormData();
87
- formData.append("params", params.params);
88
- formData.append("signature", params.signature);
89
- formData.append(options.fileField ?? "file", file);
90
-
91
- return new Promise((resolve, reject) => {
92
- const xhr = new XMLHttpRequest();
93
- xhr.open("POST", params.url, true);
94
-
95
- xhr.upload.onprogress = (event) => {
96
- if (!event.lengthComputable) return;
97
- const progress = Math.round((event.loaded / event.total) * 100);
98
- options.onProgress?.(progress);
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;
99
394
  };
100
395
 
101
- xhr.onload = () => {
102
- try {
103
- const response = JSON.parse(xhr.responseText) as Record<
104
- string,
105
- unknown
106
- >;
107
- if (xhr.status >= 200 && xhr.status < 300) {
108
- resolve(response);
109
- } else {
110
- reject(
111
- new Error(
112
- `Transloadit upload failed (${xhr.status}): ${JSON.stringify(response)}`,
113
- ),
114
- );
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;
115
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;
116
472
  } catch (error) {
117
- reject(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;
118
481
  }
119
- };
120
-
121
- xhr.onerror = () => {
122
- reject(new Error("Transloadit upload failed"));
123
- };
482
+ }
124
483
 
125
- xhr.send(formData);
126
- });
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
+ }
127
494
  }
128
495
 
129
- export function useTransloaditUpload(
130
- generateUploadParams: GenerateUploadParamsFn,
131
- ) {
132
- const generate = useAction(generateUploadParams);
496
+ /**
497
+ * @deprecated Prefer `useTransloaditUpload` (single + multi-file) for new code.
498
+ */
499
+ export function useTransloaditTusUpload(createAssembly: CreateAssemblyFn) {
500
+ const create = useAction(createAssembly);
133
501
  const [state, setState] = useState<UploadState>({
134
502
  isUploading: false,
135
503
  progress: 0,
@@ -137,37 +505,11 @@ export function useTransloaditUpload(
137
505
  });
138
506
 
139
507
  const upload = useCallback(
140
- async (file: File, options: FormUploadOptions) => {
141
- setState({ isUploading: true, progress: 0, error: null });
142
- try {
143
- const params = await generate({
144
- templateId: options.templateId,
145
- steps: options.steps,
146
- fields: options.fields,
147
- notifyUrl: options.notifyUrl,
148
- numExpectedUploadFiles: options.numExpectedUploadFiles,
149
- expires: options.expires,
150
- additionalParams: options.additionalParams,
151
- userId: options.userId,
152
- });
153
-
154
- const response = await uploadViaForm(file, params, {
155
- ...options,
156
- onProgress: (progress) => {
157
- setState((prev) => ({ ...prev, progress }));
158
- options.onProgress?.(progress);
159
- },
160
- });
161
-
162
- setState({ isUploading: false, progress: 100, error: null });
163
- return response;
164
- } catch (error) {
165
- const err = error instanceof Error ? error : new Error("Upload failed");
166
- setState({ isUploading: false, progress: 0, error: err });
167
- throw err;
168
- }
169
- },
170
- [generate],
508
+ async (file: File, options: TusUploadOptions) =>
509
+ uploadWithTransloaditTus(create, file, options, {
510
+ onStateChange: setState,
511
+ }),
512
+ [create],
171
513
  );
172
514
 
173
515
  const reset = useCallback(() => {
@@ -186,95 +528,564 @@ export function useTransloaditUpload(
186
528
  );
187
529
  }
188
530
 
189
- export function useTransloaditTusUpload(createAssembly: CreateAssemblyFn) {
190
- const create = useAction(createAssembly);
191
- const [state, setState] = useState<UploadState>({
192
- isUploading: false,
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,
193
542
  progress: 0,
194
543
  error: null,
195
- });
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;
196
552
 
197
- const upload = useCallback(
198
- async (file: File, options: TusUploadOptions) => {
199
- setState({ isUploading: true, progress: 0, error: null });
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
+ };
200
559
 
560
+ const cancel = () => {
561
+ if (cancelled) return;
562
+ cancelled = true;
563
+ abortController.abort();
564
+ for (const uploader of inFlight) {
201
565
  try {
202
- const assembly = await create({
203
- templateId: options.templateId,
204
- steps: options.steps,
205
- fields: options.fields,
206
- notifyUrl: options.notifyUrl,
207
- numExpectedUploadFiles: options.numExpectedUploadFiles ?? 1,
208
- expires: options.expires,
209
- additionalParams: options.additionalParams,
210
- userId: options.userId,
211
- });
566
+ uploader.abort(true);
567
+ } catch {
568
+ // ignore abort errors
569
+ }
570
+ }
571
+ };
212
572
 
213
- const data = assembly.data as Record<string, unknown>;
214
- const tusUrl =
215
- (typeof data.tus_url === "string" && data.tus_url) ||
216
- (typeof data.tusUrl === "string" && data.tusUrl) ||
217
- "";
573
+ if (options.signal) {
574
+ if (options.signal.aborted) {
575
+ cancel();
576
+ } else {
577
+ options.signal.addEventListener("abort", cancel, { once: true });
578
+ }
579
+ }
218
580
 
219
- if (!tusUrl) {
220
- throw new Error(
221
- "Transloadit response missing tus_url for resumable upload",
222
- );
223
- }
581
+ const promise = (async () => {
582
+ if (files.length === 0) {
583
+ throw transloaditError("upload", "No files provided for upload");
584
+ }
224
585
 
225
- const metadata: Record<string, string> = {
226
- filename: file.name,
227
- filetype: file.type,
228
- ...options.metadata,
229
- };
586
+ emitState({ ...state });
587
+
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
+ });
598
+
599
+ options.onAssemblyCreated?.(assembly);
600
+
601
+ type RetryError = {
602
+ originalResponse?: {
603
+ getStatus?: () => number;
604
+ getHeader?: (header: string) => string | undefined;
605
+ } | null;
606
+ };
230
607
 
231
- await new Promise<void>((resolve, reject) => {
232
- const uploader = new Upload(file, {
233
- endpoint: tusUrl,
608
+ const getStatus = (error: RetryError) =>
609
+ error.originalResponse?.getStatus &&
610
+ typeof error.originalResponse.getStatus === "function"
611
+ ? error.originalResponse.getStatus()
612
+ : 0;
613
+
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,
676
+ });
677
+
678
+ let currentUploader: Upload;
679
+ const uploadOptions: ConstructorParameters<typeof Upload>[1] = {
680
+ endpoint,
234
681
  metadata,
235
- chunkSize: options.chunkSize,
236
- retryDelays: options.retryDelays ?? [0, 3000, 5000, 10000],
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),
237
690
  onProgress: (bytesUploaded, bytesTotal) => {
691
+ perFileBytes.set(index, {
692
+ uploaded: bytesUploaded,
693
+ total: bytesTotal,
694
+ });
238
695
  const progress = Math.round((bytesUploaded / bytesTotal) * 100);
239
- setState((prev) => ({ ...prev, progress }));
240
- options.onProgress?.(progress);
696
+ options.onFileProgress?.(file, progress);
697
+ updateOverallProgress();
241
698
  },
242
699
  onError: (error) => {
700
+ abortController.signal.removeEventListener("abort", onAbort);
243
701
  reject(error);
244
702
  },
245
703
  onSuccess: () => {
704
+ abortController.signal.removeEventListener("abort", onAbort);
246
705
  resolve();
247
706
  },
248
- });
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
+ }
249
726
 
250
- uploader.start();
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
+ }
251
736
  });
252
737
 
253
- setState({ isUploading: false, progress: 100, error: null });
254
- return assembly;
738
+ while (true) {
739
+ try {
740
+ await runUpload();
741
+ break;
742
+ } catch (error) {
743
+ if (cancelled) {
744
+ throw error;
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;
757
+ }
758
+ }
759
+ };
760
+
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;
255
879
  } catch (error) {
256
- const err = error instanceof Error ? error : new Error("Upload failed");
257
- setState({ isUploading: false, progress: 0, error: err });
258
- 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;
259
888
  }
260
889
  },
261
890
  [create],
262
891
  );
263
892
 
893
+ const cancel = useCallback(() => {
894
+ cancelRef.current?.();
895
+ }, []);
896
+
264
897
  const reset = useCallback(() => {
898
+ cancelRef.current?.();
899
+ cancelRef.current = null;
900
+ setAssemblyId(null);
901
+ setAssemblyData(null);
265
902
  setState({ isUploading: false, progress: 0, error: null });
266
903
  }, []);
267
904
 
268
- return useMemo(
269
- () => ({
270
- upload,
271
- reset,
272
- isUploading: state.isUploading,
273
- progress: state.progress,
274
- error: state.error,
275
- }),
276
- [state.error, state.isUploading, state.progress, upload, reset],
905
+ const assembly = useQuery(
906
+ options.getStatus,
907
+ assemblyId ? { assemblyId } : "skip",
277
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,
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
+ };
278
1089
  }
279
1090
 
280
1091
  export function useAssemblyStatus(
@@ -284,6 +1095,148 @@ export function useAssemblyStatus(
284
1095
  return useQuery(getStatus, { assemblyId });
285
1096
  }
286
1097
 
1098
+ export function useAssemblyStatusWithPolling(
1099
+ getStatus: GetAssemblyStatusFn,
1100
+ refreshAssembly: RefreshAssemblyFn,
1101
+ assemblyId: string,
1102
+ options?: {
1103
+ pollIntervalMs?: number;
1104
+ stopOnTerminal?: boolean;
1105
+ shouldContinue?: () => boolean;
1106
+ onError?: (error: Error) => void;
1107
+ },
1108
+ ) {
1109
+ const status = useQuery(getStatus, { assemblyId });
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]);
1126
+
1127
+ useEffect(() => {
1128
+ if (!assemblyId) return;
1129
+ const intervalMs = options?.pollIntervalMs ?? 5000;
1130
+ if (intervalMs <= 0) return;
1131
+
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);
1143
+ };
1144
+
1145
+ if (!shouldKeepPolling()) return;
1146
+
1147
+ let cancelled = false;
1148
+ let intervalId: ReturnType<typeof setInterval> | null = null;
1149
+ let inFlight = false;
1150
+ const tick = async () => {
1151
+ if (cancelled) return;
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
+ }
1170
+ };
1171
+
1172
+ intervalId = setInterval(() => {
1173
+ void tick();
1174
+ }, intervalMs);
1175
+ void tick();
1176
+
1177
+ return () => {
1178
+ cancelled = true;
1179
+ if (intervalId) clearInterval(intervalId);
1180
+ };
1181
+ }, [assemblyId, options?.pollIntervalMs, options?.stopOnTerminal, refresh]);
1182
+
1183
+ return status;
1184
+ }
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
+
287
1240
  export function useTransloaditFiles(
288
1241
  listResults: ListResultsFn,
289
1242
  args: { assemblyId: string; stepName?: string; limit?: number },