UNPKG

uploadthing

Version:

Learn more: [docs.uploadthing.com](https://docs.uploadthing.com)

319 lines (312 loc) 14 kB
import * as Arr from 'effect/Array'; import * as Micro from 'effect/Micro'; import { fetchEff, parseResponseJson, UploadThingError, getErrorTypeFromStatusCode, FetchContext, matchFileType, objectKeys, fileSizeToBytes, createIdentityProxy, resolveMaybeUrlArg, UploadPausedError, UploadAbortedError } from '@uploadthing/shared'; export { UploadAbortedError, UploadPausedError, generateClientDropzoneAccept, generateMimeTypes, generatePermittedFileTypes } from '@uploadthing/shared'; import { unsafeCoerce } from 'effect/Function'; var version$1 = "7.4.4"; const createDeferred = ()=>{ let resolve; let reject; const ac = new AbortController(); const promise = new Promise((res, rej)=>{ resolve = res; reject = rej; }); return { promise, ac, resolve, reject }; }; const createAPIRequestUrl = (config)=>{ const url = new URL(config.url); const queryParams = new URLSearchParams(url.search); queryParams.set("actionType", config.actionType); queryParams.set("slug", config.slug); url.search = queryParams.toString(); return url; }; /** * Creates a "client" for reporting events to the UploadThing server via the user's API endpoint. * Events are handled in "./handler.ts starting at L112" */ const createUTReporter = (cfg)=>(type, payload)=>Micro.gen(function*() { const url = createAPIRequestUrl({ url: cfg.url, slug: cfg.endpoint, actionType: type }); const headers = new Headers((yield* Micro.promise(async ()=>typeof cfg.headers === "function" ? await cfg.headers() : cfg.headers))); headers.set("x-uploadthing-package", cfg.package); headers.set("x-uploadthing-version", version$1); headers.set("Content-Type", "application/json"); const response = yield* fetchEff(url, { method: "POST", body: JSON.stringify(payload), headers }).pipe(Micro.andThen(parseResponseJson), /** * We don't _need_ to validate the response here, just cast it for now. * As of now, @effect/schema includes quite a few bytes we cut out by this... * We have "strong typing" on the backend that ensures the shape should match. */ Micro.map(unsafeCoerce), Micro.catchTag("FetchError", (e)=>Micro.fail(new UploadThingError({ code: "INTERNAL_CLIENT_ERROR", message: `Failed to report event "${type}" to UploadThing server`, cause: e }))), Micro.catchTag("BadRequestError", (e)=>Micro.fail(new UploadThingError({ code: getErrorTypeFromStatusCode(e.status), message: e.getMessage(), cause: e.json }))), Micro.catchTag("InvalidJson", (e)=>Micro.fail(new UploadThingError({ code: "INTERNAL_CLIENT_ERROR", message: "Failed to parse response from UploadThing server", cause: e })))); return response; }); const uploadWithProgress = (file, rangeStart, presigned, onUploadProgress)=>Micro.async((resume)=>{ const xhr = new XMLHttpRequest(); xhr.open("PUT", presigned.url, true); xhr.setRequestHeader("Range", `bytes=${rangeStart}-`); xhr.setRequestHeader("x-uploadthing-version", version$1); xhr.responseType = "json"; let previousLoaded = 0; xhr.upload.addEventListener("progress", ({ loaded })=>{ const delta = loaded - previousLoaded; onUploadProgress?.({ loaded, delta }); previousLoaded = loaded; }); xhr.addEventListener("load", ()=>{ resume(xhr.status >= 200 && xhr.status < 300 ? Micro.succeed(xhr.response) : Micro.die(`XHR failed ${xhr.status} ${xhr.statusText} - ${JSON.stringify(xhr.response)}`)); }); // Is there a case when the client would throw and // ingest server not knowing about it? idts? xhr.addEventListener("error", ()=>{ resume(new UploadThingError({ code: "UPLOAD_FAILED" })); }); const formData = new FormData(); formData.append("file", rangeStart > 0 ? file.slice(rangeStart) : file); xhr.send(formData); return Micro.sync(()=>xhr.abort()); }); const uploadFile = (file, presigned, opts)=>fetchEff(presigned.url, { method: "HEAD" }).pipe(Micro.map(({ headers })=>parseInt(headers.get("x-ut-range-start") ?? "0", 10)), Micro.tap((start)=>opts.onUploadProgress?.({ delta: start, loaded: start })), Micro.flatMap((start)=>uploadWithProgress(file, start, presigned, (progressEvent)=>opts.onUploadProgress?.({ delta: progressEvent.delta, loaded: progressEvent.loaded + start }))), Micro.map(unsafeCoerce), Micro.map((uploadResponse)=>({ name: file.name, size: file.size, key: presigned.key, lastModified: file.lastModified, serverData: uploadResponse.serverData, url: uploadResponse.url, appUrl: uploadResponse.appUrl, customId: presigned.customId, type: file.type, fileHash: uploadResponse.fileHash }))); const uploadFilesInternal = (endpoint, opts)=>{ // classic service right here const reportEventToUT = createUTReporter({ endpoint: String(endpoint), package: opts.package, url: opts.url, headers: opts.headers }); const totalSize = opts.files.reduce((acc, f)=>acc + f.size, 0); let totalLoaded = 0; return reportEventToUT("upload", { input: "input" in opts ? opts.input : null, files: opts.files.map((f)=>({ name: f.name, size: f.size, type: f.type, lastModified: f.lastModified })) }).pipe(Micro.flatMap((presigneds)=>Micro.forEach(presigneds, (presigned, i)=>Micro.flatMap(Micro.sync(()=>opts.onUploadBegin?.({ file: opts.files[i].name })), ()=>uploadFile(opts.files[i], presigned, { onUploadProgress: (ev)=>{ totalLoaded += ev.delta; opts.onUploadProgress?.({ file: opts.files[i], progress: Math.round(ev.loaded / opts.files[i].size * 100), loaded: ev.loaded, delta: ev.delta, totalLoaded, totalProgress: Math.round(totalLoaded / totalSize * 100) }); } })), { concurrency: 6 })), Micro.provideService(FetchContext, window.fetch)); }; const version = version$1; /** * Validate that a file is of a valid type given a route config * @public */ const isValidFileType = (file, routeConfig)=>Micro.runSync(matchFileType(file, objectKeys(routeConfig)).pipe(Micro.map((type)=>file.type.includes(type)), Micro.orElseSucceed(()=>false))); /** * Validate that a file is of a valid size given a route config * @public */ const isValidFileSize = (file, routeConfig)=>Micro.runSync(matchFileType(file, objectKeys(routeConfig)).pipe(Micro.flatMap((type)=>fileSizeToBytes(routeConfig[type].maxFileSize)), Micro.map((maxFileSize)=>file.size <= maxFileSize), Micro.orElseSucceed(()=>false))); /** * Generate a typed uploader for a given FileRouter * @public */ const genUploader = (initOpts)=>{ const routeRegistry = createIdentityProxy(); const controllableUpload = async (slug, opts)=>{ const uploads = new Map(); const endpoint = typeof slug === "function" ? slug(routeRegistry) : slug; const utReporter = createUTReporter({ endpoint: String(endpoint), package: initOpts.package, url: resolveMaybeUrlArg(initOpts?.url), headers: opts.headers }); const presigneds = await Micro.runPromise(utReporter("upload", { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment input: "input" in opts ? opts.input : null, files: opts.files.map((f)=>({ name: f.name, size: f.size, type: f.type, lastModified: f.lastModified })) }).pipe(Micro.provideService(FetchContext, window.fetch))); const totalSize = opts.files.reduce((acc, f)=>acc + f.size, 0); let totalLoaded = 0; const uploadEffect = (file, presigned)=>uploadFile(file, presigned, { onUploadProgress: (progressEvent)=>{ totalLoaded += progressEvent.delta; opts.onUploadProgress?.({ ...progressEvent, file, progress: Math.round(progressEvent.loaded / file.size * 100), totalLoaded, totalProgress: Math.round(totalLoaded / totalSize * 100) }); } }).pipe(Micro.provideService(FetchContext, window.fetch)); for (const [i, p] of presigneds.entries()){ const file = opts.files[i]; const deferred = createDeferred(); uploads.set(file, { deferred, presigned: p }); void Micro.runPromiseExit(uploadEffect(file, p), { 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)=>{ const files = Arr.ensure(file ?? opts.files); for (const file of files){ const upload = uploads.get(file); if (!upload) return; if (upload.deferred.ac.signal.aborted) { // Cancel the upload if it's already been paused throw new UploadAbortedError(); } upload.deferred.ac.abort(); } }; /** * Resume a paused upload * @param file The file upload you want to resume. Can be omitted to resume all files */ const resumeUpload = (file)=>{ const files = Arr.ensure(file ?? opts.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, 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 (file)=>{ const promises = []; const files = Arr.ensure(file ?? opts.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; }; return { pauseUpload, resumeUpload, done }; }; /** * One step upload function that both requests presigned URLs * and then uploads the files to UploadThing */ const typedUploadFiles = (slug, opts)=>{ const endpoint = typeof slug === "function" ? slug(routeRegistry) : slug; return uploadFilesInternal(endpoint, { ...opts, skipPolling: {}, url: resolveMaybeUrlArg(initOpts?.url), package: initOpts.package, // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access input: opts.input }).pipe((effect)=>Micro.runPromiseExit(effect, opts.signal && { signal: opts.signal })).then((exit)=>{ if (exit._tag === "Success") { return exit.value; } else if (exit.cause._tag === "Interrupt") { throw new UploadAbortedError(); } throw Micro.causeSquash(exit.cause); }); }; return { uploadFiles: typedUploadFiles, 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 }; }; export { genUploader, isValidFileSize, isValidFileType, version };