UNPKG

uploadthing

Version:

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

817 lines (802 loc) 38.8 kB
import * as Effect from 'effect/Effect'; import { FetchHttpClient, Headers as Headers$1, HttpApp, HttpServerResponse, HttpServerRequest, HttpRouter, HttpClient, HttpClientRequest, HttpClientResponse, HttpBody } from '@effect/platform'; import * as Config from 'effect/Config'; import * as Context from 'effect/Context'; import * as Match from 'effect/Match'; import * as Redacted from 'effect/Redacted'; import * as S from 'effect/Schema'; import { ValidContentDispositions, ValidACLs, filterDefinedObjectValues, UploadThingError, matchFileType, objectKeys, InvalidRouteConfigError, fileSizeToBytes, fillInputRouteConfig, bytesToFileSize, getStatusCodeFromError, verifySignature, generateKey, generateSignedURL } from '@uploadthing/shared'; import * as ConfigProvider from 'effect/ConfigProvider'; import * as Stream from 'effect/Stream'; import * as Layer from 'effect/Layer'; import * as Logger from 'effect/Logger'; import * as LogLevel from 'effect/LogLevel'; import * as Cause from 'effect/Cause'; import * as Data from 'effect/Data'; import * as Runtime from 'effect/Runtime'; import * as FiberRef from 'effect/FiberRef'; import * as ManagedRuntime from 'effect/ManagedRuntime'; import { UTFiles } from '../internal/types.js'; export { UTFiles } from '../internal/types.js'; var version = "7.4.4"; S.Literal(...ValidContentDispositions); S.Literal(...ValidACLs); /** * Valid options for the `?actionType` query param */ const ActionType = S.Literal("upload"); /** * Valid options for the `uploadthing-hook` header * for requests coming from UT server */ const UploadThingHook = S.Literal("callback", "error"); /** * ============================================================================= * =========================== Configuration =================================== * ============================================================================= */ const DecodeString = S.transform(S.Uint8ArrayFromSelf, S.String, { decode: (data)=>new TextDecoder().decode(data), encode: (data)=>new TextEncoder().encode(data) }); const ParsedToken = S.Struct({ apiKey: S.Redacted(S.String.pipe(S.startsWith("sk_"))), appId: S.String, regions: S.NonEmptyArray(S.String), ingestHost: S.String.pipe(S.optionalWith({ default: ()=>"ingest.uploadthing.com" })) }); const UploadThingToken = S.Uint8ArrayFromBase64.pipe(S.compose(DecodeString), S.compose(S.parseJson(ParsedToken))); /** * ============================================================================= * ======================== File Type Hierarchy =============================== * ============================================================================= */ /** * Properties from the web File object, this is what the client sends when initiating an upload */ class FileUploadData extends S.Class("FileUploadData")({ name: S.String, size: S.Number, type: S.String, lastModified: S.Number.pipe(S.optional) }) { } /** * `.middleware()` can add a customId to the incoming file data */ class FileUploadDataWithCustomId extends FileUploadData.extend("FileUploadDataWithCustomId")({ customId: S.NullOr(S.String) }) { } /** * When files are uploaded, we get back * - a key * - a direct URL for the file * - an app-specific URL for the file (useful for scoping eg. for optimization allowed origins) * - the hash (md5-hex) of the uploaded file's contents */ class UploadedFileData extends FileUploadDataWithCustomId.extend("UploadedFileData")({ key: S.String, url: S.String, appUrl: S.String, fileHash: S.String }) { } /** * ============================================================================= * ======================== Server Response Schemas ============================ * ============================================================================= */ class NewPresignedUrl extends S.Class("NewPresignedUrl")({ url: S.String, key: S.String, customId: S.NullOr(S.String), name: S.String }) { } class MetadataFetchStreamPart extends S.Class("MetadataFetchStreamPart")({ payload: S.String, signature: S.String, hook: UploadThingHook }) { } class MetadataFetchResponse extends S.Class("MetadataFetchResponse")({ ok: S.Boolean }) { } class CallbackResultResponse extends S.Class("CallbackResultResponse")({ ok: S.Boolean }) { } /** * ============================================================================= * ======================== Client Action Payloads ============================ * ============================================================================= */ class UploadActionPayload extends S.Class("UploadActionPayload")({ files: S.Array(FileUploadData), input: S.Unknown }) { } /** * Merge in `import.meta.env` to the built-in `process.env` provider * Prefix keys with `UPLOADTHING_` so we can reference just the name. * @example * process.env.UPLOADTHING_TOKEN = "foo" * Config.string("token"); // Config<"foo"> */ const envProvider = ConfigProvider.fromEnv().pipe(ConfigProvider.orElse(()=>ConfigProvider.fromMap(new Map(Object.entries(filterDefinedObjectValues(// fuck this I give up. import.meta is a mistake, someone else can fix it import.meta?.env ?? {}))), { pathDelim: "_" })), ConfigProvider.nested("uploadthing"), ConfigProvider.constantCase); /** * Config provider that merges the options from the object * and environment variables prefixed with `UPLOADTHING_`. * @remarks Options take precedence over environment variables. */ const configProvider = (options)=>ConfigProvider.fromJson(options ?? {}).pipe(ConfigProvider.orElse(()=>envProvider)); const IsDevelopment = Config.boolean("isDev").pipe(Config.orElse(()=>Config.succeed(typeof process !== "undefined" ? process.env.NODE_ENV : undefined).pipe(Config.map((_)=>_ === "development"))), Config.withDefault(false)); const UTToken = S.Config("token", UploadThingToken).pipe(Effect.catchTags({ ConfigError: (e)=>new UploadThingError({ code: e._op === "InvalidData" ? "INVALID_SERVER_CONFIG" : "MISSING_ENV", message: e._op === "InvalidData" ? "Invalid token. A token is a base64 encoded JSON object matching { apiKey: string, appId: string, regions: string[] }." : "Missing token. Please set the `UPLOADTHING_TOKEN` environment variable or provide a token manually through config.", cause: e }) })); Config.string("apiUrl").pipe(Config.withDefault("https://api.uploadthing.com"), Config.mapAttempt((_)=>new URL(_)), Config.map((url)=>url.href.replace(/\/$/, ""))); const IngestUrl = Effect.gen(function*() { const { regions, ingestHost } = yield* UTToken; const region = regions[0]; // Currently only support 1 region per app return yield* Config.string("ingestUrl").pipe(Config.withDefault(`https://${region}.${ingestHost}`), Config.mapAttempt((_)=>new URL(_)), Config.map((url)=>url.href.replace(/\/$/, ""))); }); function defaultErrorFormatter(error) { return { message: error.message }; } function formatError(error, router) { const errorFormatter = router[Object.keys(router)[0]]?.errorFormatter ?? defaultErrorFormatter; return errorFormatter(error); } const handleJsonLineStream = (schema, onChunk)=>(stream)=>{ let buf = ""; return stream.pipe(Stream.decodeText(), Stream.mapEffect((chunk)=>Effect.gen(function*() { buf += chunk; // Scan buffer for newlines const parts = buf.split("\n"); const validChunks = []; for (const part of parts){ try { // Attempt to parse chunk as JSON validChunks.push(JSON.parse(part)); // Advance buffer if parsing succeeded buf = buf.slice(part.length + 1); } catch { // } } yield* Effect.logDebug("Received chunks").pipe(Effect.annotateLogs("chunk", chunk), Effect.annotateLogs("parsedChunks", validChunks), Effect.annotateLogs("buf", buf)); return validChunks; })), Stream.mapEffect(S.decodeUnknown(S.Array(schema))), Stream.mapEffect(Effect.forEach((part)=>onChunk(part))), Stream.runDrain, Effect.withLogSpan("handleJsonLineStream")); }; const withMinimalLogLevel = Config.logLevel("logLevel").pipe(Config.withDefault(LogLevel.Info), Effect.andThen((level)=>Logger.minimumLogLevel(level)), Effect.tapError((e)=>Effect.logError("Invalid log level").pipe(Effect.annotateLogs("error", e))), Effect.catchTag("ConfigError", (e)=>new UploadThingError({ code: "INVALID_SERVER_CONFIG", message: "Invalid server configuration", cause: e })), Layer.unwrapEffect); const LogFormat = Config.literal("json", "logFmt", "structured", "pretty")("logFormat"); const withLogFormat = Effect.gen(function*() { const isDev = yield* IsDevelopment; const logFormat = yield* LogFormat.pipe(Config.withDefault(isDev ? "pretty" : "json")); return Logger[logFormat]; }).pipe(Effect.catchTag("ConfigError", (e)=>new UploadThingError({ code: "INVALID_SERVER_CONFIG", message: "Invalid server configuration", cause: e })), Layer.unwrapEffect); const logHttpClientResponse = (message, opts)=>{ const mixin = opts?.mixin ?? "json"; const level = LogLevel.fromLiteral(opts?.level ?? "Debug"); return (response)=>Effect.flatMap(mixin !== "None" ? response[mixin] : Effect.void, ()=>Effect.logWithLevel(level, `${message} (${response.status})`).pipe(Effect.annotateLogs("response", response))); }; const logHttpClientError = (message)=>(err)=>err._tag === "ResponseError" ? logHttpClientResponse(message, { level: "Error" })(err.response) : Effect.logError(message).pipe(Effect.annotateLogs("error", err)); class ParserError extends Data.TaggedError("ParserError") { constructor(...args){ super(...args), this.message = "Input validation failed. The original error with it's validation issues is in the error cause."; } } function getParseFn(parser) { if ("~standard" in parser) { /** * Standard Schema */ return async (value)=>{ const result = await parser["~standard"].validate(value); if (result.issues) { throw new ParserError({ cause: result.issues }); } return result.value; }; } if ("parseAsync" in parser && typeof parser.parseAsync === "function") { /** * Zod * TODO (next major): Consider wrapping ZodError in ParserError */ return parser.parseAsync; } if (S.isSchema(parser)) { /** * Effect Schema */ return (value)=>S.decodeUnknownPromise(parser)(value).catch((error)=>{ throw new ParserError({ cause: Cause.squash(error[Runtime.FiberFailureCauseId]) }); }); } throw new Error("Invalid parser"); } class FileSizeMismatch extends Data.Error { constructor(type, max, actual){ const reason = `You uploaded a ${type} file that was ${bytesToFileSize(actual)}, but the limit for that type is ${max}`; super({ reason }), this._tag = "FileSizeMismatch", this.name = "FileSizeMismatchError"; } } class FileCountMismatch extends Data.Error { constructor(type, boundtype, bound, actual){ const reason = `You uploaded ${actual} file(s) of type '${type}', but the ${boundtype} for that type is ${bound}`; super({ reason }), this._tag = "FileCountMismatch", this.name = "FileCountMismatchError"; } } // Verify that the uploaded files doesn't violate the route config, // e.g. uploading more videos than allowed, or a file that is larger than allowed. // This is double-checked on infra side, but we want to fail early to avoid network latency. const assertFilesMeetConfig = (files, routeConfig)=>Effect.gen(function*() { const counts = {}; for (const file of files){ const type = yield* matchFileType(file, objectKeys(routeConfig)); counts[type] = (counts[type] ?? 0) + 1; const sizeLimit = routeConfig[type]?.maxFileSize; if (!sizeLimit) { return yield* new InvalidRouteConfigError(type, "maxFileSize"); } const sizeLimitBytes = yield* fileSizeToBytes(sizeLimit); if (file.size > sizeLimitBytes) { return yield* new FileSizeMismatch(type, sizeLimit, file.size); } } for(const _key in counts){ const key = _key; const config = routeConfig[key]; if (!config) return yield* new InvalidRouteConfigError(key); const count = counts[key]; const min = config.minFileCount; const max = config.maxFileCount; if (min > max) { return yield* new UploadThingError({ code: "BAD_REQUEST", message: "Invalid config during file count - minFileCount > maxFileCount", cause: `minFileCount must be less than maxFileCount for key ${key}. got: ${min} > ${max}` }); } if (count < min) { return yield* new FileCountMismatch(key, "minimum", min, count); } if (count > max) { return yield* new FileCountMismatch(key, "maximum", max, count); } } return null; }); const extractRouterConfig = (router)=>Effect.forEach(objectKeys(router), (slug)=>Effect.map(fillInputRouteConfig(router[slug].routerConfig), (config)=>({ slug, config }))); const makeRuntime = (fetch, config)=>{ const fetchHttpClient = Layer.provideMerge(FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetch)); const withRedactedHeaders = Layer.effectDiscard(FiberRef.update(Headers$1.currentRedactedNames, (_)=>_.concat([ "x-uploadthing-api-key" ]))); const layer = Layer.provide(Layer.mergeAll(withLogFormat, withMinimalLogLevel, fetchHttpClient, withRedactedHeaders), Layer.setConfigProvider(configProvider(config))); return ManagedRuntime.make(layer); }; class AdapterArguments extends Context.Tag("uploadthing/AdapterArguments")() { } const makeAdapterHandler = (makeAdapterArgs, toRequest, opts, beAdapter)=>{ const managed = makeRuntime(opts.config?.fetch, opts.config); const handle = Effect.promise(()=>managed.runtime().then(HttpApp.toWebHandlerRuntime)); const app = (...args)=>Effect.map(Effect.promise(()=>managed.runPromise(createRequestHandler(opts, beAdapter))), Effect.provideServiceEffect(AdapterArguments, makeAdapterArgs(...args))); return async (...args)=>{ const result = await handle.pipe(Effect.ap(app(...args)), Effect.ap(toRequest(...args)), Effect.withLogSpan("requestHandler"), managed.runPromise); return result; }; }; const createRequestHandler = (opts, beAdapter)=>Effect.gen(function*() { const isDevelopment = yield* IsDevelopment; const routerConfig = yield* extractRouterConfig(opts.router); const handleDaemon = (()=>{ if (opts.config?.handleDaemonPromise) { return opts.config.handleDaemonPromise; } return isDevelopment ? "void" : "await"; })(); if (isDevelopment && handleDaemon === "await") { return yield* new UploadThingError({ code: "INVALID_SERVER_CONFIG", message: 'handleDaemonPromise: "await" is forbidden in development.' }); } const GET = Effect.gen(function*() { return yield* HttpServerResponse.json(routerConfig); }); const POST = Effect.gen(function*() { const { "uploadthing-hook": uploadthingHook, "x-uploadthing-package": fePackage, "x-uploadthing-version": clientVersion } = yield* HttpServerRequest.schemaHeaders(S.Struct({ "uploadthing-hook": UploadThingHook.pipe(S.optional), "x-uploadthing-package": S.String.pipe(S.optionalWith({ default: ()=>"unknown" })), "x-uploadthing-version": S.String.pipe(S.optionalWith({ default: ()=>version })) })); if (clientVersion !== version) { const serverVersion = version; yield* Effect.logWarning("Client version mismatch. Things may not work as expected, please sync your versions to ensure compatibility.").pipe(Effect.annotateLogs({ clientVersion, serverVersion })); } const { slug, actionType } = yield* HttpRouter.schemaParams(S.Struct({ actionType: ActionType.pipe(S.optional), slug: S.String })); const uploadable = opts.router[slug]; if (!uploadable) { const msg = `No file route found for slug ${slug}`; yield* Effect.logError(msg); return yield* new UploadThingError({ code: "NOT_FOUND", message: msg }); } const { body, fiber } = yield* Match.value({ actionType, uploadthingHook }).pipe(Match.when({ actionType: "upload", uploadthingHook: undefined }, ()=>handleUploadAction({ uploadable, fePackage, beAdapter, slug })), Match.when({ actionType: undefined, uploadthingHook: "callback" }, ()=>handleCallbackRequest({ uploadable, fePackage, beAdapter })), Match.when({ actionType: undefined, uploadthingHook: "error" }, ()=>handleErrorRequest({ uploadable })), Match.orElse(()=>Effect.succeed({ body: null, fiber: null }))); if (fiber) { yield* Effect.logDebug("Running fiber as daemon").pipe(Effect.annotateLogs("handleDaemon", handleDaemon)); if (handleDaemon === "void") ; else if (handleDaemon === "await") { yield* fiber.await; } else if (typeof handleDaemon === "function") { handleDaemon(Effect.runPromise(fiber.await)); } } yield* Effect.logDebug("Sending response").pipe(Effect.annotateLogs("body", body)); return yield* HttpServerResponse.json(body); }).pipe(Effect.catchTags({ ParseError: (e)=>HttpServerResponse.json(formatError(new UploadThingError({ code: "BAD_REQUEST", message: "Invalid input", cause: e.message }), opts.router), { status: 400 }), UploadThingError: (e)=>// eslint-disable-next-line @typescript-eslint/no-unsafe-argument HttpServerResponse.json(formatError(e, opts.router), { status: getStatusCodeFromError(e) }) })); const appendResponseHeaders = Effect.map(HttpServerResponse.setHeader("x-uploadthing-version", version)); return HttpRouter.empty.pipe(HttpRouter.get("*", GET), HttpRouter.post("*", POST), HttpRouter.use(appendResponseHeaders)); }).pipe(Effect.withLogSpan("createRequestHandler")); const handleErrorRequest = (opts)=>Effect.gen(function*() { const { uploadable } = opts; const request = yield* HttpServerRequest.HttpServerRequest; const { apiKey } = yield* UTToken; const verified = yield* verifySignature((yield* request.text), request.headers["x-uploadthing-signature"], apiKey); yield* Effect.logDebug(`Signature verified: ${verified}`); if (!verified) { yield* Effect.logError("Invalid signature"); return yield* new UploadThingError({ code: "BAD_REQUEST", message: "Invalid signature" }); } const requestInput = yield* HttpServerRequest.schemaBodyJson(S.Struct({ fileKey: S.String, error: S.String })); yield* Effect.logDebug("Handling error callback request with input:").pipe(Effect.annotateLogs("json", requestInput)); const adapterArgs = yield* AdapterArguments; const fiber = yield* Effect.tryPromise({ try: async ()=>uploadable.onUploadError({ ...adapterArgs, error: new UploadThingError({ code: "UPLOAD_FAILED", message: `Upload failed for ${requestInput.fileKey}: ${requestInput.error}` }), fileKey: requestInput.fileKey }), catch: (error)=>new UploadThingError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to run onUploadError", cause: error }) }).pipe(Effect.tapError((error)=>Effect.logError("Failed to run onUploadError. You probably shouldn't be throwing errors here.").pipe(Effect.annotateLogs("error", error)))).pipe(Effect.ignoreLogged, Effect.forkDaemon); return { body: null, fiber }; }).pipe(Effect.withLogSpan("handleErrorRequest")); const handleCallbackRequest = (opts)=>Effect.gen(function*() { const { uploadable, fePackage, beAdapter } = opts; const request = yield* HttpServerRequest.HttpServerRequest; const { apiKey } = yield* UTToken; const verified = yield* verifySignature((yield* request.text), request.headers["x-uploadthing-signature"], apiKey); yield* Effect.logDebug(`Signature verified: ${verified}`); if (!verified) { yield* Effect.logError("Invalid signature"); return yield* new UploadThingError({ code: "BAD_REQUEST", message: "Invalid signature" }); } const requestInput = yield* HttpServerRequest.schemaBodyJson(S.Struct({ status: S.String, file: UploadedFileData, metadata: S.Record({ key: S.String, value: S.Unknown }) })); yield* Effect.logDebug("Handling callback request with input:").pipe(Effect.annotateLogs("json", requestInput)); /** * Run `.onUploadComplete` as a daemon to prevent the * request from UT to potentially timeout. */ const fiber = yield* Effect.gen(function*() { const adapterArgs = yield* AdapterArguments; const serverData = yield* Effect.tryPromise({ try: async ()=>uploadable.onUploadComplete({ ...adapterArgs, file: requestInput.file, metadata: requestInput.metadata }), catch: (error)=>new UploadThingError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to run onUploadComplete. You probably shouldn't be throwing errors here.", cause: error }) }); const payload = { fileKey: requestInput.file.key, callbackData: serverData ?? null }; yield* Effect.logDebug("'onUploadComplete' callback finished. Sending response to UploadThing:").pipe(Effect.annotateLogs("callbackData", payload)); const baseUrl = yield* IngestUrl; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); yield* HttpClientRequest.post(`/callback-result`).pipe(HttpClientRequest.prependUrl(baseUrl), HttpClientRequest.setHeaders({ "x-uploadthing-api-key": Redacted.value(apiKey), "x-uploadthing-version": version, "x-uploadthing-be-adapter": beAdapter, "x-uploadthing-fe-package": fePackage }), HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.tapError(logHttpClientError("Failed to register callback result")), Effect.flatMap(HttpClientResponse.schemaBodyJson(CallbackResultResponse)), Effect.tap(Effect.log("Sent callback result to UploadThing")), Effect.scoped); }).pipe(Effect.ignoreLogged, Effect.forkDaemon); return { body: null, fiber }; }).pipe(Effect.withLogSpan("handleCallbackRequest")); const runRouteMiddleware = (opts)=>Effect.gen(function*() { const { json: { files, input }, uploadable } = opts; yield* Effect.logDebug("Running middleware"); const adapterArgs = yield* AdapterArguments; const metadata = yield* Effect.tryPromise({ try: async ()=>uploadable.middleware({ ...adapterArgs, input, files }), catch: (error)=>error instanceof UploadThingError ? error : new UploadThingError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to run middleware", cause: error }) }); if (metadata[UTFiles] && metadata[UTFiles].length !== files.length) { const msg = `Expected files override to have the same length as original files, got ${metadata[UTFiles].length} but expected ${files.length}`; yield* Effect.logError(msg); return yield* new UploadThingError({ code: "BAD_REQUEST", message: "Files override must have the same length as files", cause: msg }); } // Attach customIds from middleware to the files const filesWithCustomIds = yield* Effect.forEach(files, (file, idx)=>Effect.gen(function*() { const theirs = metadata[UTFiles]?.[idx]; if (theirs && theirs.size !== file.size) { yield* Effect.logWarning("File size mismatch. Reverting to original size"); } return { name: theirs?.name ?? file.name, size: file.size, type: file.type, customId: theirs?.customId, lastModified: theirs?.lastModified ?? Date.now() }; })); return { metadata, filesWithCustomIds }; }).pipe(Effect.withLogSpan("runRouteMiddleware")); const handleUploadAction = (opts)=>Effect.gen(function*() { const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); const { uploadable, fePackage, beAdapter, slug } = opts; const json = yield* HttpServerRequest.schemaBodyJson(UploadActionPayload); yield* Effect.logDebug("Handling upload request").pipe(Effect.annotateLogs("json", json)); // validate the input yield* Effect.logDebug("Parsing user input"); const parsedInput = yield* Effect.tryPromise({ try: ()=>getParseFn(uploadable.inputParser)(json.input), catch: (error)=>new UploadThingError({ code: "BAD_REQUEST", message: "Invalid input", cause: error }) }); yield* Effect.logDebug("Input parsed successfully").pipe(Effect.annotateLogs("input", parsedInput)); const { metadata, filesWithCustomIds } = yield* runRouteMiddleware({ json: { input: parsedInput, files: json.files }, uploadable }); yield* Effect.logDebug("Parsing route config").pipe(Effect.annotateLogs("routerConfig", uploadable.routerConfig)); const parsedConfig = yield* fillInputRouteConfig(uploadable.routerConfig).pipe(Effect.catchTag("InvalidRouteConfig", (err)=>new UploadThingError({ code: "BAD_REQUEST", message: "Invalid route config", cause: err }))); yield* Effect.logDebug("Route config parsed successfully").pipe(Effect.annotateLogs("routeConfig", parsedConfig)); yield* Effect.logDebug("Validating files meet the config requirements").pipe(Effect.annotateLogs("files", json.files)); yield* assertFilesMeetConfig(json.files, parsedConfig).pipe(Effect.mapError((e)=>new UploadThingError({ code: "BAD_REQUEST", message: `Invalid config: ${e._tag}`, cause: "reason" in e ? e.reason : e.message }))); yield* Effect.logDebug("Files validated."); const fileUploadRequests = yield* Effect.forEach(filesWithCustomIds, (file)=>Effect.map(matchFileType(file, objectKeys(parsedConfig)), (type)=>({ name: file.name, size: file.size, type: file.type || type, lastModified: file.lastModified, customId: file.customId, contentDisposition: parsedConfig[type]?.contentDisposition ?? "inline", acl: parsedConfig[type]?.acl }))).pipe(Effect.catchTags({ /** Shouldn't happen since config is validated above so just dying is fine I think */ InvalidFileType: (e)=>Effect.die(e), UnknownFileType: (e)=>Effect.die(e) })); const routeOptions = uploadable.routeOptions; const { apiKey, appId } = yield* UTToken; const ingestUrl = yield* IngestUrl; const isDev = yield* IsDevelopment; yield* Effect.logDebug("Generating presigned URLs").pipe(Effect.annotateLogs("fileUploadRequests", fileUploadRequests), Effect.annotateLogs("ingestUrl", ingestUrl)); const presignedUrls = yield* Effect.forEach(fileUploadRequests, (file)=>Effect.gen(function*() { const key = yield* generateKey(file, appId, routeOptions.getFileHashParts); const url = yield* generateSignedURL(`${ingestUrl}/${key}`, apiKey, { ttlInSeconds: routeOptions.presignedURLTTL, data: { "x-ut-identifier": appId, "x-ut-file-name": file.name, "x-ut-file-size": file.size, "x-ut-file-type": file.type, "x-ut-slug": slug, "x-ut-custom-id": file.customId, "x-ut-content-disposition": file.contentDisposition, "x-ut-acl": file.acl } }); return { url, key }; }), { concurrency: "unbounded" }); const serverReq = yield* HttpServerRequest.HttpServerRequest; const requestUrl = yield* HttpServerRequest.toURL(serverReq); const devHookRequest = yield* Config.string("callbackUrl").pipe(Config.withDefault(requestUrl.origin + requestUrl.pathname), Effect.map((url)=>HttpClientRequest.post(url).pipe(HttpClientRequest.appendUrlParam("slug", slug)))); const metadataRequest = HttpClientRequest.post("/route-metadata").pipe(HttpClientRequest.prependUrl(ingestUrl), HttpClientRequest.setHeaders({ "x-uploadthing-api-key": Redacted.value(apiKey), "x-uploadthing-version": version, "x-uploadthing-be-adapter": beAdapter, "x-uploadthing-fe-package": fePackage }), HttpClientRequest.bodyJson({ fileKeys: presignedUrls.map(({ key })=>key), metadata: metadata, isDev, callbackUrl: devHookRequest.url, callbackSlug: slug, awaitServerData: routeOptions.awaitServerData ?? true }), Effect.flatMap(httpClient.execute)); // Send metadata to UT server (non blocking as a daemon) // In dev, keep the stream open and simulate the callback requests as // files complete uploading const fiber = yield* Effect.if(isDev, { onTrue: ()=>metadataRequest.pipe(Effect.tapBoth({ onSuccess: logHttpClientResponse("Registered metadata", { mixin: "None" }), onFailure: logHttpClientError("Failed to register metadata") }), HttpClientResponse.stream, handleJsonLineStream(MetadataFetchStreamPart, (chunk)=>devHookRequest.pipe(HttpClientRequest.setHeaders({ "uploadthing-hook": chunk.hook, "x-uploadthing-signature": chunk.signature }), HttpClientRequest.setBody(HttpBody.text(chunk.payload, "application/json")), httpClient.execute, Effect.tapBoth({ onSuccess: logHttpClientResponse("Successfully forwarded callback request from dev stream"), onFailure: logHttpClientError("Failed to forward callback request from dev stream") }), Effect.annotateLogs(chunk), Effect.asVoid, Effect.ignoreLogged, Effect.scoped))), onFalse: ()=>metadataRequest.pipe(Effect.tapBoth({ onSuccess: logHttpClientResponse("Registered metadata"), onFailure: logHttpClientError("Failed to register metadata") }), Effect.flatMap(HttpClientResponse.schemaBodyJson(MetadataFetchResponse)), Effect.scoped) }).pipe(Effect.forkDaemon); const presigneds = presignedUrls.map((p, i)=>({ url: p.url, key: p.key, name: fileUploadRequests[i].name, customId: fileUploadRequests[i].customId ?? null })); yield* Effect.logInfo("Sending presigned URLs to client").pipe(Effect.annotateLogs("presignedUrls", presigneds)); return { body: presigneds, fiber }; }).pipe(Effect.withLogSpan("handleUploadAction")); class InvalidURL extends Data.Error { constructor(attemptedUrl, base){ Effect.runSync(Effect.logError(`Failed to parse URL from request. '${attemptedUrl}' is not a valid URL with base '${base}'.`)); super({ reason: `Failed to parse URL from request. '${attemptedUrl}' is not a valid URL with base '${base}'.` }), this._tag = "InvalidURL", this.name = "InvalidURLError"; } } const parseURL = (req)=>{ const headers = req.headers; let relativeUrl = req.url ?? "/"; if ("baseUrl" in req && typeof req.baseUrl === "string") { relativeUrl = req.baseUrl + relativeUrl; } const proto = headers?.["x-forwarded-proto"] ?? "http"; const host = headers?.["x-forwarded-host"] ?? headers?.host; const baseUrl = Config.string("url").pipe(Config.withDefault(`${proto.toString()}://${host?.toString()}`)); return Effect.flatMap(baseUrl, (baseUrl)=>Effect.try({ try: ()=>new URL(relativeUrl, baseUrl), catch: ()=>new InvalidURL(relativeUrl, baseUrl) })).pipe(Effect.catchTag("ConfigError", ()=>Effect.fail(new InvalidURL(relativeUrl)))); }; const isBodyAllowed = (method)=>[ "POST", "PUT", "PATCH" ].includes(method); const toWebRequest = (req, body)=>{ body ??= req.body; const bodyStr = typeof body === "string" ? body : JSON.stringify(body); const method = req.method ?? "GET"; const allowsBody = isBodyAllowed(method); const headers = new Headers(); for (const [key, value] of Object.entries(req.headers ?? [])){ if (typeof value === "string") headers.set(key, value); if (Array.isArray(value)) headers.set(key, value.join(",")); } return parseURL(req).pipe(Effect.catchTag("InvalidURL", (e)=>Effect.die(e)), Effect.andThen((url)=>new Request(url, { method, headers, ...allowsBody ? { body: bodyStr } : {} }))); }; function internalCreateBuilder(initDef = {}) { const _def = { $types: {}, // Default router config routerConfig: { image: { maxFileSize: "4MB" } }, routeOptions: { awaitServerData: true }, inputParser: { parseAsync: ()=>Promise.resolve(undefined), _input: undefined, _output: undefined }, middleware: ()=>({}), onUploadError: ()=>{ // noop }, onUploadComplete: ()=>undefined, errorFormatter: initDef.errorFormatter ?? defaultErrorFormatter, // Overload with properties passed in ...initDef }; return { input (userParser) { return internalCreateBuilder({ ..._def, inputParser: userParser }); }, middleware (userMiddleware) { return internalCreateBuilder({ ..._def, middleware: userMiddleware }); }, onUploadComplete (userUploadComplete) { return { ..._def, onUploadComplete: userUploadComplete }; }, onUploadError (userOnUploadError) { return internalCreateBuilder({ ..._def, onUploadError: userOnUploadError }); } }; } function createBuilder(opts) { return (input, config)=>{ return internalCreateBuilder({ routerConfig: input, routeOptions: config ?? {}, ...opts }); }; } const createUploadthing = (opts)=>createBuilder(opts); const createRouteHandler = (opts)=>{ const handler = makeAdapterHandler((req, res)=>Effect.succeed({ req, res, event: undefined }), (req)=>toWebRequest(req), opts, "nextjs-pages"); return async (req, res)=>{ const response = await handler(req, res); res.status(response.status); for (const [name, value] of response.headers){ res.setHeader(name, value); } // FIXME: Should be able to just forward it instead of consuming it first return res.json(await response.json()); }; }; export { createRouteHandler, createUploadthing };