From f8c7cd26cc8f2dca0b0bbfecbcf16e24530211a3 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Sat, 5 Apr 2025 22:33:39 +0200 Subject: [PATCH] initial impl of new API --- .changeset/happy-houses-hang.md | 9 + examples/profile-picture/package.json | 2 +- examples/with-novel/package.json | 2 +- packages/uploadthing/package.json | 15 +- .../src/_internal/client-future.ts | 620 +++++++++++++ .../uploadthing/src/_internal/ut-reporter.ts | 6 +- packages/uploadthing/src/client-future.ts | 255 ++++++ packages/uploadthing/src/client.ts | 4 + packages/uploadthing/turbo.json | 1 + playground/app/api/uploadthing/route.ts | 43 +- playground/app/future/page.tsx | 218 +++++ playground/app/global.css | 96 ++ playground/app/originui/page.tsx | 843 ++++++++++++++++++ playground/components/button.tsx | 58 +- playground/components/fieldset.tsx | 2 +- playground/components/file-card.tsx | 9 +- playground/components/skeleton.tsx | 2 +- playground/components/uploader.tsx | 2 +- playground/lib/uploadthing.ts | 71 ++ playground/package.json | 4 +- pnpm-lock.yaml | 502 +++++++---- 21 files changed, 2543 insertions(+), 221 deletions(-) create mode 100644 .changeset/happy-houses-hang.md create mode 100644 packages/uploadthing/src/_internal/client-future.ts create mode 100644 packages/uploadthing/src/client-future.ts create mode 100644 playground/app/future/page.tsx create mode 100644 playground/app/originui/page.tsx create mode 100644 playground/lib/uploadthing.ts diff --git a/.changeset/happy-houses-hang.md b/.changeset/happy-houses-hang.md new file mode 100644 index 0000000000..b421b2a3e2 --- /dev/null +++ b/.changeset/happy-houses-hang.md @@ -0,0 +1,9 @@ +--- +"uploadthing": minor +--- + +feat: introduce new experimental client API + +This API is not covered under semver. Check out some example usage here: + +https://github.com/pingdotgg/uploadthing/blob/main/playground/app/originui/page.tsx \ No newline at end of file diff --git a/examples/profile-picture/package.json b/examples/profile-picture/package.json index 59eadec20d..476dc5eac5 100644 --- a/examples/profile-picture/package.json +++ b/examples/profile-picture/package.json @@ -16,7 +16,7 @@ "@auth/drizzle-adapter": "^1.2.0", "@libsql/client": "^0.14.0", "@uploadthing/react": "7.3.0", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "client-only": "^0.0.1", "drizzle-orm": "^0.38.3", "lucide-react": "^0.469.0", diff --git a/examples/with-novel/package.json b/examples/with-novel/package.json index 86cb65cb9e..790faea349 100644 --- a/examples/with-novel/package.json +++ b/examples/with-novel/package.json @@ -11,7 +11,7 @@ "dependencies": { "@tailwindcss/typography": "^0.5.15", "@uploadthing/react": "7.3.0", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", "lucide-react": "^0.469.0", "next": "15.3.1", diff --git a/packages/uploadthing/package.json b/packages/uploadthing/package.json index 49e31756a0..3e2ae4bdad 100644 --- a/packages/uploadthing/package.json +++ b/packages/uploadthing/package.json @@ -19,6 +19,16 @@ "default": "./client/index.cjs" } }, + "./client-future": { + "import": { + "types": "./client-future/index.d.ts", + "default": "./client-future/index.js" + }, + "require": { + "types": "./client-future/index.d.cts", + "default": "./client-future/index.cjs" + } + }, "./server": { "import": { "types": "./server/index.d.ts", @@ -118,6 +128,7 @@ }, "files": [ "client", + "client-future", "dist", "effect-platform", "express", @@ -135,9 +146,9 @@ }, "scripts": { "lint": "eslint src test --max-warnings 0", - "build": "NODE_OPTIONS='--max_old_space_size=8192' bunchee --tsconfig tsconfig.build.json && cp src/tw/v4.css tw/v4.css", + "build": "NODE_OPTIONS='--max_old_space_size=16384' bunchee --tsconfig tsconfig.build.json && cp src/tw/v4.css tw/v4.css", "clean": "git clean -xdf client express fastify h3 internal next next-legacy server tw node_modules", - "dev": "NODE_OPTIONS='--max_old_space_size=8192' bunchee --tsconfig tsconfig.build.json --no-clean && cp src/tw/v4.css tw/v4.css", + "dev": "NODE_OPTIONS='--max_old_space_size=16384' bunchee --tsconfig tsconfig.build.json --no-clean && cp src/tw/v4.css tw/v4.css", "prepack": "bun ../../.github/replace-workspace-protocol.ts", "test": "vitest run", "typecheck": "tsc --noEmit" diff --git a/packages/uploadthing/src/_internal/client-future.ts b/packages/uploadthing/src/_internal/client-future.ts new file mode 100644 index 0000000000..85577f5330 --- /dev/null +++ b/packages/uploadthing/src/_internal/client-future.ts @@ -0,0 +1,620 @@ +import { Array, Micro, Predicate } from "effect"; +import type { LazyArg } from "effect/Function"; + +import { fetchEff } from "@uploadthing/shared"; +import type { + FetchContext, + FetchError, + MaybePromise, +} from "@uploadthing/shared"; + +import { version } from "../../package.json"; +import type { + AnyFileRoute, + FileRouter as AnyFileRouter, + NewPresignedUrl, +} from "../types"; +import type { UploadPutResult } from "./types"; +import { createUTReporter } from "./ut-reporter"; + +/** + * Error indicating the XHR request failed + * @public + */ +export class XHRError extends Micro.TaggedError("XHRError")<{ + message: string; + xhr: unknown; +}> {} + +/** + * Error indicating the network request failed + * @public + */ +export type NetworkError = XHRError | FetchError; + +/** + * Error indicating the upload was rejected during upload to the storage provider + * @public + */ +export class UTStorageError extends Micro.TaggedError("UTStorageError")<{ + message: string; + response: unknown; +}> {} + +/** + * Error indicating the request to your UploadThing server failed + * @public + */ +export class UTServerError extends Micro.TaggedError( + "UTServerError", +)<{ + message: string; + cause: unknown; + /** + * Matches the shape returned by your error formatter + */ + data: TErrorShape; +}> {} + +/** + * Error indicating the upload failed + * @public + */ +export type UploadThingClientError = + | NetworkError + | UTStorageError + | UTServerError; + +/** + * A file that has not started uploading yet. + * Can either be pending for the presigned request to resolve, + * or pending for the browser to schedule the network request. + * @public + */ +export interface PendingFile extends File { + status: "pending"; + /** + * How many bytes of the file has been uploaded + * @example 0 + */ + sent: number; + /** + * The key of the file. Null before the presigned request resolves. + */ + key: string | null; + /** + * The customId of the file. Null before the presigned request resolves, then present if your file route sets it + */ + customId: string | null; +} + +/** + * A file that is currently uploading. + * @public + */ +export interface UploadingFile extends File { + status: "uploading"; + /** + * How many bytes of the file has been uploaded + * @example 2500 + */ + sent: number; + /** + * The key of the file. + */ + key: string; + /** + * The customId of the file, if your file route sets it + */ + customId: string | null; +} + +/** + * A file that failed to upload. + * @public + */ +export interface FailedFile extends File { + status: "failed"; + /** + * How many bytes of the file were uploaded before the upload failed. + * @example 2500 + */ + sent: number; + /** + * The key of the file. + */ + key: string; + /** + * The customId of the file, if your file route sets it + */ + customId: string | null; + /** + * The error that occurred during the upload. + */ + reason: UploadThingClientError; +} + +/** + * A file that has been uploaded successfully. + * @public + */ +export interface UploadedFile extends File { + status: "uploaded"; + /** + * How many bytes of the file has been uploaded. + * @example 10000 + */ + sent: number; + /** + * The key of the file. + */ + key: string; + /** + * The customId of the file, if your file route sets it + */ + customId: string | null; + /** + * The url of the file. + * @example "https://APP_ID.ufs.sh/f/KEY" + */ + url: string; + /** + * The data returned by the serverside `onUploadComplete` callback. + * @example { uploadedBy: "user_123" } + */ + data: TRoute["$types"]["output"]; + /** + * The hash ( <> checksum ) of the file. + */ + hash: string; +} + +/** + * A web file with additional state properties + * @public + */ +export type AnyFile = + | PendingFile + | UploadingFile + | FailedFile + | UploadedFile; + +/** + * Predicate function to check if a file is pending + * @public + */ +export function isPendingFile( + file: AnyFile, +): file is PendingFile { + return file.status === "pending"; +} + +/** + * Predicate function to check if a file is uploading + * @public + */ +export function isUploadingFile( + file: AnyFile, +): file is UploadingFile { + return file.status === "uploading"; +} + +/** + * Predicate function to check if a file is failed + * @public + */ +export function isFailedFile( + file: AnyFile, +): file is FailedFile { + return file.status === "failed"; +} + +/** + * Predicate function to check if a file is uploaded + * @public + */ +export function isUploadedFile( + file: AnyFile, +): file is UploadedFile { + return file.status === "uploaded"; +} + +/** + * @internal + */ +export function makePendingFile(file: File): PendingFile { + return Object.assign(file, { + status: "pending" as const, + sent: 0, + key: null, + customId: null, + }); +} + +/** + * Modifies a pending file to an uploading file in place + * @internal + */ +function transitionToUploading( + file: PendingFile, + rangeStart: number, +): UploadingFile { + const uploadingFile = file as unknown as UploadingFile; + uploadingFile.sent = rangeStart; + uploadingFile.status = "uploading"; + return uploadingFile; +} + +/** + * Modifies an uploading file to an uploaded file in place + * @internal + */ +function transitionToUploaded( + file: UploadingFile, + xhrResult: UploadPutResult, +): UploadedFile { + const uploadedFile = file as unknown as UploadedFile; + uploadedFile.status = "uploaded"; + uploadedFile.data = xhrResult.serverData; + uploadedFile.hash = xhrResult.fileHash; + uploadedFile.url = xhrResult.ufsUrl; + return uploadedFile; +} + +/** + * Modifies a pending or uploading file to a failed file in place + * @internal + */ +function transitionToFailed( + file: PendingFile | UploadingFile, + reason: UploadThingClientError, +): FailedFile { + const failedFile = file as unknown as FailedFile; + failedFile.status = "failed"; + failedFile.reason = reason; + return failedFile; +} + +/** + * Event emitted when the presigned URLs have been retrieved from your server + * @public + */ +export interface PresignedReceivedEvent { + type: "presigned-received"; + /** + * All files that are being uploaded as part of this action. + */ + files: AnyFile[]; +} + +/** + * Event emitted when a file starts uploading + * @public + */ +export interface UploadStartedEvent { + type: "upload-started"; + /** + * The file that started uploading. + */ + file: UploadingFile; + /** + * All files that are being uploaded as part of this action. + */ + files: AnyFile[]; +} + +/** + * Event emitted when a file is uploading and received a progress update + * @public + */ +export interface UploadProgressEvent { + type: "upload-progress"; + /** + * The file that is currently uploading and received a progress update. + */ + file: UploadingFile; + /** + * All files that are being uploaded as part of this action. + */ + files: AnyFile[]; +} + +/** + * Event emitted when a file has finished uploading + * @public + */ +export interface UploadCompletedEvent { + type: "upload-completed"; + /** + * The file that finished uploading. + */ + file: UploadedFile; + /** + * All files that are being uploaded as part of this action. + */ + files: AnyFile[]; +} + +/** + * Event emitted when a file failed to upload + * @public + */ +export interface UploadFailedEvent { + type: "upload-failed"; + /** + * The file that failed to upload. + */ + file: FailedFile; + /** + * All files that are being uploaded as part of this action. + */ + files: AnyFile[]; +} + +/** + * Event emitted throughout the upload process + * @public + */ +export type UploadEvent = + | PresignedReceivedEvent + | UploadStartedEvent + | UploadProgressEvent + | UploadCompletedEvent + | UploadFailedEvent; + +export interface UploadFileOptions { + file: PendingFile; + files: AnyFile[]; + input: TRoute["$types"]["input"]; + onEvent: (event: UploadEvent) => void; + + XHRImpl: new () => XMLHttpRequest; +} + +/** + * Upload a file to the storage provider + * Throughout the upload, the file's status and progress will be updated + * @remarks This function never rejects + * @internal + */ +export function uploadFile( + url: string, + { file, files, XHRImpl, ...options }: UploadFileOptions, +): Micro.Micro | FailedFile, never, FetchContext> { + return fetchEff(url, { method: "HEAD" }).pipe( + Micro.map(({ headers }) => + Number.parseInt(headers.get("x-ut-range-start") ?? "0"), + ), + Micro.map((rangeStart) => transitionToUploading(file, rangeStart)), + Micro.tap((uploadingFile) => { + options.onEvent({ + type: "upload-started", + file: uploadingFile, + files, + }); + }), + Micro.flatMap((uploadingFile) => + Micro.async, XHRError | UTStorageError>((resume) => { + const xhr = new XHRImpl(); + xhr.open("PUT", url, true); + + const rangeStart = uploadingFile.sent; + xhr.setRequestHeader("Range", `bytes=${rangeStart}-`); + xhr.setRequestHeader("x-uploadthing-version", version); + xhr.responseType = "json"; + + xhr.upload.addEventListener("progress", (ev) => { + uploadingFile.sent = rangeStart + ev.loaded; + options.onEvent({ + type: "upload-progress", + file: uploadingFile, + files, + }); + }); + xhr.addEventListener("load", () => { + if ( + xhr.status > 299 || + Predicate.hasProperty(xhr.response, "error") + ) { + resume( + new UTStorageError({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: String(xhr.response.error), + response: xhr.response, + }), + ); + } else { + const uploadedFile = transitionToUploaded( + uploadingFile, + xhr.response as UploadPutResult, + ); + options.onEvent({ + type: "upload-completed", + file: uploadedFile, + files, + }); + resume(Micro.succeed(uploadedFile)); + } + }); + xhr.addEventListener("error", () => { + resume( + new XHRError({ + message: `XHR failed ${xhr.status} ${xhr.statusText}`, + xhr: xhr, + }), + ); + }); + + const formData = new FormData(); + /** + * iOS/React Native FormData handling requires special attention: + * + * Issue: In React Native, iOS crashes with "attempt to insert nil object" when appending File directly + * to FormData. This happens because iOS tries to create NSDictionary from the file object and expects + * specific structure {uri, type, name}. + * + * + * Note: Don't try to use Blob or modify File object - iOS specifically needs plain object + * with these properties to create valid NSDictionary. + */ + if ("uri" in file) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + formData.append("file", { + uri: file.uri as string, + type: file.type, + name: file.name, + ...(rangeStart > 0 && { range: rangeStart }), + } as any); + } else { + formData.append( + "file", + rangeStart > 0 ? file.slice(rangeStart) : file, + ); + } + xhr.send(formData); + + return Micro.sync(() => xhr.abort()); + }), + ), + Micro.catchAll((error) => { + const failedFile = transitionToFailed(file, error); + options.onEvent({ + type: "upload-failed", + file: failedFile, + files, + }); + return Micro.succeed(failedFile); + }), + ); +} + +export interface RequestPresignedUrlsOptions< + TRouter extends AnyFileRouter, + TEndpoint extends keyof TRouter, +> { + /** + * The URL to your UploadThing server endpoint + * @example URL { https://www.example.com/api/uploadthing } + */ + url: URL; + /** + * The slug to your UploadThing FileRoute + * @example "imageUploader" + */ + endpoint: TEndpoint; + /** + * The files to request presigned URLs for + */ + files: File[]; + /** + * The route input for the endpoint + */ + input?: TRouter[TEndpoint]["$types"]["input"]; + /** + * Custom headers to send with the request + * @example { Authorization: "Bearer 123" } + */ + headers?: HeadersInit | LazyArg> | undefined; + /** + * The uploadthing package that is making this request, used to identify the client in the server logs + * @example "@uploadthing/react" + */ + package?: string | undefined; +} + +/** + * Request presigned URLs from your server for a set of files + * @internal + */ +export function requestPresignedUrls< + TRouter extends AnyFileRouter, + TEndpoint extends keyof TRouter, +>( + options: RequestPresignedUrlsOptions, +): Micro.Micro< + ReadonlyArray, + UTServerError, + FetchContext +> { + const reportEventToUT = createUTReporter({ + endpoint: String(options.endpoint), + package: options.package, + url: options.url, + headers: options.headers, + }); + + return reportEventToUT("upload", { + input: options.input, + files: options.files.map((f) => ({ + name: f.name, + size: f.size, + type: f.type, + lastModified: f.lastModified, + })), + }).pipe( + Micro.mapError( + (error) => + new UTServerError({ + message: error.message, + cause: error, + data: error.data, + }), + ), + ); +} + +export interface UploadFilesOptions { + url: URL; + files: File[]; + input?: TRoute["$types"]["input"]; + onEvent: (event: UploadEvent) => void; + headers?: HeadersInit | LazyArg> | undefined; + package?: string | undefined; + signal?: AbortSignal | undefined; +} + +/** + * Upload a set of files to the storage provider + * @internal + */ +export function uploadFiles< + TRouter extends AnyFileRouter, + TEndpoint extends keyof TRouter, +>(endpoint: TEndpoint, options: UploadFilesOptions) { + const pendingFiles = options.files.map(makePendingFile); + + return requestPresignedUrls({ + endpoint: endpoint, + files: options.files, + url: options.url, + input: options.input, + headers: options.headers, + package: options.package, + }).pipe( + Micro.map(Array.zip(pendingFiles)), + Micro.tap((pairs) => { + for (const [presigned, file] of pairs) { + file.key = presigned.key; + file.customId = presigned.customId; + } + options.onEvent({ + type: "presigned-received", + files: pendingFiles, + }); + }), + Micro.flatMap((pairs) => + Micro.forEach( + pairs, + ([presigned, file]) => + uploadFile(presigned.url, { + file, + files: pendingFiles, + input: options.input, + onEvent: options.onEvent, + XHRImpl: globalThis.XMLHttpRequest, + }), + { concurrency: 6 }, + ), + ), + ); +} diff --git a/packages/uploadthing/src/_internal/ut-reporter.ts b/packages/uploadthing/src/_internal/ut-reporter.ts index 48941de6c8..cfd8f8ad54 100644 --- a/packages/uploadthing/src/_internal/ut-reporter.ts +++ b/packages/uploadthing/src/_internal/ut-reporter.ts @@ -46,7 +46,7 @@ export const createUTReporter = (cfg: { url: URL; endpoint: string; - package: string; + package?: string | undefined; headers: HeadersInit | (() => MaybePromise) | undefined; }): UTReporter => (type, payload) => @@ -61,7 +61,9 @@ export const createUTReporter = typeof cfg.headers === "function" ? await cfg.headers() : cfg.headers, ), ); - headers.set("x-uploadthing-package", cfg.package); + if (cfg.package) { + headers.set("x-uploadthing-package", cfg.package); + } headers.set("x-uploadthing-version", pkgJson.version); headers.set("Content-Type", "application/json"); diff --git a/packages/uploadthing/src/client-future.ts b/packages/uploadthing/src/client-future.ts new file mode 100644 index 0000000000..de01f26534 --- /dev/null +++ b/packages/uploadthing/src/client-future.ts @@ -0,0 +1,255 @@ +import { Array } from "effect"; +import * as Arr from "effect/Array"; +import * as Micro from "effect/Micro"; + +import type { FetchEsque } from "@uploadthing/shared"; +import { + createIdentityProxy, + FetchContext, + resolveMaybeUrlArg, + UploadAbortedError, + UploadPausedError, +} from "@uploadthing/shared"; + +import * as pkgJson from "../package.json"; +import type { + AnyFile, + FailedFile, + PendingFile, + UploadedFile, + UploadFilesOptions, +} from "./_internal/client-future"; +import { + makePendingFile, + requestPresignedUrls, + uploadFile, +} from "./_internal/client-future"; +import type { Deferred } from "./_internal/deferred"; +import { createDeferred } from "./_internal/deferred"; +import type { + EndpointArg, + FileRouter, + GenerateUploaderOptions, + inferEndpointInput, + NewPresignedUrl, + RouteRegistry, +} from "./types"; + +export const version = pkgJson.version; + +export { + /** @public */ + generateClientDropzoneAccept, + /** @public */ + generateMimeTypes, + /** @public */ + generatePermittedFileTypes, + /** @public */ + UploadAbortedError, + /** @public */ + UploadPausedError, +} from "@uploadthing/shared"; + +export * from "./_internal/client-future"; + +/** + * Generate a typed uploader for a given FileRouter + * @public + * @remarks This API is not covered by semver + */ +export const future_genUploader = ( + initOpts?: GenerateUploaderOptions, +) => { + const routeRegistry = createIdentityProxy>(); + + const controllableUpload = async ( + slug: EndpointArg, + options: Omit< + UploadFilesOptions, + keyof GenerateUploaderOptions + >, + ) => { + const endpoint = typeof slug === "function" ? slug(routeRegistry) : slug; + const fetchFn: FetchEsque = initOpts?.fetch ?? window.fetch; + + const pExit = await requestPresignedUrls({ + endpoint: String(endpoint), + files: options.files, + url: resolveMaybeUrlArg(initOpts?.url), + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + input: (options as any).input as inferEndpointInput, + headers: options.headers, + }).pipe(Micro.provideService(FetchContext, fetchFn), (effect) => + Micro.runPromiseExit( + effect, + options.signal && { signal: options.signal }, + ), + ); + if (pExit._tag === "Failure") throw Micro.causeSquash(pExit.cause); + const presigneds = pExit.value; + const pendingFiles = options.files.map(makePendingFile); + + options.onEvent({ + type: "presigned-received", + files: pendingFiles, + }); + + const uploads = new Map< + File, + { + presigned: NewPresignedUrl; + deferred: Deferred>; + } + >(); + + const uploadEffect = (file: PendingFile, presigned: NewPresignedUrl) => + uploadFile(presigned.url, { + file, + files: pendingFiles, + input: options.input, + onEvent: options.onEvent, + XHRImpl: globalThis.XMLHttpRequest, + }).pipe(Micro.provideService(FetchContext, fetchFn)); + + for (const [presigned, file] of Array.zip(presigneds, pendingFiles)) { + file.key = presigned.key; + file.customId = presigned.customId; + + const deferred = createDeferred>(); + uploads.set(file, { presigned, deferred }); + + void Micro.runPromiseExit(uploadEffect(file, presigned), { + signal: deferred.ac.signal, + }) + .then((result) => { + if (result._tag === "Success") { + return deferred.resolve(result.value); + } else if (result.cause._tag === "Interrupt") { + throw new UploadPausedError(); + } + throw Micro.causeSquash(result.cause); + }) + .catch((err) => { + if (err instanceof UploadPausedError) return; + deferred.reject(err); + }); + } + + /** + * Pause an ongoing upload + * @param file The file upload you want to pause. Can be omitted to pause all files + */ + const pauseUpload = (file?: File) => { + const files = Arr.ensure(file ?? options.files); + for (const file of files) { + const upload = uploads.get(file); + if (!upload) return; + + if (upload.deferred.ac.signal.aborted) { + // Noop if it's already paused + return; + } + + upload.deferred.ac.abort(); + } + }; + + /** + * Abort an upload + * @param file The file upload you want to abort. Can be omitted to abort all files + */ + const abortUpload = (file?: File) => { + const files = Arr.ensure(file ?? options.files); + for (const file of files) { + const upload = uploads.get(file); + if (!upload) throw "No upload found"; + + if (upload.deferred.ac.signal.aborted === false) { + // Ensure the upload is paused + upload.deferred.ac.abort(); + } + } + + // Abort the upload + throw new UploadAbortedError(); + }; + + /** + * Resume a paused upload + * @param file The file upload you want to resume. Can be omitted to resume all files + */ + const resumeUpload = (file?: File) => { + const files = Arr.ensure(file ?? options.files); + for (const file of files) { + const upload = uploads.get(file); + if (!upload) throw "No upload found"; + + upload.deferred.ac = new AbortController(); + void Micro.runPromiseExit( + uploadEffect(file as PendingFile, upload.presigned), + { + signal: upload.deferred.ac.signal, + }, + ) + .then((result) => { + if (result._tag === "Success") { + return upload.deferred.resolve(result.value); + } else if (result.cause._tag === "Interrupt") { + throw new UploadPausedError(); + } + throw Micro.causeSquash(result.cause); + }) + .catch((err) => { + if (err instanceof UploadPausedError) return; + upload.deferred.reject(err); + }); + } + }; + + /** + * Wait for an upload to complete + * @param file The file upload you want to wait for. Can be omitted to wait for all files + */ + const done = async | void = void>( + file?: T, + ): Promise< + T extends AnyFile + ? UploadedFile | FailedFile + : (UploadedFile | FailedFile)[] + > => { + const promises = []; + + const files = Arr.ensure(file ?? options.files); + for (const file of files) { + const upload = uploads.get(file); + if (!upload) throw "No upload found"; + + promises.push(upload.deferred.promise); + } + + const results = await Promise.all(promises); + return (file ? results[0] : results) as never; + }; + + return { pauseUpload, abortUpload, resumeUpload, done }; + }; + + const uploadFiles = ( + slug: EndpointArg, + opts: Omit< + UploadFilesOptions, + keyof GenerateUploaderOptions + >, + ) => controllableUpload(slug, opts).then((_) => _.done()); + + return { + uploadFiles, + createUpload: controllableUpload, + /** + * Identity object that can be used instead of raw strings + * that allows "Go to definition" in your IDE to bring you + * to the backend definition of a route. + */ + routeRegistry, + }; +}; diff --git a/packages/uploadthing/src/client.ts b/packages/uploadthing/src/client.ts index 811b421220..a77a46de97 100644 --- a/packages/uploadthing/src/client.ts +++ b/packages/uploadthing/src/client.ts @@ -41,6 +41,10 @@ export { /** @public */ generatePermittedFileTypes, /** @public */ + bytesToFileSize, + /** @public */ + allowedContentTextLabelGenerator, + /** @public */ UploadAbortedError, /** @public */ UploadPausedError, diff --git a/packages/uploadthing/turbo.json b/packages/uploadthing/turbo.json index eebdb6dd52..45186d17c4 100644 --- a/packages/uploadthing/turbo.json +++ b/packages/uploadthing/turbo.json @@ -5,6 +5,7 @@ "build": { "outputs": [ "client/**", + "client-future/**", "dist/**", "effect-platform/**", "express/**", diff --git a/playground/app/api/uploadthing/route.ts b/playground/app/api/uploadthing/route.ts index 332a9bf69c..19b327d647 100644 --- a/playground/app/api/uploadthing/route.ts +++ b/playground/app/api/uploadthing/route.ts @@ -14,7 +14,48 @@ import { getSession } from "../../../lib/data"; const fileRoute = createUploadthing(); export const uploadRouter = { - anything: fileRoute({ blob: { maxFileSize: "256MB" } }) + anyPrivate: fileRoute({ + blob: { maxFileSize: "256MB", maxFileCount: 10, acl: "private" }, + }) + .input(z.object({})) + .middleware(async (opts) => { + const session = await getSession(); + if (!session) { + throw new UploadThingError("Unauthorized"); + } + + console.log("middleware ::", session.sub, opts.input); + + return {}; + }) + .onUploadComplete(async (opts) => { + console.log("Upload complete", opts.file); + revalidateTag(CACHE_TAGS.LIST_FILES); + }), + + anyPublic: fileRoute({ + blob: { maxFileSize: "256MB", maxFileCount: 10, acl: "public-read" }, + }) + .input(z.object({})) + .middleware(async (opts) => { + const session = await getSession(); + if (!session) { + throw new UploadThingError("Unauthorized"); + } + + console.log("middleware ::", session.sub, opts.input); + + return {}; + }) + .onUploadComplete(async (opts) => { + console.log("Upload complete", opts.file); + revalidateTag(CACHE_TAGS.LIST_FILES); + }), + + images: fileRoute( + { image: { maxFileSize: "256MB", maxFileCount: 10, acl: "public-read" } }, + { awaitServerData: false }, + ) .input(z.object({})) .middleware(async (opts) => { const session = await getSession(); diff --git a/playground/app/future/page.tsx b/playground/app/future/page.tsx new file mode 100644 index 0000000000..8a2dcb900d --- /dev/null +++ b/playground/app/future/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useState, useTransition } from "react"; + +import { AnyFile } from "uploadthing/client-future"; + +import { future_createUpload, future_uploadFiles } from "../../lib/uploadthing"; +import { UploadRouter } from "../api/uploadthing/route"; + +function AsyncUploader() { + const [files, setFiles] = useState[]>([]); + + return ( +
+
{ + const files = formData.getAll("files") as File[]; + console.log("SUBMITTED", files); + + const result = await future_uploadFiles("anyPrivate", { + files, + onEvent: (event) => { + console.log("EVENT", event); + setFiles([...event.files]); + }, + input: {}, + }); + + console.log("COMPLETE", result); + }} + > + + +
+ +
+        Files: {JSON.stringify(files, null, 2)}
+      
+
+ ); +} + +function ControlledUploader() { + const [files, setFiles] = useState[]>([]); + const [selectedFiles, setSelectedFiles] = useState([]); + const [uploadControls, setUploadControls] = useState + > | null>(null); + const [isUploading, setIsUploading] = useState(false); + const [isPending, startTransition] = useTransition(); + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + setSelectedFiles(Array.from(e.target.files)); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (selectedFiles.length === 0) return; + + startTransition(async () => { + setIsUploading(true); + + // Create uploads for each file + const controls = await future_createUpload("anyPrivate", { + files: selectedFiles, + onEvent: (event) => { + console.log("EVENT", event); + setFiles([...event.files]); + }, + input: {}, + }); + + setUploadControls(controls); + }); + }; + + const handleComplete = () => { + if (uploadControls?.done) { + startTransition(async () => { + const result = await uploadControls.done(); + console.log("COMPLETE", result); + setIsUploading(false); + setSelectedFiles([]); + setFiles([]); + setUploadControls(null); + alert("Upload complete!"); + }); + } + }; + + return ( +
+
+ +
+ + {isUploading && uploadControls && ( + + )} +
+
+ + {files.length > 0 && ( +
+

Files

+ {files.map((file, index) => ( +
+
+ {file.name} +
+ + +
+
+
+
+
+
+
+ {Math.round((file.sent / file.size) * 100)}% +
+
+
+ ))} +
+ )} + +
+        Files: {JSON.stringify(files, null, 2)}
+      
+
+ ); +} + +export default function FuturePage() { + const [mode, setMode] = useState<"async" | "controlled">("async"); + + return ( +
+

Future

+

+ A place to test stuff not yet on stable channel +

+ +
+ + +
+ + {mode === "async" ? : } +
+ ); +} diff --git a/playground/app/global.css b/playground/app/global.css index ee429164fd..665e7fdb3c 100644 --- a/playground/app/global.css +++ b/playground/app/global.css @@ -2,3 +2,99 @@ @import "uploadthing/tw/v4"; @source "../node_modules/@uploadthing/react/dist"; + +:root { + --background: oklch(1 0 0); /* --color-white */ + --foreground: oklch(0.141 0.005 285.823); /* --color-zinc-950 */ + --card: oklch(1 0 0); /* --color-white */ + --card-foreground: oklch(0.141 0.005 285.823); /* --color-zinc-950 */ + --popover: oklch(1 0 0); /* --color-white */ + --popover-foreground: oklch(0.141 0.005 285.823); /* --color-zinc-950 */ + --primary: oklch(0.21 0.006 285.885); /* --color-zinc-900 */ + --primary-foreground: oklch(0.985 0 0); /* --color-zinc-50 */ + --secondary: oklch(0.967 0.001 286.375); /* --color-zinc-100 */ + --secondary-foreground: oklch(0.21 0.006 285.885); /* --color-zinc-900 */ + --muted: oklch(0.967 0.001 286.375); /* --color-zinc-100 */ + --muted-foreground: oklch(0.55 0.01 286); /* --color-zinc-500 */ + --accent: oklch(0.967 0.001 286.375); /* --color-zinc-100 */ + --accent-foreground: oklch(0.21 0.006 285.885); /* --color-zinc-900 */ + --destructive: oklch(0.637 0.237 25.331); /* --color-red-500 */ + --destructive-foreground: oklch(0.637 0.237 25.331); /* --color-red-500 */ + --border: oklch(0.92 0 286); /* --color-zinc-200 */ + --input: oklch(0.871 0.006 286.286); /* --color-zinc-300 */ + --ring: oklch(0.871 0.006 286.286); /* --color-zinc-300 */ + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); /* --color-zinc-50 */ + --sidebar-foreground: oklch(0.141 0.005 285.823); /* --color-zinc-950 */ + --sidebar-primary: oklch(0.21 0.006 285.885); /* --color-zinc-900 */ + --sidebar-primary-foreground: oklch(0.985 0 0); /* --color-zinc-50 */ + --sidebar-accent: oklch(0.967 0.001 286.375); /* --color-zinc-100 */ + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); /* --color-zinc-900 */ + --sidebar-border: oklch(0.92 0.004 286.32); /* --color-zinc-200 */ + --sidebar-ring: oklch(0.871 0.006 286.286); /* --color-zinc-300 */ +} + +@theme inline { + --font-sans: var(--font-sans); + --font-heading: var(--font-heading); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + --animate-collapsible-down: collapsible-down 0.2s ease-out; + --animate-collapsible-up: collapsible-up 0.2s ease-out; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/playground/app/originui/page.tsx b/playground/app/originui/page.tsx new file mode 100644 index 0000000000..3ebd112cdb --- /dev/null +++ b/playground/app/originui/page.tsx @@ -0,0 +1,843 @@ +"use client"; + +import * as React from "react"; +import { + CheckCircleIcon, + CheckIcon, + FileArchiveIcon, + FileIcon, + FileSpreadsheetIcon, + FileTextIcon, + HeadphonesIcon, + ImageIcon, + Loader2Icon, + PlusIcon, + Trash2Icon, + UploadIcon, + VideoIcon, + XIcon, +} from "lucide-react"; +import { twMerge } from "tailwind-merge"; + +import { + allowedContentTextLabelGenerator, + bytesToFileSize, +} from "uploadthing/client"; +import { AnyFile } from "uploadthing/client-future"; +import { AnyFileRoute } from "uploadthing/types"; + +import { Button, buttonVariants } from "../../components/button"; +import { + future_uploadFiles, + routeRegistry, + useUploadThingDropzone, +} from "../../lib/uploadthing"; + +function Header() { + return ( +
+

+ UploadThing x Origin UI +

+

+ A collection of examples of using UploadThing with{" "} + + Origin UI + +

+
+ ); +} + +function Grid(props: { children: React.ReactNode }) { + return ( +
+ {props.children} +
+ ); +} + +function GridItem(props: { children: React.ReactNode }) { + return ( +
+ {props.children} +
+ ); +} + +export default function Page() { + return ( + <> +
+ + + + + + + + + + + + + + + + ); +} + +function Comp546() { + const [isPending, startTransition] = React.useTransition(); + + const { + getRootProps, + getInputProps, + isDragActive, + routeConfig, + files, + removeFile, + setFiles, + } = useUploadThingDropzone({ + route: routeRegistry.images, + disabled: isPending, + }); + + const uploadFiles = () => { + startTransition(async () => { + await future_uploadFiles((rr) => rr.images, { + files: files.filter((f) => f.status === "pending"), + input: {}, + onEvent: (event) => { + setFiles((prev) => + prev.map((f) => + "file" in event && f === event.file ? event.file : f, + ), + ); + }, + }); + }); + }; + + return ( +
+ {/* Drop area */} +
0 || undefined} + className="border-input data-[dragging=true]:bg-accent/50 has-[input:focus]:border-ring has-[input:focus]:ring-ring/50 not-data-[files]:justify-center relative flex min-h-52 flex-col items-center overflow-hidden rounded-xl border border-dashed p-4 transition-colors has-[input:focus]:ring-[3px]" + > + {files.length > 0 ? ( +
+
+

+ Files ({files.length}) +

+
+ + +
+
+ +
+ {files.map((file, index) => ( +
+ {file.name} +
+ {file.status === "uploaded" ? ( +
+ +
+ ) : file.status === "uploading" || + (file.status === "pending" && file.key) ? ( +
+
+ ) : ( + + )} +
+
+ ))} +
+
+ ) : ( +
+ +

Drop your images here

+

+ {allowedContentTextLabelGenerator(routeConfig)} +

+ +
+ )} +
+
+ ); +} + +function Comp547() { + const [isPending, startTransition] = React.useTransition(); + const { + getRootProps, + getInputProps, + isDragActive, + routeConfig, + files, + clearFiles, + removeFile, + setFiles, + } = useUploadThingDropzone({ + route: routeRegistry.images, + disabled: isPending, + }); + + const uploadFiles = () => { + startTransition(async () => { + await future_uploadFiles((rr) => rr.images, { + files: files.filter((f) => f.status === "pending"), + input: {}, + onEvent: (event) => { + setFiles((prev) => + prev.map((f) => + "file" in event && f === event.file ? event.file : f, + ), + ); + }, + }); + }); + }; + + return ( +
+ {/* Drop area */} +
0 || undefined} + className="border-input data-[dragging=true]:bg-accent/50 has-[input:focus]:border-ring has-[input:focus]:ring-ring/50 not-data-[files]:justify-center relative flex min-h-52 flex-col items-center overflow-hidden rounded-xl border border-dashed p-4 transition-colors has-[input:focus]:ring-[3px]" + > +
+ +

Drop your images here

+

+ {allowedContentTextLabelGenerator(routeConfig)} +

+ +
+
+ + {/* File list */} + {files.length > 0 && ( +
+ {files.map((file, index) => ( +
+
+
+ {file.name} +
+
+

+ {file.name} +

+

+ {bytesToFileSize(file.size)} +

+
+
+ + {file.status === "uploaded" ? ( +
+ ))} + + {/* Controls */} + {files.length > 0 && ( +
+ + +
+ )} +
+ )} +
+ ); +} + +const getFileIcon = (file: AnyFile) => { + const iconMap = { + pdf: { + icon: FileTextIcon, + conditions: (type: string, name: string) => + type.includes("pdf") || + name.endsWith(".pdf") || + type.includes("word") || + name.endsWith(".doc") || + name.endsWith(".docx"), + }, + archive: { + icon: FileArchiveIcon, + conditions: (type: string, name: string) => + type.includes("zip") || + type.includes("archive") || + name.endsWith(".zip") || + name.endsWith(".rar"), + }, + excel: { + icon: FileSpreadsheetIcon, + conditions: (type: string, name: string) => + type.includes("excel") || + name.endsWith(".xls") || + name.endsWith(".xlsx"), + }, + video: { + icon: VideoIcon, + conditions: (type: string) => type.includes("video/"), + }, + audio: { + icon: HeadphonesIcon, + conditions: (type: string) => type.includes("audio/"), + }, + image: { + icon: ImageIcon, + conditions: (type: string) => type.startsWith("image/"), + }, + }; + + for (const { icon: Icon, conditions } of Object.values(iconMap)) { + if (conditions(file.type, file.name)) { + return ; + } + } + + return ; +}; + +const getFilePreview = (file: AnyFile & { preview?: string }) => { + const renderImage = (src: string) => ( + {file.name} + ); + + const src = file.status === "uploaded" ? file.url : file.preview; + + return ( +
+ {file.type.startsWith("image/") && src + ? renderImage(src) + : getFileIcon(file)} +
+ ); +}; + +function Comp552() { + const [isPending, startTransition] = React.useTransition(); + const { + getRootProps, + getInputProps, + isDragActive, + routeConfig, + files, + clearFiles, + removeFile, + setFiles, + } = useUploadThingDropzone({ + route: routeRegistry.anyPublic, + disabled: isPending, + }); + + const uploadFiles = () => { + startTransition(async () => { + await future_uploadFiles((rr) => rr.anyPublic, { + files: files.filter((f) => f.status === "pending"), + input: {}, + onEvent: (event) => { + setFiles((prev) => + prev.map((f) => + "file" in event && f === event.file ? event.file : f, + ), + ); + }, + }); + }); + }; + + return ( +
+ {/* Drop area */} +
0 || undefined} + className="border-input data-[dragging=true]:bg-accent/50 has-[input:focus]:border-ring has-[input:focus]:ring-ring/50 not-data-[files]:justify-center relative flex min-h-52 flex-col items-center overflow-hidden rounded-xl border border-dashed p-4 transition-colors has-[input:focus]:ring-[3px]" + > + {files.length > 0 ? ( +
+
+

+ Files ({files.length}) +

+
+ + + +
+
+ +
+ {files.map((file, index) => ( +
+ {getFilePreview(file)} +
+ {file.status === "uploaded" ? ( +
+ +
+ ) : file.status === "uploading" || + (file.status === "pending" && file.key) ? ( +
+
+ ) : ( + + )} +
+
+

+ {file.name} +

+

+ {bytesToFileSize(file.size)} +

+
+
+ ))} +
+
+ ) : ( +
+ +

Drop your files here

+

+ {allowedContentTextLabelGenerator(routeConfig)} +

+ +
+ )} +
+
+ ); +} + +function Comp553() { + const [isPending, startTransition] = React.useTransition(); + + const { + routeConfig, + getRootProps, + getInputProps, + isDragActive, + files, + removeFile, + clearFiles, + setFiles, + } = useUploadThingDropzone({ + route: routeRegistry.anyPublic, + disabled: isPending, + }); + + const uploadFiles = () => { + startTransition(async () => { + await future_uploadFiles((rr) => rr.anyPublic, { + files: files.filter((f) => f.status === "pending"), + input: {}, + onEvent: (event) => { + setFiles((prev) => + prev.map((f) => + "file" in event && f === event.file ? event.file : f, + ), + ); + }, + }); + }); + }; + + return ( +
+ {/* Drop area */} +
0 || undefined} + className="border-input data-[dragging=true]:bg-accent/50 has-[input:focus]:border-ring has-[input:focus]:ring-ring/50 not-data-[files]:justify-center relative flex min-h-52 flex-col items-center overflow-hidden rounded-xl border border-dashed p-4 transition-colors has-[input:focus]:ring-[3px]" + > + {files.length > 0 ? ( +
+
+

+ Files ({files.length}) +

+
+ + + +
+
+ +
+ {files.map((file, index) => ( +
+
+
+
+ {getFilePreview(file)} +
+
+

+ {file.name} +

+

+ {bytesToFileSize(file.size)} +

+
+
+ + {file.status === "uploaded" ? ( +
+ + {/* Upload progress bar */} + {(file.status === "uploading" || + (file.status === "pending" && file.key)) && + (() => { + const progress = Math.round( + (file.sent / file.size) * 100, + ); + + return ( +
+
+
+
+ + {progress}% + +
+ ); + })()} +
+ ))} +
+
+ ) : ( +
+ +

Drop your files here

+

+ {allowedContentTextLabelGenerator(routeConfig)} +

+ +
+ )} +
+
+ ); +} diff --git a/playground/components/button.tsx b/playground/components/button.tsx index 0f0923c028..3fc3bc4cba 100644 --- a/playground/components/button.tsx +++ b/playground/components/button.tsx @@ -1,26 +1,48 @@ -import cx from "clsx"; +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { twMerge } from "tailwind-merge"; -const colors = { - blue: "bg-blue-600 hover:bg-blue-700 text-blue-50", - red: "bg-red-600 hover:bg-red-700 text-red-50", - lightgray: "bg-gray-300 hover:bg-gray-400 text-gray-950", - outline: "bg-transparent border hover:bg-gray-100", -}; -type Color = keyof typeof colors; - -export function Button( - props: React.ComponentProps<"button"> & { - color?: Color; +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: + "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, }, -) { +); + +export function Button({ + className, + variant, + size, + ...props +}: React.ComponentProps<"button"> & VariantProps) { return (