diff --git a/.changeset/uppy-official.md b/.changeset/uppy-official.md new file mode 100644 index 0000000..a038360 --- /dev/null +++ b/.changeset/uppy-official.md @@ -0,0 +1,7 @@ +--- +"@transloadit/convex": minor +--- + +- switch the example and docs to Uppy + @uppy/transloadit and remove the React/tus helpers +- add signed assemblyOptions helpers and ensure expected upload counts are included in params +- update docs for the new Uppy-first integration path diff --git a/README.md b/README.md index 0b506f2..3645879 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Transloadit Convex Component -A Convex component for creating Transloadit Assemblies, handling resumable uploads with tus, and persisting status/results in Convex. +A Convex component for creating Transloadit Assemblies, signing Uppy uploads, and persisting status/results in Convex. ## Features - Create Assemblies with Templates or inline Steps. -- Resumable uploads via tus (client-side hook; form/XHR uploads are intentionally not supported). +- Signed upload options for Uppy + `@uppy/transloadit`. - Webhook ingestion with signature verification (direct or queued). - Persist Assembly status + results in Convex tables. -- Typed API wrappers and React hooks. +- Typed API wrappers and helpers. ## Requirements @@ -45,8 +45,8 @@ npx convex env set TRANSLOADIT_SECRET ## Golden path (secure by default) -1. **Server-only create**: a Convex action creates the Assembly (auth secret stays server-side). -2. **Client upload**: use `useTransloaditUppy` for resumable uploads. +1. **Server-only create**: a Convex action creates signed `assemblyOptions` (auth secret stays server-side). +2. **Client upload**: use Uppy + `@uppy/transloadit` with `assemblyOptions()`. 3. **Webhook ingestion**: verify the signature and `queueWebhook` for durable processing. 4. **Realtime UI**: query status/results and render the gallery. @@ -59,6 +59,7 @@ import { components } from "./_generated/api"; export const { createAssembly, + createAssemblyOptions, handleWebhook, queueWebhook, refreshAssembly, @@ -129,25 +130,33 @@ const transloadit = new Transloadit(components.transloadit, { }); ``` -## React usage (Uppy) +## Uppy client (React example) ```tsx -import { useTransloaditUppy } from "@transloadit/convex/react"; +import Uppy from "@uppy/core"; +import Transloadit from "@uppy/transloadit"; import { api } from "../convex/_generated/api"; -const { startUpload, status, results, stage } = useTransloaditUppy({ - uppy, - createAssembly: api.wedding.createWeddingAssembly, - getStatus: api.transloadit.getAssemblyStatus, - listResults: api.transloadit.listResults, - refreshAssembly: api.transloadit.refreshAssembly, +const uppy = new Uppy().use(Transloadit, { + waitForEncoding: true, + assemblyOptions: async () => { + const { assemblyOptions } = await runAction( + api.wedding.createWeddingAssemblyOptions, + { fileCount, guestName, uploadCode }, + ); + return assemblyOptions; + }, }); -await startUpload({ - createAssemblyArgs: { guestName, uploadCode }, -}); +await uppy.upload(); ``` -For advanced/legacy helpers (raw parsing, low-level tus uploads, polling utilities), see `docs/advanced.md`. +Note: `assemblyOptions()` is called once per batch, so pass per-file metadata via Uppy file meta +(e.g. `uppy.setFileMeta(fileId, {...})`) and use `fields` for shared values. + +Migration note: the `@transloadit/convex/react` entrypoint has been removed; use Uppy + +`@uppy/transloadit` directly. + +For status parsing and polling helpers, see `docs/advanced.md`. ## Example app (Next.js + Uppy wedding gallery) diff --git a/docs/advanced.md b/docs/advanced.md index 95d2114..8534d39 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -1,109 +1,6 @@ # Advanced usage -This page collects low-level helpers and optional maintenance tools. These are intentionally -kept out of the main README so new users can follow a single, Uppy-first path. - -## Low-level tus helpers (advanced) - -If you need a custom uploader (no Uppy), the legacy tus helpers are still available: - -```tsx -import { - uploadWithTransloaditTus, - useTransloaditTusUpload, - uploadFilesWithTransloaditTus, -} from "@transloadit/convex/react"; -import { api } from "../convex/_generated/api"; - -function TusUpload() { - const { upload, isUploading, progress } = useTransloaditTusUpload( - api.transloadit.createAssembly, - ); - - const handleUpload = async (file: File) => { - await upload(file, { - templateId: "template_id_here", - onAssemblyCreated: (assembly) => console.log(assembly.assemblyId), - }); - }; - - return ( -
- handleUpload(e.target.files![0])} /> - {isUploading &&

Uploading: {progress}%

} -
- ); -} -``` - -Imperative helper (e.g. non-React): - -```ts -import { useAction } from "convex/react"; - -const createAssembly = useAction(api.transloadit.createAssembly); - -await uploadWithTransloaditTus( - createAssembly, - file, - { templateId: "template_id_here" }, - { onStateChange: (state) => console.log(state) }, -); -``` - -Multi-file uploads with concurrency + cancellation: - -```ts -import { uploadFilesWithTransloaditTus } from "@transloadit/convex/react"; - -const controller = uploadFilesWithTransloaditTus(createAssembly, files, { - concurrency: 3, - onFileProgress: (file, progress) => console.log(file.name, progress), - onOverallProgress: (progress) => console.log("overall", progress), -}); - -// Optional: cancel in-flight uploads -// controller.cancel(); - -const result = await controller.promise; -console.log(result.files); -``` - -## Reactive status/results helpers - -```tsx -import { useAssemblyStatus, useTransloaditFiles } from "@transloadit/convex/react"; -import { api } from "../convex/_generated/api"; - -function AssemblyStatus({ assemblyId }: { assemblyId: string }) { - const status = useAssemblyStatus(api.transloadit.getAssemblyStatus, assemblyId); - const results = useTransloaditFiles(api.transloadit.listResults, { - assemblyId, - }); - - if (!status) return null; - return ( -
-

Status: {status.ok}

-

Results: {results?.length ?? 0}

-
- ); -} -``` - -Polling fallback (no webhooks): - -```tsx -import { useAssemblyStatusWithPolling } from "@transloadit/convex/react"; -import { api } from "../convex/_generated/api"; - -const status = useAssemblyStatusWithPolling( - api.transloadit.getAssemblyStatus, - api.transloadit.refreshAssembly, - assemblyId, - { pollIntervalMs: 5000, stopOnTerminal: true }, -); -``` +This page collects optional helpers that build on the Uppy-first integration path. ## Typed helpers (raw payload parsing) @@ -145,14 +42,20 @@ type ResizeResult = ResultForRobot<"/image/resize">; type EncodeResult = ResultForRobot<"/video/encode">; ``` -Uppy/Tus wiring: +Polling fallback (no webhooks): ```ts -import { buildTusUploadConfig } from "@transloadit/convex"; - -const { endpoint, metadata } = buildTusUploadConfig(assembly.data, file, { - fieldName: "file", +import { pollAssembly, isAssemblyTerminal } from "@transloadit/convex"; + +const controller = pollAssembly({ + intervalMs: 5000, + refresh: async () => { + await refreshAssembly({ assemblyId }); + }, + isTerminal: () => isAssemblyTerminal(status), }); + +// controller.stop(); ``` ## Optional demo template tooling diff --git a/example/app/WeddingUploadsClient.tsx b/example/app/WeddingUploadsClient.tsx index a9084dc..82c16af 100644 --- a/example/app/WeddingUploadsClient.tsx +++ b/example/app/WeddingUploadsClient.tsx @@ -1,24 +1,23 @@ "use client"; import { useAuthActions } from "@convex-dev/auth/react"; -import Uppy from "@uppy/core"; -import Tus from "@uppy/tus"; -import { useConvexAuth, useQuery } from "convex/react"; +import Uppy, { type UploadResult } from "@uppy/core"; +import Transloadit from "@uppy/transloadit"; +import { useAction, useConvexAuth, useQuery } from "convex/react"; import { makeFunctionReference } from "convex/server"; import dynamic from "next/dynamic"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ASSEMBLY_STATUS_COMPLETED, + type AssemblyOptions, type AssemblyResponse, type AssemblyResultResponse, type AssemblyStatus, getAssemblyStage, getResultOriginalKey, + isAssemblyTerminal, parseAssemblyStatus, - type UppyUploadResult, - uploadWithAssembly, - useAssemblyPoller, - useTransloaditUppy, + pollAssembly, weddingStepNames, } from "../lib/transloadit"; import { Providers } from "./providers"; @@ -29,9 +28,8 @@ const Dashboard = dynamic(() => import("@uppy/react/dashboard"), { type WeddingUppy = Uppy, Record>; -type WeddingAssemblyResponse = { - assemblyId: string; - data: Record; +type WeddingAssemblyOptionsResponse = { + assemblyOptions: AssemblyOptions; params?: Record; }; @@ -40,8 +38,6 @@ type Toast = { message: string; }; -type UploadResult = UppyUploadResult; - type UploadStage = | "idle" | "creating" @@ -81,6 +77,15 @@ const stageRank: Record = { const shouldAdvanceStage = (current: UploadStage, next: UploadStage) => stageRank[next] >= stageRank[current]; +const resolveAssemblyId = (assembly: unknown): string | null => { + if (!assembly || typeof assembly !== "object") return null; + const record = assembly as Record; + if (typeof record.assembly_id === "string") return record.assembly_id; + if (typeof record.assemblyId === "string") return record.assemblyId; + if (typeof record.id === "string") return record.id; + return null; +}; + const UploadTimeline = ({ stage }: { stage: UploadStage }) => { const steps: Array<{ stage: UploadStage; label: string }> = [ { stage: "creating", label: "Assembly created" }, @@ -112,7 +117,15 @@ const UploadTimeline = ({ stage }: { stage: UploadStage }) => { ); }; -const useWeddingUppy = (): WeddingUppy => { +const useWeddingUppy = ( + getAssemblyOptions: () => Promise, +): WeddingUppy => { + const getAssemblyOptionsRef = useRef(getAssemblyOptions); + + useEffect(() => { + getAssemblyOptionsRef.current = getAssemblyOptions; + }, [getAssemblyOptions]); + const [uppy] = useState(() => new Uppy, Record>({ autoProceed: false, @@ -120,7 +133,10 @@ const useWeddingUppy = (): WeddingUppy => { allowedFileTypes: ["image/*", "video/*"], maxNumberOfFiles: 12, }, - }).use(Tus, { endpoint: "" }), + }).use(Transloadit, { + waitForEncoding: true, + assemblyOptions: () => getAssemblyOptionsRef.current(), + }), ); useEffect(() => { @@ -192,16 +208,21 @@ const useUploadToasts = (assemblies: AssemblyResponse[] | undefined) => { return toasts; }; -const formatUploadFailure = (result: UploadResult) => { +const formatUploadFailure = ( + result: UploadResult, Record>, +) => { const failed = result.failed ?? []; if (failed.length === 0) return null; const summary = failed .map((file) => { const name = file.name ?? file.id; + const errorValue = file.error as unknown; const message = - typeof file.error === "string" - ? file.error - : (file.error?.message ?? "Unknown error"); + typeof errorValue === "string" + ? errorValue + : typeof (errorValue as { message?: unknown })?.message === "string" + ? (errorValue as { message: string }).message + : "Unknown error"; return `${name}: ${message}`; }) .join("; "); @@ -214,11 +235,11 @@ type WeddingAssemblyArgs = { uploadCode?: string; }; -const createWeddingAssemblyRef = makeFunctionReference< +const createWeddingAssemblyOptionsRef = makeFunctionReference< "action", WeddingAssemblyArgs, - WeddingAssemblyResponse ->("wedding:createWeddingAssembly"); + WeddingAssemblyOptionsResponse +>("wedding:createWeddingAssemblyOptions"); const listAssembliesRef = makeFunctionReference< "query", { status?: string; userId?: string; limit?: number }, @@ -330,7 +351,37 @@ const LocalWeddingUploads = () => { const [stage, setStage] = useState("idle"); const [guestName, setGuestName] = useState("Guest"); const [uploadCode, setUploadCode] = useState(""); - const uppy = useWeddingUppy(); + const assemblyOptionsPromise = + useRef | null>(null); + const fileCountRef = useRef(0); + + const getAssemblyOptions = useCallback(async () => { + if (assemblyOptionsPromise.current) { + const cached = await assemblyOptionsPromise.current; + return cached.assemblyOptions; + } + const fileCount = Math.max(1, fileCountRef.current || 1); + const promise = fetch("/api/assemblies", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + fileCount, + guestName, + uploadCode, + }), + }).then(async (response) => { + if (!response.ok) { + throw new Error("Failed to create assembly options"); + } + return (await response.json()) as WeddingAssemblyOptionsResponse; + }); + assemblyOptionsPromise.current = promise; + const resolved = await promise; + setAssemblyParams(resolved.params ?? null); + return resolved.assemblyOptions; + }, [guestName, uploadCode]); + + const uppy = useWeddingUppy(getAssemblyOptions); const refreshResults = useCallback(async (id: string, refresh = false) => { const params = new URLSearchParams({ assemblyId: id }); @@ -353,6 +404,24 @@ const LocalWeddingUploads = () => { setResults(data.results ?? []); }, []); + useEffect(() => { + const handleAssemblyCreated = (assembly: unknown) => { + const nextId = resolveAssemblyId(assembly); + if (!nextId) return; + setAssemblyId(nextId); + setStage("uploading"); + }; + const handleComplete = () => { + setStage("processing"); + }; + uppy.on("transloadit:assembly-created", handleAssemblyCreated); + uppy.on("transloadit:complete", handleComplete); + return () => { + uppy.off("transloadit:assembly-created", handleAssemblyCreated); + uppy.off("transloadit:complete", handleComplete); + }; + }, [uppy]); + const startUpload = async () => { setError(null); setStage("creating"); @@ -364,50 +433,21 @@ const LocalWeddingUploads = () => { } setIsUploading(true); + assemblyOptionsPromise.current = null; + fileCountRef.current = files.length; try { - const { assembly, uploadResult } = await uploadWithAssembly< - WeddingAssemblyArgs, - WeddingAssemblyResponse - >( - async ({ fileCount, guestName: name, uploadCode: code }) => { - const response = await fetch("/api/assemblies", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - fileCount, - guestName: name, - uploadCode: code, - }), - }); - if (!response.ok) { - throw new Error("Failed to create assembly"); - } - return (await response.json()) as WeddingAssemblyResponse; - }, - uppy, - { - fileCount: files.length, - createAssemblyArgs: { - guestName, - uploadCode, - }, - fieldName: "file", - onAssemblyCreated: (created) => { - setAssemblyId(created.assemblyId); - setAssemblyParams( - (created as WeddingAssemblyResponse).params ?? null, - ); - setStage("uploading"); - }, - }, - ); - const result = uploadResult as UploadResult; + const result = await uppy.upload(); + if (!result) { + throw new Error("Upload failed"); + } const failure = formatUploadFailure(result); if (failure) { throw new Error(failure); } setStage("processing"); - await refreshResults(assembly.assemblyId, true); + if (assemblyId) { + await refreshResults(assemblyId, true); + } } catch (err) { const message = err instanceof Error ? err.message : "Upload failed"; setError(message); @@ -425,23 +465,18 @@ const LocalWeddingUploads = () => { } }, [assemblyStatus, stage]); - const refreshLocal = useCallback(() => { - if (!assemblyId) return Promise.resolve(); - return refreshResults(assemblyId, true); - }, [assemblyId, refreshResults]); - const shouldContinueLocal = useCallback( - () => results.length === 0, - [results.length], - ); - - useAssemblyPoller({ - assemblyId, - status: assemblyStatus, - intervalMs: 4000, - refresh: refreshLocal, - onError: (err) => setError(err.message), - shouldContinue: shouldContinueLocal, - }); + useEffect(() => { + if (!assemblyId) return; + const controller = pollAssembly({ + intervalMs: 4000, + refresh: () => refreshResults(assemblyId, true), + isTerminal: () => + assemblyStatus ? isAssemblyTerminal(assemblyStatus) : false, + shouldContinue: () => results.length === 0, + onError: (err) => setError(err.message), + }); + return () => controller.stop(); + }, [assemblyId, assemblyStatus, refreshResults, results.length]); return ( { string, unknown > | null>(null); + const [assemblyId, setAssemblyId] = useState(null); const [error, setError] = useState(null); const [stage, setStage] = useState("idle"); const [guestName, setGuestName] = useState("Guest"); const [uploadCode, setUploadCode] = useState(""); - const uppy = useWeddingUppy(); + const [isUploading, setIsUploading] = useState(false); const { signIn } = useAuthActions(); const { isAuthenticated, isLoading } = useConvexAuth(); - const { - startUpload: startUppyUpload, - isUploading, - error: uploadError, - assemblyId, - status, - results, - stage: hookStage, - } = useTransloaditUppy({ - uppy, - createAssembly: createWeddingAssemblyRef, - getStatus: getAssemblyStatusRef, - listResults: listResultsRef, - refreshAssembly: refreshAssemblyRef, - pollIntervalMs: 8000, - onAssemblyCreated: (created) => { - setAssemblyParams((created as WeddingAssemblyResponse).params ?? null); - setStage("uploading"); - }, - }); + const assemblyOptionsPromise = + useRef | null>(null); + const fileCountRef = useRef(0); + const createAssemblyOptions = useAction(createWeddingAssemblyOptionsRef); + const refreshAssembly = useAction(refreshAssemblyRef); + const getAssemblyOptions = useCallback(async () => { + if (!isAuthenticated) { + throw new Error("Authentication required."); + } + if (assemblyOptionsPromise.current) { + const cached = await assemblyOptionsPromise.current; + return cached.assemblyOptions; + } + const fileCount = Math.max(1, fileCountRef.current || 1); + const promise = createAssemblyOptions({ + fileCount, + guestName, + uploadCode, + }) as Promise; + assemblyOptionsPromise.current = promise; + const resolved = await promise; + setAssemblyParams(resolved.params ?? null); + return resolved.assemblyOptions; + }, [createAssemblyOptions, guestName, uploadCode, isAuthenticated]); + const uppy = useWeddingUppy(getAssemblyOptions); + const status = useQuery( + getAssemblyStatusRef, + assemblyId ? { assemblyId } : "skip", + ); + const results = useQuery( + listResultsRef, + assemblyId ? { assemblyId } : "skip", + ); const albumResults = useQuery(listAlbumResultsRef, { album: galleryAlbum, limit: 80, @@ -517,21 +566,59 @@ const CloudWeddingUploads = () => { }; }, [isLoading, isAuthenticated, signIn]); + const parsedStatus = useMemo(() => { + const candidate = + status && typeof status === "object" + ? ((status as { raw?: unknown }).raw ?? status) + : status; + return parseAssemblyStatus(candidate); + }, [status]); + useEffect(() => { - if (!hookStage) return; - if (shouldAdvanceStage(stage, hookStage)) { - setStage(hookStage); - } - }, [hookStage, stage]); + const handleAssemblyCreated = (assembly: unknown) => { + const nextId = resolveAssemblyId(assembly); + if (!nextId) return; + setAssemblyId(nextId); + setStage("uploading"); + }; + const handleComplete = () => { + setStage("processing"); + }; + uppy.on("transloadit:assembly-created", handleAssemblyCreated); + uppy.on("transloadit:complete", handleComplete); + return () => { + uppy.off("transloadit:assembly-created", handleAssemblyCreated); + uppy.off("transloadit:complete", handleComplete); + }; + }, [uppy]); useEffect(() => { - if (uploadError) { - setError(uploadError.message); - setStage("error"); + const nextStage = getAssemblyStage(parsedStatus); + if (!nextStage) return; + if (shouldAdvanceStage(stage, nextStage)) { + setStage(nextStage); } - }, [uploadError]); + }, [parsedStatus, stage]); + + useEffect(() => { + if (!assemblyId) return; + const controller = pollAssembly({ + intervalMs: 8000, + refresh: async () => { + await refreshAssembly({ assemblyId }); + }, + isTerminal: () => + parsedStatus ? isAssemblyTerminal(parsedStatus) : false, + shouldContinue: () => (results ?? []).length === 0, + onError: (err) => setError(err.message), + }); + return () => controller.stop(); + }, [assemblyId, parsedStatus, refreshAssembly, results]); - const statusOk = typeof status?.ok === "string" ? status.ok : "pending"; + const statusOk = + parsedStatus && typeof parsedStatus.ok === "string" + ? parsedStatus.ok + : "pending"; const galleryResults = filterResults( (albumResults ?? results ?? []) as AssemblyResultResponse[], ); @@ -551,16 +638,14 @@ const CloudWeddingUploads = () => { return; } + setIsUploading(true); + assemblyOptionsPromise.current = null; + fileCountRef.current = files.length; try { - const { uploadResult } = await startUppyUpload({ - fileCount: files.length, - fieldName: "file", - createAssemblyArgs: { - guestName, - uploadCode, - }, - }); - const result = uploadResult as UploadResult; + const result = await uppy.upload(); + if (!result) { + throw new Error("Upload failed"); + } const failure = formatUploadFailure(result); if (failure) { throw new Error(failure); @@ -570,6 +655,8 @@ const CloudWeddingUploads = () => { const message = err instanceof Error ? err.message : "Upload failed"; setError(message); setStage("error"); + } finally { + setIsUploading(false); } }; diff --git a/example/app/api/assemblies/route.ts b/example/app/api/assemblies/route.ts index be00722..0291296 100644 --- a/example/app/api/assemblies/route.ts +++ b/example/app/api/assemblies/route.ts @@ -11,7 +11,7 @@ export async function POST(request: Request) { ? Math.max(1, payload.fileCount ?? 1) : 1; - const response = await runAction("createWeddingAssembly", { + const response = await runAction("createWeddingAssemblyOptions", { fileCount, guestName: payload.guestName ?? "Guest", uploadCode: payload.uploadCode, diff --git a/example/convex/transloadit.ts b/example/convex/transloadit.ts index 0db60e6..0fa91be 100644 --- a/example/convex/transloadit.ts +++ b/example/convex/transloadit.ts @@ -3,6 +3,7 @@ import { components } from "./_generated/api"; export const { createAssembly, + createAssemblyOptions, handleWebhook, queueWebhook, refreshAssembly, diff --git a/example/convex/wedding.ts b/example/convex/wedding.ts index 4824df2..0343b36 100644 --- a/example/convex/wedding.ts +++ b/example/convex/wedding.ts @@ -1,3 +1,4 @@ +import { vAssemblyOptions } from "@transloadit/convex"; import { v } from "convex/values"; import { buildWeddingSteps } from "../lib/transloadit-steps"; import { components, internal } from "./_generated/api"; @@ -51,15 +52,14 @@ export const checkUploadLimit = internalMutation({ }, }); -export const createWeddingAssembly = action({ +export const createWeddingAssemblyOptions = action({ args: { fileCount: v.number(), guestName: v.optional(v.string()), uploadCode: v.optional(v.string()), }, returns: v.object({ - assemblyId: v.string(), - data: v.any(), + assemblyOptions: vAssemblyOptions, params: v.any(), }), handler: async (ctx, args) => { @@ -96,8 +96,8 @@ export const createWeddingAssembly = action({ userId: identity.subject, }; - const assembly = await ctx.runAction( - components.transloadit.lib.createAssembly, + const assemblyOptions = await ctx.runAction( + components.transloadit.lib.createAssemblyOptions, { ...assemblyArgs, config: { @@ -107,15 +107,25 @@ export const createWeddingAssembly = action({ }, ); - const params = redactSecrets(assemblyArgs); + const parsedParams = safeParseParams(assemblyOptions.params); + const params = redactSecrets(parsedParams ?? assemblyArgs); return { - ...assembly, + assemblyOptions, params, }; }, }); +const safeParseParams = (value: string) => { + try { + return JSON.parse(value) as Record; + } catch (error) { + console.warn("Failed to parse Transloadit params", error); + return null; + } +}; + const secretKeys = new Set([ "secret", "key", diff --git a/example/lib/convex.ts b/example/lib/convex.ts index 4560ef7..5e90f6e 100644 --- a/example/lib/convex.ts +++ b/example/lib/convex.ts @@ -44,8 +44,8 @@ export const runAction = async ( ) => { if (remoteClient) { const remoteName = - name === "createWeddingAssembly" - ? "wedding:createWeddingAssembly" + name === "createWeddingAssemblyOptions" + ? "wedding:createWeddingAssemblyOptions" : `transloadit:${name}`; const remoteAction = remoteClient as ConvexHttpClient & { action: ( @@ -70,7 +70,7 @@ export const runAction = async ( throw new Error("Missing TRANSLOADIT_KEY or TRANSLOADIT_SECRET"); } - if (name === "createWeddingAssembly") { + if (name === "createWeddingAssemblyOptions") { const notifyUrl = process.env.TRANSLOADIT_NOTIFY_URL; if (!notifyUrl) { throw new Error("Missing TRANSLOADIT_NOTIFY_URL"); @@ -88,17 +88,25 @@ export const runAction = async ( } } - return testClient.action(api.lib.createAssembly, { - steps: buildWeddingSteps(), - notifyUrl, - numExpectedUploadFiles: fileCount, - fields: { - guestName, - album: "wedding-gallery", - fileCount, + const assemblyOptions = await testClient.action( + api.lib.createAssemblyOptions, + { + steps: buildWeddingSteps(), + notifyUrl, + numExpectedUploadFiles: fileCount, + fields: { + guestName, + album: "wedding-gallery", + fileCount, + }, + config, }, - config, - }); + ); + const params = safeParseParams(assemblyOptions.params); + return { + assemblyOptions, + params, + }; } if (name === "createAssembly") { @@ -177,3 +185,12 @@ export const runQuery = async (name: string, args: Record) => { throw new Error(`Unknown query ${name}`); }; + +const safeParseParams = (value: string) => { + try { + return JSON.parse(value) as Record; + } catch (error) { + console.warn("Failed to parse Transloadit params", error); + return null; + } +}; diff --git a/example/lib/transloadit.ts b/example/lib/transloadit.ts index 915c287..cc535ce 100644 --- a/example/lib/transloadit.ts +++ b/example/lib/transloadit.ts @@ -5,6 +5,7 @@ export const weddingStepNames = { }; export type { + AssemblyOptions, AssemblyResponse, AssemblyResultResponse, AssemblyStatus, @@ -13,7 +14,6 @@ export type { export { ASSEMBLY_STATUS_COMPLETED, ASSEMBLY_STATUS_UPLOADING, - buildTusUploadConfig, getAssemblyStage, getResultOriginalKey, getResultUrl, @@ -27,10 +27,3 @@ export { parseAssemblyStatus, pollAssembly, } from "@transloadit/convex"; - -export type { UppyUploadResult } from "@transloadit/convex/react"; -export { - uploadWithAssembly, - useAssemblyPoller, - useTransloaditUppy, -} from "@transloadit/convex/react"; diff --git a/example/tsconfig.json b/example/tsconfig.json index cc7aca0..7fd2f3b 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -17,7 +17,6 @@ "baseUrl": ".", "paths": { "@transloadit/convex": ["../src/client/index.ts"], - "@transloadit/convex/react": ["../src/react/index.tsx"], "@transloadit/convex/lib": ["../src/component/lib.ts"], "@transloadit/convex/test": ["../src/test/index.ts"], "@transloadit/convex/convex.config": ["../src/component/convex.config.ts"] diff --git a/package.json b/package.json index f68836b..e5ed6f6 100644 --- a/package.json +++ b/package.json @@ -58,11 +58,6 @@ "types": "./dist/client/index.d.ts", "default": "./dist/client/index.js" }, - "./react": { - "@convex-dev/component-source": "./src/react/index.tsx", - "types": "./dist/react/index.d.ts", - "default": "./dist/react/index.js" - }, "./lib": { "@convex-dev/component-source": "./src/component/lib.ts", "types": "./dist/component/lib.d.ts", @@ -83,18 +78,11 @@ } }, "peerDependencies": { - "convex": "^1.24.8", - "react": "^18.3.1 || ^19.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } + "convex": "^1.24.8" }, "dependencies": { "@transloadit/utils": "^4.1.9", - "@transloadit/zod": "^4.1.9", - "tus-js-client": "^4.3.1" + "@transloadit/zod": "^4.1.9" }, "devDependencies": { "@auth/core": "^0.37.0", @@ -113,7 +101,7 @@ "@uppy/core": "^5.2.0", "@uppy/dashboard": "^5.1.0", "@uppy/react": "^5.1.1", - "@uppy/tus": "^5.1.0", + "@uppy/transloadit": "^5.4.0", "convex": "^1.31.4", "convex-test": "^0.0.41", "dotenv": "^17.2.3", diff --git a/scripts/qa/app-template.ts b/scripts/qa/app-template.ts index b004af9..0d3a693 100644 --- a/scripts/qa/app-template.ts +++ b/scripts/qa/app-template.ts @@ -292,8 +292,9 @@ export const writeAppFiles = async ({ await writeFile( join(convexDir, "wedding.ts"), [ - 'import { action, internalMutation } from "./_generated/server";', + 'import { vAssemblyOptions } from "@transloadit/convex";', 'import { v } from "convex/values";', + 'import { action, internalMutation } from "./_generated/server";', 'import { components, internal } from "./_generated/api";', 'import { buildWeddingSteps } from "../lib/transloadit-steps";', "", @@ -345,15 +346,15 @@ export const writeAppFiles = async ({ " },", "});", "", - "export const createWeddingAssembly = action({", + "export const createWeddingAssemblyOptions = action({", " args: {", " fileCount: v.number(),", " guestName: v.optional(v.string()),", " uploadCode: v.optional(v.string()),", " },", " returns: v.object({", - " assemblyId: v.string(),", - " data: v.any(),", + " assemblyOptions: vAssemblyOptions,", + " params: v.any(),", " }),", " handler: async (ctx, args) => {", " const identity = await ctx.auth.getUserIdentity();", @@ -370,10 +371,10 @@ export const writeAppFiles = async ({ " }", " }", "", - ' const notifyUrl = requireEnv("TRANSLOADIT_NOTIFY_URL");', " const steps = buildWeddingSteps();", + ' const notifyUrl = requireEnv("TRANSLOADIT_NOTIFY_URL");', " const fileCount = Math.max(1, args.fileCount);", - " return ctx.runAction(components.transloadit.lib.createAssembly, {", + " const assemblyArgs = {", " steps,", " notifyUrl,", " numExpectedUploadFiles: fileCount,", @@ -384,14 +385,56 @@ export const writeAppFiles = async ({ " userId: identity.subject,", " },", " userId: identity.subject,", - " config: {", - ' authKey: requireEnv("TRANSLOADIT_KEY"),', - ' authSecret: requireEnv("TRANSLOADIT_SECRET"),', + " };", + " const assemblyOptions = await ctx.runAction(", + " components.transloadit.lib.createAssemblyOptions,", + " {", + " ...assemblyArgs,", + " config: {", + ' authKey: requireEnv("TRANSLOADIT_KEY"),', + ' authSecret: requireEnv("TRANSLOADIT_SECRET"),', + " },", " },", - " });", + " );", + " const parsedParams = safeParseParams(assemblyOptions.params);", + " const params = redactSecrets(parsedParams ?? assemblyArgs);", + " return { assemblyOptions, params };", " },", "});", "", + "const safeParseParams = (value: string) => {", + " try {", + " return JSON.parse(value);", + " } catch (error) {", + ' console.warn("Failed to parse Transloadit params", error);', + " return null;", + " }", + "};", + "", + "const secretKeys = new Set([", + ' "secret",', + ' "key",', + ' "credentials",', + ' "authSecret",', + ' "authKey",', + "]);", + "", + "const redactSecrets = (value: unknown): unknown => {", + " if (Array.isArray(value)) {", + " return value.map((item) => redactSecrets(item));", + " }", + ' if (value && typeof value === "object") {', + " const entries = Object.entries(value).map(([key, val]) => {", + " if (secretKeys.has(key)) {", + ' return [key, "***"];', + " }", + " return [key, redactSecrets(val)];", + " });", + " return Object.fromEntries(entries);", + " }", + " return value;", + "};", + "", ].join("\n"), "utf8", ); diff --git a/src/client/index.ts b/src/client/index.ts index 602e519..5ad4712 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -4,10 +4,12 @@ import { actionGeneric, mutationGeneric, queryGeneric } from "convex/server"; import { v } from "convex/values"; import type { ComponentApi } from "../component/_generated/component.ts"; import { + type AssemblyOptions, type AssemblyResponse, type AssemblyResultResponse, type CreateAssemblyArgs, vAssemblyIdArgs, + vAssemblyOptions, vAssemblyResponse, vAssemblyResultResponse, vCreateAssemblyArgs, @@ -82,11 +84,6 @@ export type { VerifiedWebhookRequest, WebhookActionArgs, } from "../shared/schemas.ts"; -export type { - TusMetadataOptions, - TusUploadConfig, -} from "../shared/tusUpload.ts"; -export { buildTusUploadConfig } from "../shared/tusUpload.ts"; export type { AssemblyStatus, AssemblyInstructionsInput }; export interface TransloaditConfig { @@ -106,7 +103,13 @@ function requireEnv(names: string[]): string { throw new Error(`Missing ${names.join(" or ")} environment variable`); } -export type { AssemblyResponse, AssemblyResultResponse, CreateAssemblyArgs }; +export { vAssemblyOptions }; +export type { + AssemblyOptions, + AssemblyResponse, + AssemblyResultResponse, + CreateAssemblyArgs, +}; /** * @deprecated Prefer `makeTransloaditAPI` or `Transloadit` for new code. @@ -137,6 +140,13 @@ export class TransloaditClient { }); } + async createAssemblyOptions(ctx: RunActionCtx, args: CreateAssemblyArgs) { + return ctx.runAction(this.component.lib.createAssemblyOptions, { + ...args, + config: this.config, + }); + } + async handleWebhook( ctx: RunActionCtx, args: { @@ -244,6 +254,17 @@ export function makeTransloaditAPI( }); }, }), + createAssemblyOptions: actionGeneric({ + args: vCreateAssemblyArgs, + returns: vAssemblyOptions, + handler: async (ctx, args) => { + const resolvedConfig = resolveConfig(); + return ctx.runAction(component.lib.createAssemblyOptions, { + ...args, + config: resolvedConfig, + }); + }, + }), handleWebhook: actionGeneric({ args: vWebhookActionArgs, returns: vWebhookResponse, diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts index 5149421..6d9a352 100644 --- a/src/component/_generated/component.ts +++ b/src/component/_generated/component.ts @@ -60,6 +60,23 @@ export type ComponentApi = { assemblyId: string; data: any }, Name >; + createAssemblyOptions: FunctionReference< + "action", + "internal", + { + config: { authKey: string; authSecret: string }; + templateId?: string; + steps?: any; + fields?: any; + notifyUrl?: string; + numExpectedUploadFiles?: number; + expires?: string; + additionalParams?: any; + userId?: string; + }, + { params: string; signature: string; fields?: any }, + Name + >; handleWebhook: FunctionReference< "action", "internal", diff --git a/src/component/lib.test.ts b/src/component/lib.test.ts index a175c88..0a5a3b3 100644 --- a/src/component/lib.test.ts +++ b/src/component/lib.test.ts @@ -256,6 +256,25 @@ describe("Transloadit component lib", () => { expect(result.resultCount).toBe(1); }); + test("createAssemblyOptions includes expected upload count when provided", async () => { + const t = convexTest(schema, modules); + + const result = await t.action(api.lib.createAssemblyOptions, { + steps: { + resize: { + robot: "/image/resize", + width: 120, + height: 120, + }, + }, + numExpectedUploadFiles: 3, + config: { authKey: "test-key", authSecret: "test-secret" }, + }); + + const params = JSON.parse(result.params) as Record; + expect(params.num_expected_upload_files).toBe(3); + }); + test("queueWebhook rejects invalid signature", async () => { const t = convexTest(schema, modules); const payload = { assembly_id: "asm_bad" }; diff --git a/src/component/lib.ts b/src/component/lib.ts index 68fea08..cb22775 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -10,6 +10,7 @@ import { vAssembly, vAssemblyBaseArgs, vAssemblyIdArgs, + vAssemblyOptions, vAssemblyResult, vCreateAssemblyReturn, vHandleWebhookArgs, @@ -282,7 +283,7 @@ export const createAssembly = action({ steps: args.steps as AssemblyInstructionsInput["steps"], fields: args.fields as AssemblyInstructionsInput["fields"], notifyUrl: args.notifyUrl, - numExpectedUploadFiles: undefined, + numExpectedUploadFiles: args.numExpectedUploadFiles, expires: args.expires, additionalParams: args.additionalParams as | Record @@ -351,6 +352,44 @@ export const createAssembly = action({ }, }); +export const createAssemblyOptions = action({ + args: { + config: vTransloaditConfig, + ...vAssemblyBaseArgs, + }, + returns: vAssemblyOptions, + handler: async (_ctx, args) => { + const { paramsString, params } = buildTransloaditParams({ + authKey: args.config.authKey, + templateId: args.templateId, + steps: args.steps as AssemblyInstructionsInput["steps"], + fields: args.fields as AssemblyInstructionsInput["fields"], + notifyUrl: args.notifyUrl, + numExpectedUploadFiles: args.numExpectedUploadFiles, + expires: args.expires, + additionalParams: args.additionalParams as + | Record + | undefined, + }); + + const signature = await signTransloaditParams( + paramsString, + args.config.authSecret, + ); + + const fields = + params && typeof params.fields === "object" && params.fields + ? (params.fields as Record) + : undefined; + + return { + params: paramsString, + signature, + fields, + }; + }, +}); + export const processWebhook = internalAction({ args: vWebhookArgs, returns: vWebhookResponse, diff --git a/src/react/index.test.tsx b/src/react/index.test.tsx deleted file mode 100644 index 4b41c1d..0000000 --- a/src/react/index.test.tsx +++ /dev/null @@ -1,340 +0,0 @@ -/// -// @vitest-environment jsdom - -import { renderHook } from "@testing-library/react"; -import type { FunctionReference } from "convex/server"; -import { act } from "react"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import type { - CreateAssemblyFn, - GetAssemblyStatusFn, - ListResultsFn, - RefreshAssemblyFn, - UppyLike, -} from "./index.tsx"; -import { - useAssemblyStatusWithPolling, - useTransloaditUpload, - useTransloaditUppy, -} from "./index.tsx"; - -( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } -).IS_REACT_ACT_ENVIRONMENT = true; - -let currentStatus: unknown = null; -let currentResults: unknown = null; -let queryHandler: (fn: unknown, args: unknown) => unknown = () => currentStatus; -const refreshMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); -const actionMock = vi.hoisted(() => vi.fn((fn: unknown) => fn)); -const queryMock = vi.hoisted(() => vi.fn()); - -vi.mock("convex/react", () => ({ - useQuery: queryMock, - useAction: actionMock, -})); - -vi.mock("tus-js-client", () => { - type UploadOptions = { - endpoint?: string; - onUploadUrlAvailable?: () => void; - onProgress?: (bytesUploaded: number, bytesTotal: number) => void; - onSuccess?: () => void; - }; - - return { - Upload: class MockUpload { - url?: string; - private options: UploadOptions; - constructor(_file: File, options: UploadOptions) { - this.options = options; - this.url = options?.endpoint - ? `${options.endpoint}/upload` - : "https://tus.mock/upload"; - } - start() { - this.options?.onUploadUrlAvailable?.(); - this.options?.onProgress?.(1, 1); - this.options?.onSuccess?.(); - } - abort() { - // no-op for tests - } - }, - }; -}); - -const noopGetStatus = (() => null) as unknown as Parameters< - typeof useAssemblyStatusWithPolling ->[0]; -const noopRefresh = refreshMock as unknown as RefreshAssemblyFn; - -queryMock.mockImplementation((fn, args) => queryHandler(fn, args)); - -describe("useAssemblyStatusWithPolling", () => { - afterEach(() => { - vi.useRealTimers(); - refreshMock.mockClear(); - actionMock.mockClear(); - queryMock.mockClear(); - currentResults = null; - currentStatus = null; - queryHandler = () => currentStatus; - queryMock.mockImplementation((fn, args) => queryHandler(fn, args)); - }); - - test("does not trigger immediate refresh on status change", async () => { - vi.useFakeTimers(); - currentStatus = { ok: "ASSEMBLY_UPLOADING" }; - - const { rerender, unmount } = renderHook( - ({ assemblyId }: { assemblyId: string }) => - useAssemblyStatusWithPolling(noopGetStatus, noopRefresh, assemblyId, { - pollIntervalMs: 1000, - }), - { initialProps: { assemblyId: "asm_1" } }, - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(refreshMock).toHaveBeenCalledTimes(1); - - await act(async () => { - vi.advanceTimersByTime(1000); - await Promise.resolve(); - }); - - expect(refreshMock).toHaveBeenCalledTimes(2); - - currentStatus = { ok: "ASSEMBLY_COMPLETED" }; - rerender({ assemblyId: "asm_1" }); - - await act(async () => { - await Promise.resolve(); - }); - - expect(refreshMock).toHaveBeenCalledTimes(2); - - unmount(); - }); - - test("does not overlap refresh calls", async () => { - vi.useFakeTimers(); - currentStatus = { ok: "ASSEMBLY_UPLOADING" }; - let resolveRefresh: (() => void) | null = null; - refreshMock.mockImplementation( - () => - new Promise((resolve) => { - resolveRefresh = resolve; - }), - ); - - const { unmount } = renderHook(() => - useAssemblyStatusWithPolling(noopGetStatus, noopRefresh, "asm_overlap", { - pollIntervalMs: 1000, - }), - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(refreshMock).toHaveBeenCalledTimes(1); - - await act(async () => { - vi.advanceTimersByTime(3000); - await Promise.resolve(); - }); - - expect(refreshMock).toHaveBeenCalledTimes(1); - - await act(async () => { - resolveRefresh?.(); - await Promise.resolve(); - }); - - await act(async () => { - vi.advanceTimersByTime(1000); - await Promise.resolve(); - }); - - expect(refreshMock).toHaveBeenCalledTimes(2); - - unmount(); - }); -}); - -describe("useTransloaditUpload", () => { - afterEach(() => { - actionMock.mockClear(); - queryMock.mockClear(); - currentResults = null; - currentStatus = null; - queryHandler = () => currentStatus; - }); - - test("uploads files and exposes status/results", async () => { - const createAssembly = vi.fn(async () => ({ - assemblyId: "asm_123", - data: { - tus_url: "https://tus.example.com", - assembly_ssl_url: "https://api2.transloadit.com/assemblies/asm_123", - }, - })); - - const getStatus = {} as GetAssemblyStatusFn; - const listResults = {} as ListResultsFn; - const refreshAssembly = refreshMock as unknown as RefreshAssemblyFn; - currentStatus = { raw: { ok: "ASSEMBLY_UPLOADING" } }; - currentResults = [{ stepName: "resize", raw: { ssl_url: "https://file" } }]; - queryHandler = (fn) => { - if (fn === getStatus) return currentStatus; - if (fn === listResults) return currentResults; - return null; - }; - queryMock.mockImplementation((fn, args) => queryHandler(fn, args)); - - const { result } = renderHook(() => - useTransloaditUpload({ - createAssembly: createAssembly as unknown as CreateAssemblyFn, - getStatus, - listResults, - refreshAssembly, - }), - ); - - const file = new File(["hello"], "hello.txt", { type: "text/plain" }); - - await act(async () => { - await result.current.upload([file], { - steps: { resize: { robot: "/image/resize" } }, - }); - }); - - expect(createAssembly).toHaveBeenCalled(); - expect(result.current.assemblyId).toBe("asm_123"); - expect(result.current.results).toEqual(currentResults); - expect(result.current.status?.ok).toBe("ASSEMBLY_UPLOADING"); - }); - - test("reset clears upload state", async () => { - const createAssembly = vi.fn(async () => ({ - assemblyId: "asm_reset", - data: { - tus_url: "https://tus.example.com", - assembly_ssl_url: "https://api2.transloadit.com/assemblies/asm_reset", - }, - })); - - const getStatus = {} as GetAssemblyStatusFn; - const listResults = {} as ListResultsFn; - const refreshAssembly = refreshMock as unknown as RefreshAssemblyFn; - currentStatus = { raw: { ok: "ASSEMBLY_UPLOADING" } }; - currentResults = [{ stepName: "resize", raw: { ssl_url: "https://file" } }]; - queryHandler = (fn) => { - if (fn === getStatus) return currentStatus; - if (fn === listResults) return currentResults; - return null; - }; - queryMock.mockImplementation((fn, args) => queryHandler(fn, args)); - - const { result } = renderHook(() => - useTransloaditUpload({ - createAssembly: createAssembly as unknown as CreateAssemblyFn, - getStatus, - listResults, - refreshAssembly, - }), - ); - - const file = new File(["hello"], "hello.txt", { type: "text/plain" }); - - await act(async () => { - await result.current.upload([file], { - steps: { resize: { robot: "/image/resize" } }, - }); - }); - - expect(result.current.assemblyId).toBe("asm_reset"); - - act(() => { - result.current.reset(); - }); - - expect(result.current.assemblyId).toBeNull(); - expect(result.current.error).toBeNull(); - }); -}); - -describe("useTransloaditUppy", () => { - afterEach(() => { - actionMock.mockClear(); - queryMock.mockClear(); - currentResults = null; - currentStatus = null; - queryHandler = () => currentStatus; - queryMock.mockImplementation((fn, args) => queryHandler(fn, args)); - }); - - test("uploads via uppy and exposes status/results", async () => { - const createAssembly = vi.fn(async () => ({ - assemblyId: "asm_uppy", - data: { - tus_url: "https://tus.example.com", - assembly_ssl_url: "https://api2.transloadit.com/assemblies/asm_uppy", - }, - })); - - const uppy = { - getFiles: () => [ - { - id: "file-1", - data: new File(["hello"], "hello.jpg", { type: "image/jpeg" }), - }, - ], - setFileMeta: vi.fn(), - setFileState: vi.fn(), - getPlugin: vi.fn(() => ({ setOptions: vi.fn() })), - upload: vi.fn(async () => ({ successful: [{ id: "file-1" }] })), - } as unknown as UppyLike; - - const getStatus = {} as GetAssemblyStatusFn; - const listResults = {} as ListResultsFn; - const refreshAssembly = refreshMock as unknown as RefreshAssemblyFn; - currentStatus = { raw: { ok: "ASSEMBLY_UPLOADING" } }; - currentResults = [{ stepName: "resize", raw: { ssl_url: "https://file" } }]; - queryHandler = (fn) => { - if (fn === getStatus) return currentStatus; - if (fn === listResults) return currentResults; - return null; - }; - queryMock.mockImplementation((fn, args) => queryHandler(fn, args)); - - const { result } = renderHook(() => - useTransloaditUppy({ - uppy, - createAssembly: createAssembly as unknown as FunctionReference< - "action", - "public", - { fileCount: number }, - { assemblyId: string; data: Record } - >, - getStatus, - listResults, - refreshAssembly, - }), - ); - - await act(async () => { - await result.current.startUpload(); - }); - - expect(createAssembly).toHaveBeenCalled(); - expect(uppy.upload).toHaveBeenCalled(); - expect(result.current.assemblyId).toBe("asm_uppy"); - expect(result.current.results).toEqual(currentResults); - expect(result.current.status?.ok).toBe("ASSEMBLY_UPLOADING"); - }); -}); diff --git a/src/react/index.tsx b/src/react/index.tsx deleted file mode 100644 index ce470bc..0000000 --- a/src/react/index.tsx +++ /dev/null @@ -1,1245 +0,0 @@ -import { - type AssemblyStatus, - isAssemblyTerminal, -} from "@transloadit/zod/v3/assemblyStatus"; -import { useAction, useQuery } from "convex/react"; -import type { FunctionReference } from "convex/server"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Upload } from "tus-js-client"; -import { - type AssemblyStage, - getAssemblyStage, - parseAssemblyStatus, -} from "../shared/assemblyUrls.ts"; -import { transloaditError } from "../shared/errors.ts"; -import { pollAssembly } from "../shared/pollAssembly.ts"; -import { buildTusUploadConfig } from "../shared/tusUpload.ts"; - -export type CreateAssemblyFn = FunctionReference< - "action", - "public", - { - templateId?: string; - steps?: unknown; - fields?: unknown; - notifyUrl?: string; - numExpectedUploadFiles?: number; - expires?: string; - additionalParams?: unknown; - userId?: string; - }, - { assemblyId: string; data: Record } ->; - -export type CreateAssemblyArgs = { - templateId?: string; - steps?: unknown; - fields?: unknown; - notifyUrl?: string; - numExpectedUploadFiles?: number; - expires?: string; - additionalParams?: unknown; - userId?: string; -}; - -export type CreateAssemblyResponse = { - assemblyId: string; - data: Record; -}; - -export type CreateAssemblyHandler = ( - args: CreateAssemblyArgs, -) => Promise; - -export type GetAssemblyStatusFn = FunctionReference< - "query", - "public", - { assemblyId: string }, - unknown ->; - -export type ListResultsFn = FunctionReference< - "query", - "public", - { assemblyId: string; stepName?: string; limit?: number }, - Array ->; - -export type RefreshAssemblyFn = FunctionReference< - "action", - "public", - { assemblyId: string }, - { assemblyId: string; ok?: string; status?: string; resultCount: number } ->; - -export interface UploadOptions { - templateId?: string; - steps?: Record; - fields?: Record; - notifyUrl?: string; - numExpectedUploadFiles?: number; - expires?: string; - additionalParams?: Record; - userId?: string; -} - -export interface UploadState { - isUploading: boolean; - progress: number; - error: Error | null; -} - -export interface TusUploadOptions extends UploadOptions { - metadata?: Record; - fieldName?: string; - chunkSize?: number; - retryDelays?: number[]; - onShouldRetry?: (error: unknown, retryAttempt: number) => boolean; - rateLimitRetryDelays?: number[]; - overridePatchMethod?: boolean; - uploadDataDuringCreation?: boolean; - storeFingerprintForResuming?: boolean; - removeFingerprintOnSuccess?: boolean; - onProgress?: (progress: number) => void; - onAssemblyCreated?: (assembly: { - assemblyId: string; - data: Record; - }) => void; -} - -export type TusUploadEvents = { - onStateChange?: (state: UploadState) => void; -}; - -export type MultiFileTusUploadOptions = Omit< - TusUploadOptions, - "metadata" | "fieldName" | "onProgress" -> & { - concurrency?: number; - metadata?: Record | ((file: File) => Record); - fieldName?: string | ((file: File) => string); - onFileProgress?: (file: File, progress: number) => void; - onFileComplete?: (file: File) => void; - onFileError?: (file: File, error: Error) => void; - onOverallProgress?: (progress: number) => void; - onStateChange?: (state: UploadState) => void; - failFast?: boolean; - signal?: AbortSignal; -}; - -export type MultiFileTusUploadResult = { - assemblyId: string; - data: Record; - files: Array<{ - file: File; - status: "success" | "error" | "canceled"; - error?: Error; - }>; -}; - -export type MultiFileTusUploadController = { - promise: Promise; - cancel: () => void; -}; - -export type UppyTusState = { - endpoint?: string; - addRequestId?: boolean; -}; - -export type UppyFile = { - id: string; - data?: unknown; - name?: string; - type?: string; - tus?: UppyTusState; -}; - -export type UppyUploadError = { message?: string } | string | null | undefined; - -export type UppyUploadResult = { - successful?: Array<{ id: string; name?: string }>; - failed?: Array<{ id: string; name?: string; error?: UppyUploadError }>; -}; - -export type UppyLike = { - getFiles: () => UppyFile[]; - setFileMeta: (fileId: string, metadata: Record) => void; - setFileState: (fileId: string, state: { tus?: UppyTusState }) => void; - getPlugin: (name: string) => - | { - setOptions?: (options: { - endpoint?: string; - addRequestId?: boolean; - }) => void; - } - | undefined - | null; - upload: () => Promise; -}; - -export type UploadWithAssemblyOptions = { - fileCount?: number; - fieldName?: string; - metadata?: Record; - addRequestId?: boolean; - createAssemblyArgs?: Partial; - onAssemblyCreated?: (assembly: { - assemblyId: string; - data: Record; - }) => void; -}; - -export type UploadWithAssemblyResult = { - assembly: TAssembly; - uploadResult: UppyUploadResult; -}; - -export type UseTransloaditUploadOptions = { - createAssembly: CreateAssemblyFn; - getStatus: GetAssemblyStatusFn; - listResults: ListResultsFn; - refreshAssembly: RefreshAssemblyFn; - pollIntervalMs?: number; - stopOnTerminal?: boolean; - shouldContinue?: () => boolean; - onError?: (error: Error) => void; -}; - -export type UseTransloaditUploadResult = { - upload: ( - files: File | File[] | FileList, - options: MultiFileTusUploadOptions, - ) => Promise; - cancel: () => void; - reset: () => void; - isUploading: boolean; - progress: number; - error: Error | null; - assemblyId: string | null; - assemblyData: Record | null; - assembly: unknown; - status: AssemblyStatus | null; - results: Array | undefined; -}; - -export type UseTransloaditUppyOptions< - TArgs extends { fileCount: number }, - TAssembly extends { assemblyId: string; data: Record }, -> = { - uppy: UppyLike; - createAssembly: FunctionReference<"action", "public", TArgs, TAssembly>; - getStatus: GetAssemblyStatusFn; - listResults: ListResultsFn; - refreshAssembly: RefreshAssemblyFn; - pollIntervalMs?: number; - shouldContinue?: () => boolean; - onError?: (error: Error) => void; - createAssemblyArgs?: Partial; - fileCount?: number; - fieldName?: string; - metadata?: Record; - addRequestId?: boolean; - onAssemblyCreated?: (assembly: TAssembly) => void; - onUploadResult?: (result: UppyUploadResult) => void; -}; - -export type UseTransloaditUppyResult< - TArgs extends { fileCount: number }, - TAssembly, -> = { - startUpload: ( - overrides?: Partial>, - ) => Promise>; - reset: () => void; - isUploading: boolean; - error: Error | null; - assemblyId: string | null; - assemblyData: Record | null; - assembly: unknown; - status: AssemblyStatus | null; - results: Array | undefined; - stage: AssemblyStage | "uploading" | "error" | null; - uploadResult: UppyUploadResult | null; -}; - -export async function uploadWithAssembly< - TArgs extends { fileCount: number }, - TAssembly extends { assemblyId: string; data: Record }, ->( - createAssembly: (args: TArgs) => Promise, - uppy: UppyLike, - options: UploadWithAssemblyOptions, -): Promise> { - const files = uppy.getFiles(); - if (files.length === 0) { - throw transloaditError("upload", "No files provided for upload"); - } - - const args = { - ...(options.createAssemblyArgs ?? {}), - fileCount: options.fileCount ?? files.length, - } as TArgs; - const assembly = await createAssembly(args); - options.onAssemblyCreated?.(assembly); - - const tusPlugin = uppy.getPlugin("Tus"); - if (!tusPlugin) { - throw transloaditError( - "upload", - 'Uppy Tus plugin is required. Call uppy.use(Tus, { endpoint: "" }) before uploadWithAssembly.', - ); - } - let tusEndpoint: string | null = null; - const addRequestId = options.addRequestId ?? true; - - for (const file of files) { - if ( - !file.data || - typeof Blob === "undefined" || - !(file.data instanceof Blob) - ) { - throw transloaditError( - "upload", - "Uppy file is missing binary data for upload", - ); - } - const uploadFile = - file.data instanceof File - ? file.data - : new File([file.data], file.name ?? "file", { - type: file.data.type || file.type, - }); - const { endpoint, metadata } = buildTusUploadConfig( - assembly.data, - uploadFile, - { - fieldName: options.fieldName, - metadata: options.metadata, - }, - ); - if (!tusEndpoint) { - tusEndpoint = endpoint; - } - uppy.setFileMeta(file.id, metadata); - uppy.setFileState(file.id, { - tus: { - ...(file.tus ?? {}), - endpoint, - addRequestId, - }, - }); - } - - if (tusPlugin && "setOptions" in tusPlugin && tusEndpoint) { - tusPlugin.setOptions?.({ endpoint: tusEndpoint, addRequestId }); - } - - const uploadResult = await uppy.upload(); - if (!uploadResult) { - throw transloaditError("upload", "Uppy upload did not return a result"); - } - return { assembly, uploadResult }; -} - -/** - * Low-level tus upload helper. Prefer `useTransloaditUpload` for new code. - */ -/** - * Low-level tus upload helper. Prefer `useTransloaditUpload` for new code. - */ -export async function uploadWithTransloaditTus( - createAssembly: CreateAssemblyHandler, - file: File, - options: TusUploadOptions, - events: TusUploadEvents = {}, -): Promise { - let currentState: UploadState = { - isUploading: true, - progress: 0, - error: null, - }; - - const emitState = (next: UploadState) => { - currentState = next; - events.onStateChange?.(next); - }; - - emitState(currentState); - - try { - const assembly = await createAssembly({ - templateId: options.templateId, - steps: options.steps, - fields: options.fields, - notifyUrl: options.notifyUrl, - numExpectedUploadFiles: options.numExpectedUploadFiles ?? 1, - expires: options.expires, - additionalParams: options.additionalParams, - userId: options.userId, - }); - - const data = assembly.data as Record; - options.onAssemblyCreated?.(assembly); - const { endpoint, metadata } = buildTusUploadConfig(data, file, { - fieldName: options.fieldName, - metadata: options.metadata, - }); - - type RetryError = { - originalResponse?: { - getStatus?: () => number; - getHeader?: (header: string) => string | undefined; - } | null; - }; - - const getStatus = (error: RetryError) => - error.originalResponse?.getStatus && - typeof error.originalResponse.getStatus === "function" - ? error.originalResponse.getStatus() - : 0; - - const retryDelays = options.retryDelays - ? [...options.retryDelays] - : [1000, 5000, 15000, 30000]; - const rateLimitRetryDelays = options.rateLimitRetryDelays - ? [...options.rateLimitRetryDelays] - : [20_000, 40_000, 80_000]; - - const shouldRetry = (error: RetryError) => { - const status = getStatus(error); - if (!status) return true; - if (status === 409 || status === 423) return true; - return status < 400 || status >= 500; - }; - - let uploadUrl: string | null = null; - let rateLimitAttempt = 0; - - const runUpload = () => - new Promise((resolve, reject) => { - let uploader: Upload; - const uploadOptions: ConstructorParameters[1] = { - endpoint, - metadata, - retryDelays, - uploadDataDuringCreation: options.uploadDataDuringCreation ?? false, - onUploadUrlAvailable: () => { - uploadUrl = uploader.url; - }, - onShouldRetry: (error, retryAttempt) => - options.onShouldRetry?.(error, retryAttempt) ?? shouldRetry(error), - onProgress: (bytesUploaded, bytesTotal) => { - const progress = Math.round((bytesUploaded / bytesTotal) * 100); - emitState({ isUploading: true, progress, error: null }); - options.onProgress?.(progress); - }, - onError: (error) => { - reject(error); - }, - onSuccess: () => { - resolve(); - }, - }; - - if (options.chunkSize !== undefined) { - uploadOptions.chunkSize = options.chunkSize; - } - if (uploadUrl) { - uploadOptions.uploadUrl = uploadUrl; - } - if (options.overridePatchMethod !== undefined) { - uploadOptions.overridePatchMethod = options.overridePatchMethod; - } - if (options.storeFingerprintForResuming !== undefined) { - uploadOptions.storeFingerprintForResuming = - options.storeFingerprintForResuming; - } - if (options.removeFingerprintOnSuccess !== undefined) { - uploadOptions.removeFingerprintOnSuccess = - options.removeFingerprintOnSuccess; - } - - uploader = new Upload(file, uploadOptions); - - uploader.start(); - }); - - while (true) { - try { - await runUpload(); - break; - } catch (error) { - const status = getStatus(error as RetryError); - if (status === 429 && rateLimitAttempt < rateLimitRetryDelays.length) { - const delay = rateLimitRetryDelays[rateLimitAttempt] ?? 0; - rateLimitAttempt += 1; - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - throw error; - } - } - - emitState({ isUploading: false, progress: 100, error: null }); - return assembly; - } catch (error) { - const err = - error instanceof Error - ? error - : transloaditError("upload", "Upload failed"); - emitState({ isUploading: false, progress: 0, error: err }); - throw err; - } -} - -/** - * @deprecated Prefer `useTransloaditUpload` (single + multi-file) for new code. - */ -export function useTransloaditTusUpload(createAssembly: CreateAssemblyFn) { - const create = useAction(createAssembly); - const [state, setState] = useState({ - isUploading: false, - progress: 0, - error: null, - }); - - const upload = useCallback( - async (file: File, options: TusUploadOptions) => - uploadWithTransloaditTus(create, file, options, { - onStateChange: setState, - }), - [create], - ); - - const reset = useCallback(() => { - setState({ isUploading: false, progress: 0, error: null }); - }, []); - - return useMemo( - () => ({ - upload, - reset, - isUploading: state.isUploading, - progress: state.progress, - error: state.error, - }), - [state.error, state.isUploading, state.progress, upload, reset], - ); -} - -/** - * Low-level multi-file tus uploader. Prefer `useTransloaditUpload` for new code. - */ -export function uploadFilesWithTransloaditTus( - createAssembly: CreateAssemblyHandler, - files: File[], - options: MultiFileTusUploadOptions, -): MultiFileTusUploadController { - const concurrency = Math.max(1, options.concurrency ?? 3); - const state: UploadState = { - isUploading: true, - progress: 0, - error: null, - }; - const results: MultiFileTusUploadResult["files"] = files.map((file) => ({ - file, - status: "canceled", - })); - const inFlight = new Set(); - const abortController = new AbortController(); - let cancelled = false; - - const emitState = (next: UploadState) => { - state.isUploading = next.isUploading; - state.progress = next.progress; - state.error = next.error; - options.onStateChange?.(next); - }; - - const cancel = () => { - if (cancelled) return; - cancelled = true; - abortController.abort(); - for (const uploader of inFlight) { - try { - uploader.abort(true); - } catch { - // ignore abort errors - } - } - }; - - if (options.signal) { - if (options.signal.aborted) { - cancel(); - } else { - options.signal.addEventListener("abort", cancel, { once: true }); - } - } - - const promise = (async () => { - if (files.length === 0) { - throw transloaditError("upload", "No files provided for upload"); - } - - emitState({ ...state }); - - const assembly = await createAssembly({ - templateId: options.templateId, - steps: options.steps, - fields: options.fields, - notifyUrl: options.notifyUrl, - numExpectedUploadFiles: options.numExpectedUploadFiles ?? files.length, - expires: options.expires, - additionalParams: options.additionalParams, - userId: options.userId, - }); - - options.onAssemblyCreated?.(assembly); - - type RetryError = { - originalResponse?: { - getStatus?: () => number; - getHeader?: (header: string) => string | undefined; - } | null; - }; - - const getStatus = (error: RetryError) => - error.originalResponse?.getStatus && - typeof error.originalResponse.getStatus === "function" - ? error.originalResponse.getStatus() - : 0; - - const retryDelays = options.retryDelays - ? [...options.retryDelays] - : [1000, 5000, 15000, 30000]; - const rateLimitRetryDelays = options.rateLimitRetryDelays - ? [...options.rateLimitRetryDelays] - : [20_000, 40_000, 80_000]; - - const shouldRetry = (error: RetryError) => { - const status = getStatus(error); - if (!status) return true; - if (status === 409 || status === 423) return true; - return status < 400 || status >= 500; - }; - - const perFileBytes = new Map(); - files.forEach((file, index) => { - perFileBytes.set(index, { uploaded: 0, total: file.size }); - }); - const updateOverallProgress = () => { - let totalUploaded = 0; - let totalBytes = 0; - for (const { uploaded, total } of perFileBytes.values()) { - totalUploaded += uploaded; - totalBytes += total; - } - const overall = - totalBytes > 0 ? Math.round((totalUploaded / totalBytes) * 100) : 0; - emitState({ isUploading: true, progress: overall, error: null }); - options.onOverallProgress?.(overall); - }; - - const resolveMetadata = (file: File) => - typeof options.metadata === "function" - ? options.metadata(file) - : options.metadata; - - const resolveFieldName = (file: File) => - typeof options.fieldName === "function" - ? options.fieldName(file) - : options.fieldName; - - const uploadFile = async (file: File, index: number) => { - const { endpoint, metadata } = buildTusUploadConfig(assembly.data, file, { - fieldName: resolveFieldName(file), - metadata: resolveMetadata(file), - }); - - let uploadUrl: string | null = null; - let rateLimitAttempt = 0; - let uploader: Upload | null = null; - - const runUpload = () => - new Promise((resolve, reject) => { - if (cancelled) { - reject(transloaditError("upload", "Upload canceled")); - return; - } - const onAbort = () => { - reject(transloaditError("upload", "Upload canceled")); - }; - abortController.signal.addEventListener("abort", onAbort, { - once: true, - }); - - let currentUploader: Upload; - const uploadOptions: ConstructorParameters[1] = { - endpoint, - metadata, - retryDelays, - uploadDataDuringCreation: options.uploadDataDuringCreation ?? false, - onUploadUrlAvailable: () => { - uploadUrl = currentUploader.url; - }, - onShouldRetry: (error, retryAttempt) => - options.onShouldRetry?.(error, retryAttempt) ?? - shouldRetry(error), - onProgress: (bytesUploaded, bytesTotal) => { - perFileBytes.set(index, { - uploaded: bytesUploaded, - total: bytesTotal, - }); - const progress = Math.round((bytesUploaded / bytesTotal) * 100); - options.onFileProgress?.(file, progress); - updateOverallProgress(); - }, - onError: (error) => { - abortController.signal.removeEventListener("abort", onAbort); - reject(error); - }, - onSuccess: () => { - abortController.signal.removeEventListener("abort", onAbort); - resolve(); - }, - }; - - if (options.chunkSize !== undefined) { - uploadOptions.chunkSize = options.chunkSize; - } - if (uploadUrl) { - uploadOptions.uploadUrl = uploadUrl; - } - if (options.overridePatchMethod !== undefined) { - uploadOptions.overridePatchMethod = options.overridePatchMethod; - } - if (options.storeFingerprintForResuming !== undefined) { - uploadOptions.storeFingerprintForResuming = - options.storeFingerprintForResuming; - } - if (options.removeFingerprintOnSuccess !== undefined) { - uploadOptions.removeFingerprintOnSuccess = - options.removeFingerprintOnSuccess; - } - - currentUploader = new Upload(file, uploadOptions); - uploader = currentUploader; - inFlight.add(currentUploader); - - currentUploader.start(); - }).finally(() => { - if (uploader) { - inFlight.delete(uploader); - } - }); - - while (true) { - try { - await runUpload(); - break; - } catch (error) { - if (cancelled) { - throw error; - } - const status = getStatus(error as RetryError); - if ( - status === 429 && - rateLimitAttempt < rateLimitRetryDelays.length - ) { - const delay = rateLimitRetryDelays[rateLimitAttempt] ?? 0; - rateLimitAttempt += 1; - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - throw error; - } - } - }; - - let nextIndex = 0; - const errors: Error[] = []; - - const worker = async () => { - while (true) { - if (cancelled) return; - const index = nextIndex; - nextIndex += 1; - if (index >= files.length) return; - const file = files[index]; - try { - await uploadFile(file, index); - results[index] = { file, status: "success" }; - options.onFileComplete?.(file); - } catch (error) { - if (cancelled) { - results[index] = { file, status: "canceled" }; - return; - } - const err = - error instanceof Error - ? error - : transloaditError("upload", "Upload failed"); - results[index] = { file, status: "error", error: err }; - errors.push(err); - options.onFileError?.(file, err); - if (options.failFast ?? false) { - cancel(); - return; - } - } - } - }; - - await Promise.all( - Array.from({ length: Math.min(concurrency, files.length) }, worker), - ); - - if (cancelled) { - const error = transloaditError("upload", "Upload canceled"); - (error as Error & { results?: MultiFileTusUploadResult }).results = { - assemblyId: assembly.assemblyId, - data: assembly.data, - files: results, - }; - throw error; - } - - const hasErrors = results.some((result) => result.status === "error"); - const resultPayload: MultiFileTusUploadResult = { - assemblyId: assembly.assemblyId, - data: assembly.data, - files: results, - }; - - if (hasErrors) { - const error = transloaditError( - "upload", - `Failed to upload ${errors.length} file${errors.length === 1 ? "" : "s"}`, - ); - (error as Error & { results?: MultiFileTusUploadResult }).results = - resultPayload; - throw error; - } - - emitState({ isUploading: false, progress: 100, error: null }); - return resultPayload; - })(); - - return { promise, cancel }; -} - -export function useTransloaditUpload( - options: UseTransloaditUploadOptions, -): UseTransloaditUploadResult { - const create = useAction(options.createAssembly); - const refresh = useAction(options.refreshAssembly); - const [state, setState] = useState({ - isUploading: false, - progress: 0, - error: null, - }); - const [assemblyId, setAssemblyId] = useState(null); - const [assemblyData, setAssemblyData] = useState | null>(null); - const cancelRef = useRef<(() => void) | null>(null); - - const upload = useCallback( - async ( - files: File | File[] | FileList, - uploadOptions: MultiFileTusUploadOptions, - ) => { - const resolved = - files instanceof FileList - ? Array.from(files) - : Array.isArray(files) - ? files - : [files]; - - const controller = uploadFilesWithTransloaditTus(create, resolved, { - ...uploadOptions, - onStateChange: setState, - onAssemblyCreated: (assembly) => { - setAssemblyId(assembly.assemblyId); - setAssemblyData(assembly.data); - uploadOptions.onAssemblyCreated?.(assembly); - }, - }); - - cancelRef.current = controller.cancel; - - try { - const result = await controller.promise; - setAssemblyId(result.assemblyId); - setAssemblyData(result.data); - return result; - } catch (error) { - const resolvedError = - error instanceof Error - ? error - : transloaditError("upload", "Upload failed"); - setState({ isUploading: false, progress: 0, error: resolvedError }); - throw error; - } finally { - cancelRef.current = null; - } - }, - [create], - ); - - const cancel = useCallback(() => { - cancelRef.current?.(); - }, []); - - const reset = useCallback(() => { - cancelRef.current?.(); - cancelRef.current = null; - setAssemblyId(null); - setAssemblyData(null); - setState({ isUploading: false, progress: 0, error: null }); - }, []); - - const assembly = useQuery( - options.getStatus, - assemblyId ? { assemblyId } : "skip", - ); - - const parsedStatus = useMemo(() => { - const candidate = - assembly && typeof assembly === "object" - ? ((assembly as { raw?: unknown }).raw ?? assembly) - : assembly; - return parseAssemblyStatus(candidate); - }, [assembly]); - - const results = useQuery( - options.listResults, - assemblyId ? { assemblyId } : "skip", - ); - - useAssemblyPoller({ - assemblyId, - status: parsedStatus, - refresh: async () => { - if (!assemblyId) return; - await refresh({ assemblyId }); - }, - intervalMs: options.pollIntervalMs ?? 5000, - shouldContinue: options.shouldContinue, - onError: options.onError, - }); - - return { - upload, - cancel, - reset, - isUploading: state.isUploading, - progress: state.progress, - error: state.error, - assemblyId, - assemblyData, - assembly, - status: parsedStatus, - results, - }; -} - -export function useTransloaditUppy< - TArgs extends { fileCount: number }, - TAssembly extends { assemblyId: string; data: Record }, ->( - options: UseTransloaditUppyOptions, -): UseTransloaditUppyResult { - const create = useAction(options.createAssembly) as unknown as ( - args: TArgs, - ) => Promise; - const refresh = useAction(options.refreshAssembly); - const [isUploading, setIsUploading] = useState(false); - const [error, setError] = useState(null); - const [assemblyId, setAssemblyId] = useState(null); - const [assemblyData, setAssemblyData] = useState | null>(null); - const [uploadResult, setUploadResult] = useState( - null, - ); - - const assembly = useQuery( - options.getStatus, - assemblyId ? { assemblyId } : "skip", - ); - const results = useQuery( - options.listResults, - assemblyId ? { assemblyId } : "skip", - ); - const parsedStatus = useMemo(() => { - const candidate = - assembly && typeof assembly === "object" - ? ((assembly as { raw?: unknown }).raw ?? assembly) - : assembly; - return parseAssemblyStatus(candidate); - }, [assembly]); - - useAssemblyPoller({ - assemblyId, - status: parsedStatus, - refresh: async () => { - if (!assemblyId) return; - await refresh({ assemblyId }); - }, - intervalMs: options.pollIntervalMs ?? 5000, - shouldContinue: options.shouldContinue, - onError: options.onError, - }); - - const startUpload = useCallback( - async (overrides?: Partial>) => { - setError(null); - setIsUploading(true); - - try { - const files = options.uppy.getFiles(); - if (files.length === 0) { - throw transloaditError("upload", "No files provided for upload"); - } - - const createAssemblyArgs = { - ...(options.createAssemblyArgs ?? {}), - ...(overrides?.createAssemblyArgs ?? {}), - } as TArgs; - - const { assembly, uploadResult: result } = await uploadWithAssembly< - TArgs, - TAssembly - >(create, options.uppy, { - fileCount: overrides?.fileCount ?? options.fileCount ?? files.length, - fieldName: overrides?.fieldName ?? options.fieldName, - metadata: overrides?.metadata ?? options.metadata, - addRequestId: overrides?.addRequestId ?? options.addRequestId, - createAssemblyArgs, - onAssemblyCreated: (created) => { - const typed = created as TAssembly; - setAssemblyId(typed.assemblyId); - setAssemblyData(typed.data); - options.onAssemblyCreated?.(typed); - overrides?.onAssemblyCreated?.(created); - }, - }); - - setAssemblyId(assembly.assemblyId); - setAssemblyData(assembly.data); - setUploadResult(result); - options.onUploadResult?.(result); - setIsUploading(false); - return { assembly, uploadResult: result }; - } catch (err) { - const resolved = - err instanceof Error - ? err - : transloaditError("upload", "Upload failed"); - setError(resolved); - setIsUploading(false); - throw resolved; - } - }, - [ - create, - options.addRequestId, - options.createAssemblyArgs, - options.fieldName, - options.fileCount, - options.metadata, - options.onAssemblyCreated, - options.onUploadResult, - options.uppy, - ], - ); - - const reset = useCallback(() => { - setIsUploading(false); - setError(null); - setAssemblyId(null); - setAssemblyData(null); - setUploadResult(null); - }, []); - - const stage = useMemo(() => { - if (error) return "error"; - if (isUploading) return "uploading"; - return parsedStatus ? getAssemblyStage(parsedStatus) : null; - }, [error, isUploading, parsedStatus]); - - return { - startUpload, - reset, - isUploading, - error, - assemblyId, - assemblyData, - assembly, - status: parsedStatus, - results, - stage, - uploadResult, - }; -} - -export function useAssemblyStatus( - getStatus: GetAssemblyStatusFn, - assemblyId: string, -) { - return useQuery(getStatus, { assemblyId }); -} - -export function useAssemblyStatusWithPolling( - getStatus: GetAssemblyStatusFn, - refreshAssembly: RefreshAssemblyFn, - assemblyId: string, - options?: { - pollIntervalMs?: number; - stopOnTerminal?: boolean; - shouldContinue?: () => boolean; - onError?: (error: Error) => void; - }, -) { - const status = useQuery(getStatus, { assemblyId }); - const refresh = useAction(refreshAssembly); - const statusRef = useRef(status); - const shouldContinueRef = useRef(options?.shouldContinue); - const onErrorRef = useRef(options?.onError); - - useEffect(() => { - statusRef.current = status; - }, [status]); - - useEffect(() => { - shouldContinueRef.current = options?.shouldContinue; - }, [options?.shouldContinue]); - - useEffect(() => { - onErrorRef.current = options?.onError; - }, [options?.onError]); - - useEffect(() => { - if (!assemblyId) return; - const intervalMs = options?.pollIntervalMs ?? 5000; - if (intervalMs <= 0) return; - - const shouldKeepPolling = () => { - const shouldContinue = shouldContinueRef.current?.(); - if (shouldContinue === false) return false; - if (!options?.stopOnTerminal) return true; - const current = statusRef.current; - const rawCandidate = - current && typeof current === "object" - ? ((current as { raw?: unknown }).raw ?? current) - : current; - const parsed = parseAssemblyStatus(rawCandidate); - return !(parsed ? isAssemblyTerminal(parsed) : false); - }; - - if (!shouldKeepPolling()) return; - - let cancelled = false; - let intervalId: ReturnType | null = null; - let inFlight = false; - const tick = async () => { - if (cancelled) return; - if (!shouldKeepPolling()) { - if (intervalId) clearInterval(intervalId); - cancelled = true; - return; - } - if (inFlight) return; - inFlight = true; - try { - await refresh({ assemblyId }); - } catch (error) { - const resolved = - error instanceof Error - ? error - : transloaditError("polling", "Refresh failed"); - onErrorRef.current?.(resolved); - } finally { - inFlight = false; - } - }; - - intervalId = setInterval(() => { - void tick(); - }, intervalMs); - void tick(); - - return () => { - cancelled = true; - if (intervalId) clearInterval(intervalId); - }; - }, [assemblyId, options?.pollIntervalMs, options?.stopOnTerminal, refresh]); - - return status; -} - -/** - * @deprecated Prefer `useAssemblyStatusWithPolling` for public usage. - */ -export function useAssemblyPoller(options: { - assemblyId: string | null; - status: AssemblyStatus | null | undefined; - refresh: () => Promise; - intervalMs: number; - shouldContinue?: () => boolean; - onError?: (error: Error) => void; -}) { - const refreshRef = useRef(options.refresh); - const onErrorRef = useRef(options.onError); - const shouldContinueRef = useRef(options.shouldContinue); - const statusRef = useRef(options.status); - - useEffect(() => { - refreshRef.current = options.refresh; - }, [options.refresh]); - - useEffect(() => { - onErrorRef.current = options.onError; - }, [options.onError]); - - useEffect(() => { - shouldContinueRef.current = options.shouldContinue; - }, [options.shouldContinue]); - - useEffect(() => { - statusRef.current = options.status; - }, [options.status]); - - useEffect(() => { - if (!options.assemblyId) return; - - const controller = pollAssembly({ - intervalMs: options.intervalMs, - refresh: () => refreshRef.current(), - shouldContinue: () => shouldContinueRef.current?.() ?? false, - isTerminal: () => { - const current = statusRef.current; - return current ? isAssemblyTerminal(current) : false; - }, - onError: (error) => { - onErrorRef.current?.(error); - }, - }); - - return () => { - controller.stop(); - }; - }, [options.assemblyId, options.intervalMs]); -} - -export function useTransloaditFiles( - listResults: ListResultsFn, - args: { assemblyId: string; stepName?: string; limit?: number }, -) { - return useQuery(listResults, args); -} diff --git a/src/react/uploadWithTus.test.tsx b/src/react/uploadWithTus.test.tsx deleted file mode 100644 index 14c5c15..0000000 --- a/src/react/uploadWithTus.test.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/// -// @vitest-environment jsdom - -import { describe, expect, it, vi } from "vitest"; -import type { UploadState } from "./index.tsx"; - -vi.mock("tus-js-client", () => { - type UploadOptions = { - onUploadUrlAvailable?: () => void; - onProgress?: (bytesUploaded: number, bytesTotal: number) => void; - onSuccess?: () => void; - onError?: (error: Error) => void; - }; - - class Upload { - url: string; - options: UploadOptions; - file: File; - aborted = false; - - constructor(file: File, options: UploadOptions) { - this.file = file; - this.options = options; - this.url = `https://upload.example.com/${encodeURIComponent(file.name)}`; - } - - start() { - const shouldFail = this.file.name.includes("fail"); - const delay = this.file.name.includes("slow") ? 25 : 0; - this.options.onUploadUrlAvailable?.(); - this.options.onProgress?.(10, 10); - setTimeout(() => { - if (this.aborted) return; - if (shouldFail) { - this.options.onError?.(new Error("Upload failed")); - return; - } - this.options.onSuccess?.(); - }, delay); - } - - abort() { - this.aborted = true; - } - } - - return { Upload }; -}); - -import { - uploadFilesWithTransloaditTus, - uploadWithTransloaditTus, -} from "./index.tsx"; - -describe("uploadWithTransloaditTus", () => { - it("uploads with tus and emits progress", async () => { - const createAssembly = vi.fn(async () => ({ - assemblyId: "asm_123", - data: { - tus_url: "https://tus.transloadit.com", - assembly_ssl_url: "https://transloadit.com/assembly", - }, - })); - const file = new File(["hello"], "hello.txt", { type: "text/plain" }); - const states: UploadState[] = []; - const progress: number[] = []; - - const result = await uploadWithTransloaditTus( - createAssembly, - file, - { - numExpectedUploadFiles: 1, - onProgress: (value) => progress.push(value), - }, - { - onStateChange: (state) => states.push(state), - }, - ); - - expect(createAssembly).toHaveBeenCalledWith( - expect.objectContaining({ numExpectedUploadFiles: 1 }), - ); - expect(result.assemblyId).toBe("asm_123"); - expect(progress).toContain(100); - expect(states[0]).toEqual({ isUploading: true, progress: 0, error: null }); - expect(states[states.length - 1]).toEqual({ - isUploading: false, - progress: 100, - error: null, - }); - }); - - it("fails when tus_url is missing", async () => { - const createAssembly = vi.fn(async () => ({ - assemblyId: "asm_456", - data: {}, - })); - const file = new File(["hello"], "hello.txt", { type: "text/plain" }); - const states: UploadState[] = []; - - await expect( - uploadWithTransloaditTus( - createAssembly, - file, - { numExpectedUploadFiles: 1 }, - { onStateChange: (state) => states.push(state) }, - ), - ).rejects.toThrow("tus_url"); - - const lastState = states[states.length - 1]; - expect(lastState?.error).toBeInstanceOf(Error); - expect(lastState?.isUploading).toBe(false); - }); -}); - -describe("uploadFilesWithTransloaditTus", () => { - const createAssembly = vi.fn(async () => ({ - assemblyId: "asm_multi", - data: { - tus_url: "https://tus.transloadit.com", - assembly_ssl_url: "https://transloadit.com/assembly", - }, - })); - - it("uploads multiple files with overall progress", async () => { - const files = [ - new File(["one"], "one.txt", { type: "text/plain" }), - new File(["two"], "two.txt", { type: "text/plain" }), - ]; - const overall: number[] = []; - const perFile: Array<{ name: string; progress: number }> = []; - - const controller = uploadFilesWithTransloaditTus(createAssembly, files, { - numExpectedUploadFiles: files.length, - onOverallProgress: (progress) => overall.push(progress), - onFileProgress: (file, progress) => - perFile.push({ name: file.name, progress }), - }); - const result = await controller.promise; - - expect(result.assemblyId).toBe("asm_multi"); - expect(result.files.every((file) => file.status === "success")).toBe(true); - expect(overall[overall.length - 1]).toBe(100); - expect(perFile.map((entry) => entry.name)).toEqual( - expect.arrayContaining(["one.txt", "two.txt"]), - ); - }); - - it("does not reach 100% before all files start", async () => { - const files = [ - new File(["slow"], "slow.txt", { type: "text/plain" }), - new File(["two"], "two.txt", { type: "text/plain" }), - ]; - const overall: number[] = []; - - const controller = uploadFilesWithTransloaditTus(createAssembly, files, { - numExpectedUploadFiles: files.length, - concurrency: 1, - onOverallProgress: (progress) => overall.push(progress), - }); - await controller.promise; - - expect(overall[0]).toBeLessThan(100); - }); - - it("returns results on partial failure when failFast is false", async () => { - const files = [ - new File(["ok"], "ok.txt", { type: "text/plain" }), - new File(["bad"], "fail.txt", { type: "text/plain" }), - ]; - - await expect( - uploadFilesWithTransloaditTus(createAssembly, files, { - failFast: false, - }).promise, - ).rejects.toThrow("Failed to upload"); - }); - - it("cancels uploads and surfaces results", async () => { - const files = [ - new File(["slow"], "slow.txt", { type: "text/plain" }), - new File(["slow"], "slow-2.txt", { type: "text/plain" }), - ]; - const controller = uploadFilesWithTransloaditTus(createAssembly, files, { - failFast: true, - }); - - controller.cancel(); - - await expect(controller.promise).rejects.toThrow("Upload canceled"); - }); -}); diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 3270d7c..148ea23 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -100,6 +100,14 @@ export const vCreateAssemblyReturn = v.object({ export type CreateAssemblyReturn = Infer; +export const vAssemblyOptions = v.object({ + params: v.string(), + signature: v.string(), + fields: v.optional(v.record(v.string(), v.any())), +}); + +export type AssemblyOptions = Infer; + export const vWebhookArgs = { payload: v.any(), rawBody: v.optional(v.string()), diff --git a/src/shared/tusUpload.ts b/src/shared/tusUpload.ts deleted file mode 100644 index 2174cbd..0000000 --- a/src/shared/tusUpload.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { parseAssemblyUrls } from "./assemblyUrls.ts"; -import { transloaditError } from "./errors.ts"; - -export type TusUploadConfig = { - endpoint: string; - metadata: Record; - addRequestId: boolean; - tusUrl: string; - assemblyUrl: string; -}; - -export type TusMetadataOptions = { - fieldName?: string; - metadata?: Record; -}; - -export const buildTusUploadConfig = ( - assemblyData: unknown, - file: File, - options: TusMetadataOptions = {}, -): TusUploadConfig => { - const { tusUrl, assemblyUrl } = parseAssemblyUrls(assemblyData); - - if (!tusUrl) { - throw transloaditError( - "upload", - "Transloadit response missing tus_url for resumable upload", - ); - } - - if (!assemblyUrl) { - throw transloaditError( - "upload", - "Transloadit response missing assembly_url for resumable upload", - ); - } - - const metadata: Record = { - filename: file.name, - ...options.metadata, - }; - if (file.type) { - metadata.filetype = file.type; - } - if (!metadata.fieldname) { - metadata.fieldname = options.fieldName ?? "file"; - } - if (!metadata.assembly_url) { - metadata.assembly_url = assemblyUrl; - } - - return { - endpoint: tusUrl, - metadata, - addRequestId: true, - tusUrl, - assemblyUrl, - }; -}; diff --git a/test/e2e/support/example-app.ts b/test/e2e/support/example-app.ts index 15e5c83..53caee0 100644 --- a/test/e2e/support/example-app.ts +++ b/test/e2e/support/example-app.ts @@ -113,6 +113,10 @@ export const startExampleApp = async ({ TRANSLOADIT_NOTIFY_URL: notifyUrl, ...env, }; + if (env.E2E_MODE === "local") { + nextEnv.NEXT_PUBLIC_CONVEX_URL = ""; + nextEnv.CONVEX_URL = ""; + } await runCommand("yarn", ["build"], nextEnv, "Package build"); diff --git a/test/e2e/upload.e2e.test.ts b/test/e2e/upload.e2e.test.ts index cb91567..def2af4 100644 --- a/test/e2e/upload.e2e.test.ts +++ b/test/e2e/upload.e2e.test.ts @@ -234,7 +234,35 @@ describeE2e("e2e upload flow", () => { const assemblyId = assemblyText?.replace("ID:", "").trim() ?? ""; expect(assemblyId).not.toBe(""); - const waitForStatus = async () => { + const readGalleryReady = async (targetAssemblyId: string) => + page.evaluate((assemblyId) => { + const cards = Array.from( + document.querySelectorAll("[data-assembly-id]"), + ).filter((card) => card.dataset.assemblyId === assemblyId); + const imgs = cards.flatMap((card) => + Array.from(card.querySelectorAll("img")), + ); + const vids = cards.flatMap((card) => + Array.from(card.querySelectorAll("video")), + ); + const imagesReady = + imgs.length > 0 && imgs.every((img) => img.complete); + const videosReady = + vids.length > 0 && + vids.every((video) => { + const src = video.getAttribute("src"); + if (src && src.length > 0) return true; + const poster = video.getAttribute("poster"); + return Boolean(poster && poster.length > 0); + }); + return { + hasCards: cards.length > 0, + imagesReady, + videosReady, + }; + }, targetAssemblyId); + + const waitForStatusOrGallery = async (targetAssemblyId: string) => { const deadline = Date.now() + timeouts.refresh; let lastStatus: string | null = null; while (Date.now() < deadline) { @@ -250,6 +278,10 @@ describeE2e("e2e upload flow", () => { throw new Error(`Assembly ended unsuccessfully: ${text}`); } } + const ready = await readGalleryReady(targetAssemblyId); + if (ready.hasCards && ready.imagesReady && ready.videosReady) { + return; + } await sleep(2000); } throw new Error( @@ -257,37 +289,12 @@ describeE2e("e2e upload flow", () => { ); }; - await waitForStatus(); + await waitForStatusOrGallery(assemblyId); const waitForAssemblyMedia = async (targetAssemblyId: string) => { const deadline = Date.now() + timeouts.results; while (Date.now() < deadline) { - const ready = await page.evaluate((assemblyId) => { - const cards = Array.from( - document.querySelectorAll("[data-assembly-id]"), - ).filter((card) => card.dataset.assemblyId === assemblyId); - const imgs = cards.flatMap((card) => - Array.from(card.querySelectorAll("img")), - ); - const vids = cards.flatMap((card) => - Array.from(card.querySelectorAll("video")), - ); - const imagesReady = - imgs.length > 0 && imgs.every((img) => img.complete); - const videosReady = - vids.length > 0 && - vids.every((video) => { - const src = video.getAttribute("src"); - if (src && src.length > 0) return true; - const poster = video.getAttribute("poster"); - return Boolean(poster && poster.length > 0); - }); - return { - hasCards: cards.length > 0, - imagesReady, - videosReady, - }; - }, targetAssemblyId); + const ready = await readGalleryReady(targetAssemblyId); if (!ready.hasCards) { await sleep(1000); diff --git a/yarn.lock b/yarn.lock index 044c409..3348b2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -124,7 +124,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-s3@npm:^3.551.0": +"@aws-sdk/client-s3@npm:^3.551.0, @aws-sdk/client-s3@npm:^3.891.0": version: 3.975.0 resolution: "@aws-sdk/client-s3@npm:3.975.0" dependencies: @@ -644,6 +644,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/s3-request-presigner@npm:^3.891.0": + version: 3.975.0 + resolution: "@aws-sdk/s3-request-presigner@npm:3.975.0" + dependencies: + "@aws-sdk/signature-v4-multi-region": "npm:3.972.0" + "@aws-sdk/types": "npm:^3.973.0" + "@aws-sdk/util-format-url": "npm:^3.972.1" + "@smithy/middleware-endpoint": "npm:^4.4.11" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/smithy-client": "npm:^4.10.12" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10c0/08989b91b7349fe40d6e8379a053f2150a21a1a89ce2d0c057faf3bfb6c0fbfcee8b940449c54c7dcdad14a1f3b671b3da09b77a3f85266ae6a541c96eff23bf + languageName: node + linkType: hard + "@aws-sdk/signature-v4-multi-region@npm:3.972.0": version: 3.972.0 resolution: "@aws-sdk/signature-v4-multi-region@npm:3.972.0" @@ -693,6 +709,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/types@npm:^3.973.1": + version: 3.973.1 + resolution: "@aws-sdk/types@npm:3.973.1" + dependencies: + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10c0/8a4a183cc39b4d6f4d065ece884b50d397a54b17add32b649f49adbe676174e7bee2c3c94394fc5227a4fccb96c34482291a1eb2702158e1dbb12c441af32863 + languageName: node + linkType: hard + "@aws-sdk/util-arn-parser@npm:3.972.0": version: 3.972.0 resolution: "@aws-sdk/util-arn-parser@npm:3.972.0" @@ -724,6 +750,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-format-url@npm:^3.972.1": + version: 3.972.2 + resolution: "@aws-sdk/util-format-url@npm:3.972.2" + dependencies: + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/querystring-builder": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10c0/20bb4340d9ed7bdb91da8601064453d24fa3416813b331e6effa295959a7c629b50b750273a218ab21b629e98f1ccf8f128f32f24f2416230d4561dc68d0033c + languageName: node + linkType: hard + "@aws-sdk/util-locate-window@npm:^3.0.0": version: 3.965.3 resolution: "@aws-sdk/util-locate-window@npm:3.965.3" @@ -2260,6 +2298,20 @@ __metadata: languageName: node linkType: hard +"@sec-ant/readable-stream@npm:^0.4.1": + version: 0.4.1 + resolution: "@sec-ant/readable-stream@npm:0.4.1" + checksum: 10c0/64e9e9cf161e848067a5bf60cdc04d18495dc28bb63a8d9f8993e4dd99b91ad34e4b563c85de17d91ffb177ec17a0664991d2e115f6543e73236a906068987af + languageName: node + linkType: hard + +"@sindresorhus/is@npm:^7.0.1": + version: 7.2.0 + resolution: "@sindresorhus/is@npm:7.2.0" + checksum: 10c0/0040c17d7826414363f99f5d56077c200789d51e6dfe5542920bfb29ab3828ec0ebf2845e8bae796bee461debb646b5e4c0a623140131cf3143471e915b50b54 + languageName: node + linkType: hard + "@smithy/abort-controller@npm:^4.2.8": version: 4.2.8 resolution: "@smithy/abort-controller@npm:4.2.8" @@ -2884,6 +2936,15 @@ __metadata: languageName: node linkType: hard +"@szmarczak/http-timer@npm:^5.0.1": + version: 5.0.1 + resolution: "@szmarczak/http-timer@npm:5.0.1" + dependencies: + defer-to-connect: "npm:^2.0.1" + checksum: 10c0/4629d2fbb2ea67c2e9dc03af235c0991c79ebdddcbc19aed5d5732fb29ce01c13331e9b1a491584b9069bd6ecde6581dcbf871f11b7eefdebbab34de6cf2197e + languageName: node + linkType: hard + "@testing-library/dom@npm:^10.4.1": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1" @@ -2942,7 +3003,7 @@ __metadata: "@uppy/core": "npm:^5.2.0" "@uppy/dashboard": "npm:^5.1.0" "@uppy/react": "npm:^5.1.1" - "@uppy/tus": "npm:^5.1.0" + "@uppy/transloadit": "npm:^5.4.0" convex: "npm:^1.31.4" convex-test: "npm:^0.0.41" dotenv: "npm:^17.2.3" @@ -2951,15 +3012,10 @@ __metadata: next: "npm:^16.1.3" react: "npm:^19.2.3" react-dom: "npm:^19.2.3" - tus-js-client: "npm:^4.3.1" typescript: "npm:^5.9.3" vitest: "npm:^4.0.17" peerDependencies: convex: ^1.24.8 - react: ^18.3.1 || ^19.0.0 - peerDependenciesMeta: - react: - optional: true languageName: unknown linkType: soft @@ -2970,6 +3026,13 @@ __metadata: languageName: node linkType: hard +"@transloadit/sev-logger@npm:^0.0.15": + version: 0.0.15 + resolution: "@transloadit/sev-logger@npm:0.0.15" + checksum: 10c0/1aa26ab5d6c8dab425ea85832f2094d59285963bc368b64990f23420ddc3a5bedea8ca70f91abb068d447479f4fca88cb1a9b2cd49ccc4093294daa7cf8e2ae1 + languageName: node + linkType: hard + "@transloadit/utils@npm:^4.1.9": version: 4.1.9 resolution: "@transloadit/utils@npm:4.1.9" @@ -3018,6 +3081,13 @@ __metadata: languageName: node linkType: hard +"@types/http-cache-semantics@npm:^4.0.4": + version: 4.2.0 + resolution: "@types/http-cache-semantics@npm:4.2.0" + checksum: 10c0/82dd33cbe7d4843f1e884a251c6a12d385b62274353b9db167462e7fbffdbb3a83606f9952203017c5b8cabbd7b9eef0cf240a3a9dedd20f69875c9701939415 + languageName: node + linkType: hard + "@types/node@npm:^12.7.1": version: 12.20.55 resolution: "@types/node@npm:12.20.55" @@ -3194,6 +3264,20 @@ __metadata: languageName: node linkType: hard +"@uppy/transloadit@npm:^5.4.0": + version: 5.4.0 + resolution: "@uppy/transloadit@npm:5.4.0" + dependencies: + "@uppy/tus": "npm:^5.1.0" + "@uppy/utils": "npm:^7.1.4" + component-emitter: "npm:^2.0.0" + transloadit: "npm:^4.0.2" + peerDependencies: + "@uppy/core": ^5.2.0 + checksum: 10c0/25e751a750ea23a3c78b49e327a1e11efa5a48023064f201cef91420b4c3b8add15e204f2e1d2753bf0bd34431b1e70f64665e19eed5603e91f5f897b860ab2b + languageName: node + linkType: hard + "@uppy/tus@npm:^5.1.0": version: 5.1.0 resolution: "@uppy/tus@npm:5.1.0" @@ -3371,6 +3455,34 @@ __metadata: languageName: node linkType: hard +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186 + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee + languageName: node + linkType: hard + "baseline-browser-mapping@npm:^2.8.3": version: 2.9.15 resolution: "baseline-browser-mapping@npm:2.9.15" @@ -3396,6 +3508,16 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^1.1.7": + version: 1.1.12 + resolution: "brace-expansion@npm:1.1.12" + dependencies: + balanced-match: "npm:^1.0.0" + concat-map: "npm:0.0.1" + checksum: 10c0/975fecac2bb7758c062c20d0b3b6288c7cc895219ee25f0a64a9de662dbac981ff0b6e89909c3897c1f84fa353113a721923afdec5f8b2350255b097f12b1f73 + languageName: node + linkType: hard + "braces@npm:^3.0.3": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -3431,6 +3553,38 @@ __metadata: languageName: node linkType: hard +"cacheable-lookup@npm:^7.0.0": + version: 7.0.0 + resolution: "cacheable-lookup@npm:7.0.0" + checksum: 10c0/63a9c144c5b45cb5549251e3ea774c04d63063b29e469f7584171d059d3a88f650f47869a974e2d07de62116463d742c287a81a625e791539d987115cb081635 + languageName: node + linkType: hard + +"cacheable-request@npm:^12.0.1": + version: 12.0.1 + resolution: "cacheable-request@npm:12.0.1" + dependencies: + "@types/http-cache-semantics": "npm:^4.0.4" + get-stream: "npm:^9.0.1" + http-cache-semantics: "npm:^4.1.1" + keyv: "npm:^4.5.4" + mimic-response: "npm:^4.0.0" + normalize-url: "npm:^8.0.1" + responselike: "npm:^3.0.0" + checksum: 10c0/3ccc26519c8dd0821fcb21fa00781e55f05ab6e1da1487fbbee9c8c03435a3cf72c29a710a991cebe398fb9a5274e2a772fc488546d402db8dc21310764ed83a + languageName: node + linkType: hard + +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + "caniuse-lite@npm:^1.0.30001579": version: 1.0.30001765 resolution: "caniuse-lite@npm:1.0.30001765" @@ -3480,6 +3634,17 @@ __metadata: languageName: node linkType: hard +"clipanion@npm:^4.0.0-rc.4": + version: 4.0.0-rc.4 + resolution: "clipanion@npm:4.0.0-rc.4" + dependencies: + typanion: "npm:^3.8.0" + peerDependencies: + typanion: "*" + checksum: 10c0/047b415b59a5e9777d00690fba563ccc850eca6bf27790a88d1deea3ecc8a89840ae9aed554ff284cc698a9f3f20256e43c25ff4a7c4c90a71e5e7d9dca61dd1 + languageName: node + linkType: hard + "clsx@npm:^2.1.1": version: 2.1.1 resolution: "clsx@npm:2.1.1" @@ -3497,6 +3662,29 @@ __metadata: languageName: node linkType: hard +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + +"component-emitter@npm:^2.0.0": + version: 2.0.0 + resolution: "component-emitter@npm:2.0.0" + checksum: 10c0/65dfaf787ea49eb48f0ffec766bda7ec67e8dbeb3b406f08724dcae842e0aa274731fcccb9280b77d2b41693061731a9080b60d276020246a146544cd9900b83 + languageName: node + linkType: hard + +"concat-map@npm:0.0.1": + version: 0.0.1 + resolution: "concat-map@npm:0.0.1" + checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f + languageName: node + linkType: hard + "convex-test@npm:^0.0.41": version: 0.0.41 resolution: "convex-test@npm:0.0.41" @@ -3581,7 +3769,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:^4.3.4, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -3600,6 +3788,29 @@ __metadata: languageName: node linkType: hard +"decompress-response@npm:^6.0.0": + version: 6.0.0 + resolution: "decompress-response@npm:6.0.0" + dependencies: + mimic-response: "npm:^3.1.0" + checksum: 10c0/bd89d23141b96d80577e70c54fb226b2f40e74a6817652b80a116d7befb8758261ad073a8895648a29cc0a5947021ab66705cb542fa9c143c82022b27c5b175e + languageName: node + linkType: hard + +"defer-to-connect@npm:^2.0.1": + version: 2.0.1 + resolution: "defer-to-connect@npm:2.0.1" + checksum: 10c0/625ce28e1b5ad10cf77057b9a6a727bf84780c17660f6644dab61dd34c23de3001f03cedc401f7d30a4ed9965c2e8a7336e220a329146f2cf85d4eddea429782 + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + "dequal@npm:^2.0.3": version: 2.0.3 resolution: "dequal@npm:2.0.3" @@ -3644,6 +3855,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + "encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -3684,6 +3906,20 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85 + languageName: node + linkType: hard + "es-module-lexer@npm:^1.7.0": version: 1.7.0 resolution: "es-module-lexer@npm:1.7.0" @@ -3691,6 +3927,27 @@ __metadata: languageName: node linkType: hard +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c + languageName: node + linkType: hard + +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af + languageName: node + linkType: hard + "esbuild@npm:0.27.0": version: 0.27.0 resolution: "esbuild@npm:0.27.0" @@ -3994,6 +4251,26 @@ __metadata: languageName: node linkType: hard +"form-data-encoder@npm:^4.0.2": + version: 4.1.0 + resolution: "form-data-encoder@npm:4.1.0" + checksum: 10c0/cbd655aa8ffff6f7c2733b1d8e95fa9a2fe8a88a90bde29fb54b8e02c9406e51f32a014bfe8297d67fbac9f77614d14a8b4bbc4fd0352838e67e97a881d06332 + languageName: node + linkType: hard + +"form-data@npm:^4.0.4": + version: 4.0.5 + resolution: "form-data@npm:4.0.5" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b + languageName: node + linkType: hard + "fs-extra@npm:^7.0.1": version: 7.0.1 resolution: "fs-extra@npm:7.0.1" @@ -4063,6 +4340,61 @@ __metadata: languageName: node linkType: hard +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + languageName: node + linkType: hard + +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8 + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.6": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d + languageName: node + linkType: hard + +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c + languageName: node + linkType: hard + +"get-stream@npm:^9.0.1": + version: 9.0.1 + resolution: "get-stream@npm:9.0.1" + dependencies: + "@sec-ant/readable-stream": "npm:^0.4.1" + is-stream: "npm:^4.0.1" + checksum: 10c0/d70e73857f2eea1826ac570c3a912757dcfbe8a718a033fa0c23e12ac8e7d633195b01710e0559af574cbb5af101009b42df7b6f6b29ceec8dbdf7291931b948 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -4097,6 +4429,32 @@ __metadata: languageName: node linkType: hard +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead + languageName: node + linkType: hard + +"got@npm:14.4.9": + version: 14.4.9 + resolution: "got@npm:14.4.9" + dependencies: + "@sindresorhus/is": "npm:^7.0.1" + "@szmarczak/http-timer": "npm:^5.0.1" + cacheable-lookup: "npm:^7.0.0" + cacheable-request: "npm:^12.0.1" + decompress-response: "npm:^6.0.0" + form-data-encoder: "npm:^4.0.2" + http2-wrapper: "npm:^2.2.1" + lowercase-keys: "npm:^3.0.0" + p-cancelable: "npm:^4.0.1" + responselike: "npm:^3.0.0" + type-fest: "npm:^4.26.1" + checksum: 10c0/bc4b0991c114947b54681d96d2ee998638bb824a6ccb2e95ab09660aaf0f514bac383a81755fe7b6b89e3a23fa1bf05c1e5ae9be102f50b37d654f29c967c8d8 + languageName: node + linkType: hard + "graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -4104,6 +4462,31 @@ __metadata: languageName: node linkType: hard +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c + languageName: node + linkType: hard + +"hasown@npm:^2.0.2": + version: 2.0.2 + resolution: "hasown@npm:2.0.2" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9 + languageName: node + linkType: hard + "html-encoding-sniffer@npm:^4.0.0": version: 4.0.0 resolution: "html-encoding-sniffer@npm:4.0.0" @@ -4130,6 +4513,16 @@ __metadata: languageName: node linkType: hard +"http2-wrapper@npm:^2.2.1": + version: 2.2.1 + resolution: "http2-wrapper@npm:2.2.1" + dependencies: + quick-lru: "npm:^5.1.1" + resolve-alpn: "npm:^1.2.0" + checksum: 10c0/7207201d3c6e53e72e510c9b8912e4f3e468d3ecc0cf3bf52682f2aac9cd99358b896d1da4467380adc151cf97c412bedc59dc13dae90c523f42053a7449eedb + languageName: node + linkType: hard + "https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.6": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" @@ -4181,6 +4574,13 @@ __metadata: languageName: node linkType: hard +"into-stream@npm:^9.0.0": + version: 9.0.0 + resolution: "into-stream@npm:9.0.0" + checksum: 10c0/d6aeb614270787cde67faa56e9ff3fe98f99e539efbf6b3628c631b83d9eea29626204898a3954410c2f518449c8bee972ae93146a3f3fc46da5d2ac5ef7e0f7 + languageName: node + linkType: hard + "ip-address@npm:^10.0.1": version: 10.1.0 resolution: "ip-address@npm:10.1.0" @@ -4232,6 +4632,13 @@ __metadata: languageName: node linkType: hard +"is-stream@npm:^4.0.1": + version: 4.0.1 + resolution: "is-stream@npm:4.0.1" + checksum: 10c0/2706c7f19b851327ba374687bc4a3940805e14ca496dc672b9629e744d143b1ad9c6f1b162dece81c7bfbc0f83b32b61ccc19ad2e05aad2dd7af347408f60c7f + languageName: node + linkType: hard + "is-subdir@npm:^1.1.1": version: 1.2.0 resolution: "is-subdir@npm:1.2.0" @@ -4339,6 +4746,13 @@ __metadata: languageName: node linkType: hard +"json-buffer@npm:3.0.1": + version: 3.0.1 + resolution: "json-buffer@npm:3.0.1" + checksum: 10c0/0d1c91569d9588e7eef2b49b59851f297f3ab93c7b35c7c221e288099322be6b562767d11e4821da500f3219542b9afd2e54c5dc573107c1126ed1080f8e96d7 + languageName: node + linkType: hard + "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" @@ -4358,6 +4772,15 @@ __metadata: languageName: node linkType: hard +"keyv@npm:^4.5.4": + version: 4.5.4 + resolution: "keyv@npm:4.5.4" + dependencies: + json-buffer: "npm:3.0.1" + checksum: 10c0/aa52f3c5e18e16bb6324876bb8b59dd02acf782a4b789c7b2ae21107fab95fab3890ed448d4f8dba80ce05391eeac4bfabb4f02a20221342982f806fa2cf271e + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -4447,6 +4870,13 @@ __metadata: languageName: node linkType: hard +"lowercase-keys@npm:^3.0.0": + version: 3.0.0 + resolution: "lowercase-keys@npm:3.0.0" + checksum: 10c0/ef62b9fa5690ab0a6e4ef40c94efce68e3ed124f583cc3be38b26ff871da0178a28b9a84ce0c209653bb25ca135520ab87fea7cd411a54ac4899cb2f30501430 + languageName: node + linkType: hard + "lru-cache@npm:^10.4.3": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" @@ -4508,6 +4938,13 @@ __metadata: languageName: node linkType: hard +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + languageName: node + linkType: hard + "merge2@npm:^1.3.0, merge2@npm:^1.4.1": version: 1.4.1 resolution: "merge2@npm:1.4.1" @@ -4525,6 +4962,13 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + "mime-match@npm:^1.0.2": version: 1.0.2 resolution: "mime-match@npm:1.0.2" @@ -4534,6 +4978,29 @@ __metadata: languageName: node linkType: hard +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"mimic-response@npm:^3.1.0": + version: 3.1.0 + resolution: "mimic-response@npm:3.1.0" + checksum: 10c0/0d6f07ce6e03e9e4445bee655202153bdb8a98d67ee8dc965ac140900d7a2688343e6b4c9a72cfc9ef2f7944dfd76eef4ab2482eb7b293a68b84916bac735362 + languageName: node + linkType: hard + +"mimic-response@npm:^4.0.0": + version: 4.0.0 + resolution: "mimic-response@npm:4.0.0" + checksum: 10c0/761d788d2668ae9292c489605ffd4fad220f442fbae6832adce5ebad086d691e906a6d5240c290293c7a11e99fbdbbef04abbbed498bf8699a4ee0f31315e3fb + languageName: node + linkType: hard + "minimatch@npm:^10.1.1": version: 10.1.1 resolution: "minimatch@npm:10.1.1" @@ -4543,6 +5010,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^3.0.5": + version: 3.1.2 + resolution: "minimatch@npm:3.1.2" + dependencies: + brace-expansion: "npm:^1.1.7" + checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + languageName: node + linkType: hard + "minipass-collect@npm:^2.0.1": version: 2.0.1 resolution: "minipass-collect@npm:2.0.1" @@ -4745,6 +5221,13 @@ __metadata: languageName: node linkType: hard +"node-watch@npm:^0.7.4": + version: 0.7.4 + resolution: "node-watch@npm:0.7.4" + checksum: 10c0/05c3e66e7b5013d64c31a6dd96b55d87c14c8c0515d05d73554d706a1f8b962fe31781dce74740db29c0ec7c9a1f33a6bac07ef1e8aecc0d38c5ab4eef4c7ac0 + languageName: node + linkType: hard + "nopt@npm:^9.0.0": version: 9.0.0 resolution: "nopt@npm:9.0.0" @@ -4756,6 +5239,13 @@ __metadata: languageName: node linkType: hard +"normalize-url@npm:^8.0.1": + version: 8.1.1 + resolution: "normalize-url@npm:8.1.1" + checksum: 10c0/1beb700ce42acb2288f39453cdf8001eead55bbf046d407936a40404af420b8c1c6be97a869884ae9e659d7b1c744e40e905c875ac9290644eec2e3e6fb0b370 + languageName: node + linkType: hard + "nwsapi@npm:^2.2.16": version: 2.2.23 resolution: "nwsapi@npm:2.2.23" @@ -4784,6 +5274,13 @@ __metadata: languageName: node linkType: hard +"p-cancelable@npm:^4.0.1": + version: 4.0.1 + resolution: "p-cancelable@npm:4.0.1" + checksum: 10c0/12636623f46784ba962b6fe7a1f34d021f1d9a2cc12c43e270baa715ea872d5c8c7d9f086ed420b8b9817e91d9bbe92c14c90e5dddd4a9968c81a2a7aef7089d + languageName: node + linkType: hard + "p-filter@npm:^2.1.0": version: 2.1.0 resolution: "p-filter@npm:2.1.0" @@ -4818,7 +5315,7 @@ __metadata: languageName: node linkType: hard -"p-map@npm:^7.0.2": +"p-map@npm:^7.0.2, p-map@npm:^7.0.3": version: 7.0.4 resolution: "p-map@npm:7.0.4" checksum: 10c0/a5030935d3cb2919d7e89454d1ce82141e6f9955413658b8c9403cfe379283770ed3048146b44cde168aa9e8c716505f196d5689db0ae3ce9a71521a2fef3abd @@ -4835,6 +5332,16 @@ __metadata: languageName: node linkType: hard +"p-queue@npm:^9.0.1": + version: 9.1.0 + resolution: "p-queue@npm:9.1.0" + dependencies: + eventemitter3: "npm:^5.0.1" + p-timeout: "npm:^7.0.0" + checksum: 10c0/f6bb4644997c20cbbf68c0c88208283697c6c9b1e1879f2073791b1ffcb2d2eb0a9fe35c9631e0c74bd6562ef159b87b418d48df7e7b30e5ddb4d99055bb5c92 + languageName: node + linkType: hard + "p-retry@npm:^6.1.0": version: 6.2.1 resolution: "p-retry@npm:6.2.1" @@ -4853,6 +5360,13 @@ __metadata: languageName: node linkType: hard +"p-timeout@npm:^7.0.0": + version: 7.0.1 + resolution: "p-timeout@npm:7.0.1" + checksum: 10c0/87d96529d1096d506607218dba6f9ec077c6dbedd0c2e2788c748e33bcd05faae8a81009fd9d22ec0b3c95fc83f4717306baba223f6e464737d8b99294c3e863 + languageName: node + linkType: hard + "p-try@npm:^2.0.0": version: 2.2.0 resolution: "p-try@npm:2.2.0" @@ -5105,6 +5619,13 @@ __metadata: languageName: node linkType: hard +"quick-lru@npm:^5.1.1": + version: 5.1.1 + resolution: "quick-lru@npm:5.1.1" + checksum: 10c0/a24cba5da8cec30d70d2484be37622580f64765fb6390a928b17f60cd69e8dbd32a954b3ff9176fa1b86d86ff2ba05252fae55dc4d40d0291c60412b0ad096da + languageName: node + linkType: hard + "react-dom@npm:^19.2.3": version: 19.2.3 resolution: "react-dom@npm:19.2.3" @@ -5142,6 +5663,15 @@ __metadata: languageName: node linkType: hard +"recursive-readdir@npm:^2.2.3": + version: 2.2.3 + resolution: "recursive-readdir@npm:2.2.3" + dependencies: + minimatch: "npm:^3.0.5" + checksum: 10c0/d0238f137b03af9cd645e1e0b40ae78b6cda13846e3ca57f626fcb58a66c79ae018a10e926b13b3a460f1285acc946a4e512ea8daa2e35df4b76a105709930d1 + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -5149,6 +5679,13 @@ __metadata: languageName: node linkType: hard +"resolve-alpn@npm:^1.2.0": + version: 1.2.1 + resolution: "resolve-alpn@npm:1.2.1" + checksum: 10c0/b70b29c1843bc39781ef946c8cd4482e6d425976599c0f9c138cec8209e4e0736161bf39319b01676a847000085dfdaf63583c6fb4427bf751a10635bd2aa0c4 + languageName: node + linkType: hard + "resolve-from@npm:^5.0.0": version: 5.0.0 resolution: "resolve-from@npm:5.0.0" @@ -5156,6 +5693,15 @@ __metadata: languageName: node linkType: hard +"responselike@npm:^3.0.0": + version: 3.0.0 + resolution: "responselike@npm:3.0.0" + dependencies: + lowercase-keys: "npm:^3.0.0" + checksum: 10c0/8af27153f7e47aa2c07a5f2d538cb1e5872995f0e9ff77def858ecce5c3fe677d42b824a62cde502e56d275ab832b0a8bd350d5cd6b467ac0425214ac12ae658 + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -5681,6 +6227,35 @@ __metadata: languageName: node linkType: hard +"transloadit@npm:^4.0.2": + version: 4.1.9 + resolution: "transloadit@npm:4.1.9" + dependencies: + "@aws-sdk/client-s3": "npm:^3.891.0" + "@aws-sdk/s3-request-presigner": "npm:^3.891.0" + "@transloadit/sev-logger": "npm:^0.0.15" + "@transloadit/utils": "npm:^4.1.9" + clipanion: "npm:^4.0.0-rc.4" + debug: "npm:^4.4.3" + dotenv: "npm:^17.2.3" + form-data: "npm:^4.0.4" + got: "npm:14.4.9" + into-stream: "npm:^9.0.0" + is-stream: "npm:^4.0.1" + node-watch: "npm:^0.7.4" + p-map: "npm:^7.0.3" + p-queue: "npm:^9.0.1" + recursive-readdir: "npm:^2.2.3" + tus-js-client: "npm:^4.3.1" + typanion: "npm:^3.14.0" + type-fest: "npm:^4.41.0" + zod: "npm:3.25.76" + bin: + transloadit: dist/cli.js + checksum: 10c0/debfbc02a79be568a41ad7e79550b3d49f00c771c275bed6fe484b36ea64fbf8d46452e00e7e5989344daef4df4acb1caaa0334261938dfd0442c2e1c727c5db + languageName: node + linkType: hard + "tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" @@ -5703,7 +6278,14 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^4.41.0": +"typanion@npm:^3.14.0, typanion@npm:^3.8.0": + version: 3.14.0 + resolution: "typanion@npm:3.14.0" + checksum: 10c0/8b03b19844e6955bfd906c31dc781bae6d7f1fb3ce4fe24b7501557013d4889ae5cefe671dafe98d87ead0adceb8afcb8bc16df7dc0bd2b7331bac96f3a7cae2 + languageName: node + linkType: hard + +"type-fest@npm:^4.26.1, type-fest@npm:^4.41.0": version: 4.41.0 resolution: "type-fest@npm:4.41.0" checksum: 10c0/f5ca697797ed5e88d33ac8f1fec21921839871f808dc59345c9cf67345bfb958ce41bd821165dbf3ae591cedec2bf6fe8882098dfdd8dc54320b859711a2c1e4 @@ -6021,6 +6603,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:3.25.76": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c + languageName: node + linkType: hard + "zod@npm:^4.0.0": version: 4.3.5 resolution: "zod@npm:4.3.5"