@transloadit/convex 0.0.1 → 0.0.3

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 (38) hide show
  1. package/README.md +162 -79
  2. package/dist/client/index.d.ts +92 -63
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +57 -30
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/client/types.d.ts.map +1 -1
  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/apiUtils.d.ts +13 -4
  10. package/dist/component/apiUtils.d.ts.map +1 -1
  11. package/dist/component/apiUtils.js +22 -12
  12. package/dist/component/apiUtils.js.map +1 -1
  13. package/dist/component/lib.d.ts +69 -45
  14. package/dist/component/lib.d.ts.map +1 -1
  15. package/dist/component/lib.js +154 -77
  16. package/dist/component/lib.js.map +1 -1
  17. package/dist/component/schema.d.ts +9 -9
  18. package/dist/component/schema.js +3 -3
  19. package/dist/component/schema.js.map +1 -1
  20. package/dist/react/index.d.ts +23 -25
  21. package/dist/react/index.d.ts.map +1 -1
  22. package/dist/react/index.js +129 -88
  23. package/dist/react/index.js.map +1 -1
  24. package/dist/test/index.d.ts +65 -0
  25. package/dist/test/index.d.ts.map +1 -0
  26. package/dist/test/index.js +8 -0
  27. package/dist/test/index.js.map +1 -0
  28. package/package.json +27 -15
  29. package/src/client/index.ts +72 -35
  30. package/src/client/types.ts +12 -11
  31. package/src/component/_generated/component.ts +44 -13
  32. package/src/component/apiUtils.test.ts +29 -0
  33. package/src/component/apiUtils.ts +52 -26
  34. package/src/component/lib.test.ts +73 -3
  35. package/src/component/lib.ts +220 -97
  36. package/src/component/schema.ts +3 -3
  37. package/src/react/index.tsx +193 -150
  38. package/src/test/index.ts +10 -0
@@ -1,24 +1,8 @@
1
1
  import { useAction, useQuery } from "convex/react";
2
2
  import type { FunctionReference } from "convex/server";
3
- import { useCallback, useMemo, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import { Upload } from "tus-js-client";
5
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
- >;
21
-
22
6
  export type CreateAssemblyFn = FunctionReference<
23
7
  "action",
24
8
  "public",
@@ -49,6 +33,13 @@ export type ListResultsFn = FunctionReference<
49
33
  Array<unknown>
50
34
  >;
51
35
 
36
+ export type RefreshAssemblyFn = FunctionReference<
37
+ "action",
38
+ "public",
39
+ { assemblyId: string },
40
+ { assemblyId: string; ok?: string; status?: string; resultCount: number }
41
+ >;
42
+
52
43
  export interface UploadOptions {
53
44
  templateId?: string;
54
45
  steps?: Record<string, unknown>;
@@ -66,124 +57,22 @@ export interface UploadState {
66
57
  error: Error | null;
67
58
  }
68
59
 
69
- export interface FormUploadOptions extends UploadOptions {
70
- fileField?: string;
71
- onProgress?: (progress: number) => void;
72
- }
73
-
74
60
  export interface TusUploadOptions extends UploadOptions {
75
61
  metadata?: Record<string, string>;
62
+ fieldName?: string;
76
63
  chunkSize?: number;
77
64
  retryDelays?: number[];
65
+ onShouldRetry?: (error: unknown, retryAttempt: number) => boolean;
66
+ rateLimitRetryDelays?: number[];
67
+ overridePatchMethod?: boolean;
68
+ uploadDataDuringCreation?: boolean;
69
+ storeFingerprintForResuming?: boolean;
70
+ removeFingerprintOnSuccess?: boolean;
78
71
  onProgress?: (progress: number) => void;
79
- }
80
-
81
- async function uploadViaForm(
82
- 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);
99
- };
100
-
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
- );
115
- }
116
- } catch (error) {
117
- reject(error);
118
- }
119
- };
120
-
121
- xhr.onerror = () => {
122
- reject(new Error("Transloadit upload failed"));
123
- };
124
-
125
- xhr.send(formData);
126
- });
127
- }
128
-
129
- export function useTransloaditUpload(
130
- generateUploadParams: GenerateUploadParamsFn,
131
- ) {
132
- const generate = useAction(generateUploadParams);
133
- const [state, setState] = useState<UploadState>({
134
- isUploading: false,
135
- progress: 0,
136
- error: null,
137
- });
138
-
139
- 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],
171
- );
172
-
173
- const reset = useCallback(() => {
174
- setState({ isUploading: false, progress: 0, error: null });
175
- }, []);
176
-
177
- return useMemo(
178
- () => ({
179
- upload,
180
- reset,
181
- isUploading: state.isUploading,
182
- progress: state.progress,
183
- error: state.error,
184
- }),
185
- [state.error, state.isUploading, state.progress, upload, reset],
186
- );
72
+ onAssemblyCreated?: (assembly: {
73
+ assemblyId: string;
74
+ data: Record<string, unknown>;
75
+ }) => void;
187
76
  }
188
77
 
189
78
  export function useTransloaditTusUpload(createAssembly: CreateAssemblyFn) {
@@ -211,6 +100,7 @@ export function useTransloaditTusUpload(createAssembly: CreateAssemblyFn) {
211
100
  });
212
101
 
213
102
  const data = assembly.data as Record<string, unknown>;
103
+ options.onAssemblyCreated?.(assembly);
214
104
  const tusUrl =
215
105
  (typeof data.tus_url === "string" && data.tus_url) ||
216
106
  (typeof data.tusUrl === "string" && data.tusUrl) ||
@@ -222,33 +112,132 @@ export function useTransloaditTusUpload(createAssembly: CreateAssemblyFn) {
222
112
  );
223
113
  }
224
114
 
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
+ }
127
+
225
128
  const metadata: Record<string, string> = {
226
129
  filename: file.name,
227
- filetype: file.type,
228
130
  ...options.metadata,
229
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
+ }
141
+
142
+ type RetryError = {
143
+ originalResponse?: {
144
+ getStatus?: () => number;
145
+ getHeader?: (header: string) => string | undefined;
146
+ } | null;
147
+ };
230
148
 
231
- await new Promise<void>((resolve, reject) => {
232
- const uploader = new Upload(file, {
233
- endpoint: tusUrl,
234
- metadata,
235
- chunkSize: options.chunkSize,
236
- retryDelays: options.retryDelays ?? [0, 3000, 5000, 10000],
237
- onProgress: (bytesUploaded, bytesTotal) => {
238
- const progress = Math.round((bytesUploaded / bytesTotal) * 100);
239
- setState((prev) => ({ ...prev, progress }));
240
- options.onProgress?.(progress);
241
- },
242
- onError: (error) => {
243
- reject(error);
244
- },
245
- onSuccess: () => {
246
- resolve();
247
- },
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
+ };
168
+
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();
248
221
  });
249
222
 
250
- uploader.start();
251
- });
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
+ }
238
+ throw error;
239
+ }
240
+ }
252
241
 
253
242
  setState({ isUploading: false, progress: 100, error: null });
254
243
  return assembly;
@@ -284,6 +273,60 @@ export function useAssemblyStatus(
284
273
  return useQuery(getStatus, { assemblyId });
285
274
  }
286
275
 
276
+ export function useAssemblyStatusWithPolling(
277
+ getStatus: GetAssemblyStatusFn,
278
+ refreshAssembly: RefreshAssemblyFn,
279
+ assemblyId: string,
280
+ options?: { pollIntervalMs?: number; stopOnTerminal?: boolean },
281
+ ) {
282
+ const status = useQuery(getStatus, { assemblyId });
283
+ const refresh = useAction(refreshAssembly);
284
+
285
+ useEffect(() => {
286
+ if (!assemblyId) return;
287
+ const intervalMs = options?.pollIntervalMs ?? 5000;
288
+ if (intervalMs <= 0) return;
289
+
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
+ );
300
+ };
301
+
302
+ if (isTerminal()) return;
303
+
304
+ let cancelled = false;
305
+ const tick = async () => {
306
+ if (cancelled) return;
307
+ await refresh({ assemblyId });
308
+ };
309
+
310
+ void tick();
311
+ const id = setInterval(() => {
312
+ void tick();
313
+ }, intervalMs);
314
+
315
+ return () => {
316
+ cancelled = true;
317
+ clearInterval(id);
318
+ };
319
+ }, [
320
+ assemblyId,
321
+ options?.pollIntervalMs,
322
+ options?.stopOnTerminal,
323
+ refresh,
324
+ status,
325
+ ]);
326
+
327
+ return status;
328
+ }
329
+
287
330
  export function useTransloaditFiles(
288
331
  listResults: ListResultsFn,
289
332
  args: { assemblyId: string; stepName?: string; limit?: number },
@@ -0,0 +1,10 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { convexTest } from "convex-test";
4
+ import schema from "../component/schema.js";
5
+
6
+ export const modules = import.meta.glob("../component/**/*.*s");
7
+
8
+ export function createTransloaditTest() {
9
+ return convexTest(schema, modules);
10
+ }