/** * @since 1.0.0 */ import type * as Cause from "effect/Cause" import * as Channel from "effect/Channel" import * as Chunk from "effect/Chunk" import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" import { constant, dual } from "effect/Function" import * as Inspectable from "effect/Inspectable" import * as Mailbox from "effect/Mailbox" import * as Option from "effect/Option" import type * as ParseResult from "effect/ParseResult" import * as Predicate from "effect/Predicate" import * as Schema from "effect/Schema" import type { ParseOptions } from "effect/SchemaAST" import type * as Scope from "effect/Scope" import type * as AsyncInput from "effect/SingleProducerAsyncInput" import * as Stream from "effect/Stream" import * as MP from "multipasta" import * as FileSystem from "./FileSystem.js" import * as IncomingMessage from "./HttpIncomingMessage.js" import * as Path from "./Path.js" /** * @since 1.0.0 * @category type ids */ export const TypeId: unique symbol = Symbol.for("@effect/platform/Multipart") /** * @since 1.0.0 * @category type ids */ export type TypeId = typeof TypeId /** * @since 1.0.0 * @category models */ export type Part = Field | File /** * @since 1.0.0 */ export declare namespace Part { /** * @since 1.0.0 * @category models */ export interface Proto extends Inspectable.Inspectable { readonly [TypeId]: TypeId readonly _tag: string } } /** * @since 1.0.0 * @category models */ export interface Field extends Part.Proto { readonly _tag: "Field" readonly key: string readonly contentType: string readonly value: string } /** * @since 1.0.0 * @category Guards */ export const isPart = (u: unknown): u is Part => Predicate.hasProperty(u, TypeId) /** * @since 1.0.0 * @category Guards */ export const isField = (u: unknown): u is Field => isPart(u) && u._tag === "Field" /** * @since 1.0.0 * @category models */ export interface File extends Part.Proto { readonly _tag: "File" readonly key: string readonly name: string readonly contentType: string readonly content: Stream.Stream readonly contentEffect: Effect.Effect } /** * @since 1.0.0 * @category Guards */ export const isFile = (u: unknown): u is File => isPart(u) && u._tag === "File" /** * @since 1.0.0 * @category models */ export interface PersistedFile extends Part.Proto { readonly _tag: "PersistedFile" readonly key: string readonly name: string readonly contentType: string readonly path: string } /** * @since 1.0.0 * @category Guards */ export const isPersistedFile = (u: unknown): u is PersistedFile => Predicate.hasProperty(u, TypeId) && Predicate.isTagged(u, "PersistedFile") /** * @since 1.0.0 * @category models */ export interface Persisted { readonly [key: string]: ReadonlyArray | ReadonlyArray | string } /** * @since 1.0.0 * @category Errors */ export const ErrorTypeId: unique symbol = Symbol.for( "@effect/platform/Multipart/MultipartError" ) /** * @since 1.0.0 * @category Errors */ export type ErrorTypeId = typeof ErrorTypeId /** * @since 1.0.0 * @category Errors */ export class MultipartError extends Schema.TaggedError()("MultipartError", { reason: Schema.Literal("FileTooLarge", "FieldTooLarge", "BodyTooLarge", "TooManyParts", "InternalError", "Parse"), cause: Schema.Defect }) { /** * @since 1.0.0 */ readonly [ErrorTypeId]: ErrorTypeId = ErrorTypeId /** * @since 1.0.0 */ get message(): string { return this.reason } } /** * @since 1.0.0 * @category Schemas */ export const FileSchema: Schema.Schema = Schema.declare(isPersistedFile, { typeConstructor: { _tag: "effect/platform/Multipart.PersistedFile" }, identifier: "PersistedFile", jsonSchema: { type: "string", format: "binary" } }) /** * @since 1.0.0 * @category Schemas */ export const FilesSchema: Schema.Schema> = Schema.Array(FileSchema) /** * @since 1.0.0 * @category Schemas */ export const SingleFileSchema: Schema.transform< Schema.Schema>, Schema.Schema > = Schema.transform(FilesSchema.pipe(Schema.itemsCount(1)), FileSchema, { strict: true, decode: ([file]) => file, encode: (file) => [file] }) /** * @since 1.0.0 * @category Schemas */ export const schemaPersisted = , R>( schema: Schema.Schema, options?: ParseOptions | undefined ): (persisted: Persisted) => Effect.Effect< A, ParseResult.ParseError, R > => Schema.decodeUnknown(schema, options) /** * @since 1.0.0 * @category Schemas */ export const schemaJson = (schema: Schema.Schema, options?: ParseOptions | undefined): { ( field: string ): (persisted: Persisted) => Effect.Effect ( persisted: Persisted, field: string ): Effect.Effect } => { const fromJson = Schema.parseJson(schema) return dual< ( field: string ) => ( persisted: Persisted ) => Effect.Effect, ( persisted: Persisted, field: string ) => Effect.Effect >(2, (persisted, field) => Effect.map( Schema.decodeUnknown( Schema.Struct({ [field]: fromJson }), options )(persisted), (_) => _[field] )) } /** * @since 1.0.0 * @category Config */ export const makeConfig = ( headers: Record ): Effect.Effect => Effect.withFiberRuntime((fiber) => { const mimeTypes = Context.get(fiber.currentContext, FieldMimeTypes) return Effect.succeed({ headers, maxParts: Option.getOrUndefined(Context.get(fiber.currentContext, MaxParts)), maxFieldSize: Number(Context.get(fiber.currentContext, MaxFieldSize)), maxPartSize: Context.get(fiber.currentContext, MaxFileSize).pipe(Option.map(Number), Option.getOrUndefined), maxTotalSize: Context.get(fiber.currentContext, IncomingMessage.MaxBodySize).pipe( Option.map(Number), Option.getOrUndefined ), isFile: mimeTypes.length === 0 ? undefined : (info: MP.PartInfo): boolean => !Chunk.some( mimeTypes, (_) => info.contentType.includes(_) ) && MP.defaultIsFile(info) }) }) /** * @since 1.0.0 * @category Parsers */ export const makeChannel = ( headers: Record, bufferSize = 16 ): Channel.Channel< Chunk.Chunk, Chunk.Chunk, MultipartError | IE, IE, unknown, unknown > => Channel.acquireUseRelease( Effect.all([ makeConfig(headers), Mailbox.make>(bufferSize) ]), ([config, mailbox]) => { let partsBuffer: Array = [] let exit = Option.none>() const input: AsyncInput.AsyncInputProducer, unknown> = { awaitRead: () => Effect.void, emit(element) { return mailbox.offer(element) }, error(cause) { exit = Option.some(Exit.failCause(cause)) return mailbox.end }, done(_value) { return mailbox.end } } const parser = MP.make({ ...config, onField(info, value) { partsBuffer.push(new FieldImpl(info.name, info.contentType, MP.decodeField(info, value))) }, onFile(info) { let chunks: Array = [] let finished = false const take: Channel.Channel> = Channel.suspend(() => { if (chunks.length === 0) { return finished ? Channel.void : Channel.zipRight(pump, take) } const chunk = Chunk.unsafeFromArray(chunks) chunks = [] return finished ? Channel.write(chunk) : Channel.zipRight( Channel.write(chunk), Channel.zipRight(pump, take) ) }) partsBuffer.push(new FileImpl(info, take)) return function(chunk) { if (chunk === null) { finished = true } else { chunks.push(chunk) } } }, onError(error_) { exit = Option.some(Exit.fail(convertError(error_))) }, onDone() { if (Option.isNone(exit)) { exit = Option.some(Exit.void) } } }) const pump = Channel.flatMap( mailbox.takeAll, ([chunks, done]) => Channel.sync(() => { Chunk.forEach(chunks, Chunk.forEach(parser.write)) if (done) { parser.end() } }) ) const partsChannel: Channel.Channel< Chunk.Chunk, unknown, IE | MultipartError > = Channel.flatMap( pump, () => { if (partsBuffer.length === 0) { return exit._tag === "None" ? partsChannel : writeExit(exit.value) } const chunk = Chunk.unsafeFromArray(partsBuffer) partsBuffer = [] return Channel.zipRight( Channel.write(chunk), exit._tag === "None" ? partsChannel : writeExit(exit.value) ) } ) return Channel.embedInput(partsChannel, input) }, ([, mailbox]) => mailbox.shutdown ) const writeExit = ( self: Exit.Exit ): Channel.Channel => self._tag === "Success" ? Channel.void : Channel.failCause(self.cause) function convertError(cause: MP.MultipartError): MultipartError { switch (cause._tag) { case "ReachedLimit": { switch (cause.limit) { case "MaxParts": { return new MultipartError({ reason: "TooManyParts", cause }) } case "MaxFieldSize": { return new MultipartError({ reason: "FieldTooLarge", cause }) } case "MaxPartSize": { return new MultipartError({ reason: "FileTooLarge", cause }) } case "MaxTotalSize": { return new MultipartError({ reason: "BodyTooLarge", cause }) } } } default: { return new MultipartError({ reason: "Parse", cause }) } } } abstract class PartBase extends Inspectable.Class { readonly [TypeId]: TypeId constructor() { super() this[TypeId] = TypeId } } class FieldImpl extends PartBase implements Field { readonly _tag = "Field" constructor( readonly key: string, readonly contentType: string, readonly value: string ) { super() } toJSON(): unknown { return { _id: "@effect/platform/Multipart/Part", _tag: "Field", key: this.key, contentType: this.contentType, value: this.value } } } class FileImpl extends PartBase implements File { readonly _tag = "File" readonly key: string readonly name: string readonly contentType: string readonly content: Stream.Stream readonly contentEffect: Effect.Effect constructor( info: MP.PartInfo, channel: Channel.Channel, unknown, never, unknown, void, unknown> ) { super() this.key = info.name this.name = info.filename ?? info.name this.contentType = info.contentType this.content = Stream.fromChannel(channel) this.contentEffect = channel.pipe( Channel.pipeTo(collectUint8Array), Channel.run, Effect.mapError((cause) => new MultipartError({ reason: "InternalError", cause })) ) } toJSON(): unknown { return { _id: "@effect/platform/Multipart/Part", _tag: "File", key: this.key, name: this.name, contentType: this.contentType } } } const defaultWriteFile = (path: string, file: File) => Effect.flatMap( FileSystem.FileSystem, (fs) => Effect.mapError( Stream.run(file.content, fs.sink(path)), (cause) => new MultipartError({ reason: "InternalError", cause }) ) ) /** * @since 1.0.0 */ export const collectUint8Array = Channel.suspend(() => { let accumulator = new Uint8Array(0) const loop: Channel.Channel< never, Chunk.Chunk, unknown, unknown, Uint8Array > = Channel.readWithCause({ onInput(chunk: Chunk.Chunk) { for (const element of chunk) { const newAccumulator = new Uint8Array(accumulator.length + element.length) newAccumulator.set(accumulator, 0) newAccumulator.set(element, accumulator.length) accumulator = newAccumulator } return loop }, onFailure: (cause: Cause.Cause) => Channel.failCause(cause), onDone: () => Channel.succeed(accumulator) }) return loop }) /** * @since 1.0.0 * @category Conversions */ export const toPersisted = ( stream: Stream.Stream, writeFile = defaultWriteFile ): Effect.Effect => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem const path_ = yield* Path.Path const dir = yield* fs.makeTempDirectoryScoped() const persisted: Record | Array | string> = Object.create(null) yield* Stream.runForEach(stream, (part) => { if (part._tag === "Field") { if (!(part.key in persisted)) { persisted[part.key] = part.value } else if (typeof persisted[part.key] === "string") { persisted[part.key] = [persisted[part.key] as string, part.value] } else { ;(persisted[part.key] as Array).push(part.value) } return Effect.void } else if (part.name === "") { return Effect.void } const file = part const path = path_.join(dir, path_.basename(file.name).slice(-128)) const filePart = new PersistedFileImpl( file.key, file.name, file.contentType, path ) if (Array.isArray(persisted[part.key])) { ;(persisted[part.key] as Array).push(filePart) } else { persisted[part.key] = [filePart] } return writeFile(path, file) }) return persisted }).pipe( Effect.catchTags({ SystemError: (cause) => Effect.fail(new MultipartError({ reason: "InternalError", cause })), BadArgument: (cause) => Effect.fail(new MultipartError({ reason: "InternalError", cause })) }) ) class PersistedFileImpl extends PartBase implements PersistedFile { readonly _tag = "PersistedFile" constructor( readonly key: string, readonly name: string, readonly contentType: string, readonly path: string ) { super() } toJSON(): unknown { return { _id: "@effect/platform/Multipart/Part", _tag: "PersistedFile", key: this.key, name: this.name, contentType: this.contentType, path: this.path } } } /** * @since 1.0.0 * @category fiber refs */ export const withLimits: { /** * @since 1.0.0 * @category fiber refs */ ( options: { readonly maxParts?: Option.Option | undefined readonly maxFieldSize?: FileSystem.SizeInput | undefined readonly maxFileSize?: Option.Option | undefined readonly maxTotalSize?: Option.Option | undefined readonly fieldMimeTypes?: ReadonlyArray | undefined } ): (effect: Effect.Effect) => Effect.Effect /** * @since 1.0.0 * @category fiber refs */ ( effect: Effect.Effect, options: { readonly maxParts?: Option.Option | undefined readonly maxFieldSize?: FileSystem.SizeInput | undefined readonly maxFileSize?: Option.Option | undefined readonly maxTotalSize?: Option.Option | undefined readonly fieldMimeTypes?: ReadonlyArray | undefined } ): Effect.Effect } = dual(2, ( effect: Effect.Effect, options: { readonly maxParts?: Option.Option | undefined readonly maxFieldSize?: FileSystem.SizeInput | undefined readonly maxFileSize?: Option.Option | undefined readonly maxTotalSize?: Option.Option | undefined readonly fieldMimeTypes?: ReadonlyArray | undefined } ): Effect.Effect => Effect.provide(effect, withLimitsContext(options))) const withLimitsContext = (options: { readonly maxParts?: Option.Option | undefined readonly maxFieldSize?: FileSystem.SizeInput | undefined readonly maxFileSize?: Option.Option | undefined readonly maxTotalSize?: Option.Option | undefined readonly fieldMimeTypes?: ReadonlyArray | undefined }) => { const contextMap = new Map() if (options.maxParts !== undefined) { contextMap.set(MaxParts.key, options.maxParts) } if (options.maxFieldSize !== undefined) { contextMap.set(MaxFieldSize.key, FileSystem.Size(options.maxFieldSize)) } if (options.maxFileSize !== undefined) { contextMap.set(MaxFileSize.key, Option.map(options.maxFileSize, FileSystem.Size)) } if (options.maxTotalSize !== undefined) { contextMap.set(IncomingMessage.MaxBodySize.key, Option.map(options.maxTotalSize, FileSystem.Size)) } if (options.fieldMimeTypes !== undefined) { contextMap.set(FieldMimeTypes.key, Chunk.fromIterable(options.fieldMimeTypes)) } return Context.unsafeMake(contextMap) } /** * @since 1.0.0 * @category fiber refs */ export const withLimitsStream: { /** * @since 1.0.0 * @category fiber refs */ ( options: { readonly maxParts?: Option.Option | undefined readonly maxFieldSize?: FileSystem.SizeInput | undefined readonly maxFileSize?: Option.Option | undefined readonly maxTotalSize?: Option.Option | undefined readonly fieldMimeTypes?: ReadonlyArray | undefined } ): (stream: Stream.Stream) => Stream.Stream /** * @since 1.0.0 * @category fiber refs */ ( stream: Stream.Stream, options: { readonly maxParts?: Option.Option | undefined readonly maxFieldSize?: FileSystem.SizeInput | undefined readonly maxFileSize?: Option.Option | undefined readonly maxTotalSize?: Option.Option | undefined readonly fieldMimeTypes?: ReadonlyArray | undefined } ): Stream.Stream } = dual(2, ( stream: Stream.Stream, options: { readonly maxParts?: Option.Option | undefined readonly maxFieldSize?: FileSystem.SizeInput | undefined readonly maxFileSize?: Option.Option | undefined readonly maxTotalSize?: Option.Option | undefined readonly fieldMimeTypes?: ReadonlyArray | undefined } ): Stream.Stream => Stream.provideSomeContext(stream, withLimitsContext(options))) /** * @since 1.0.0 * @category fiber refs */ export declare namespace withLimits { /** * @since 1.0.0 * @category fiber refs */ export type Options = { readonly maxParts?: Option.Option | undefined readonly maxFieldSize?: FileSystem.SizeInput | undefined readonly maxFileSize?: Option.Option | undefined readonly maxTotalSize?: Option.Option | undefined readonly fieldMimeTypes?: ReadonlyArray | undefined } } /** * @since 1.0.0 * @category fiber refs */ export class MaxParts extends Context.Reference()("@effect/platform/Multipart/MaxParts", { defaultValue: Option.none }) {} /** * @since 1.0.0 * @category fiber refs */ export const withMaxParts: { /** * @since 1.0.0 * @category fiber refs */ (count: Option.Option): (effect: Effect.Effect) => Effect.Effect /** * @since 1.0.0 * @category fiber refs */ (effect: Effect.Effect, count: Option.Option): Effect.Effect } = dual( 2, (effect: Effect.Effect, count: Option.Option): Effect.Effect => Effect.provideService(effect, MaxParts, count) ) /** * @since 1.0.0 * @category fiber refs */ export class MaxFieldSize extends Context.Reference()("@effect/platform/Multipart/MaxFieldSize", { defaultValue: constant(FileSystem.Size(10 * 1024 * 1024)) }) {} /** * @since 1.0.0 * @category fiber refs */ export const withMaxFieldSize: { /** * @since 1.0.0 * @category fiber refs */ (size: FileSystem.SizeInput): (effect: Effect.Effect) => Effect.Effect /** * @since 1.0.0 * @category fiber refs */ (effect: Effect.Effect, size: FileSystem.SizeInput): Effect.Effect } = dual( 2, (effect: Effect.Effect, size: FileSystem.SizeInput): Effect.Effect => Effect.provideService(effect, MaxFieldSize, FileSystem.Size(size)) ) /** * @since 1.0.0 * @category fiber refs */ export class MaxFileSize extends Context.Reference()("@effect/platform/Multipart/MaxFileSize", { defaultValue: Option.none }) {} /** * @since 1.0.0 * @category fiber refs */ export const withMaxFileSize: { /** * @since 1.0.0 * @category fiber refs */ (size: Option.Option): (effect: Effect.Effect) => Effect.Effect /** * @since 1.0.0 * @category fiber refs */ (effect: Effect.Effect, size: Option.Option): Effect.Effect } = dual( 2, (effect: Effect.Effect, size: Option.Option): Effect.Effect => Effect.provideService( effect, MaxFileSize, Option.map(size, FileSystem.Size) ) ) /** * @since 1.0.0 * @category fiber refs */ export class FieldMimeTypes extends Context.Reference()("@effect/platform/Multipart/FieldMimeTypes", { defaultValue: constant>(Chunk.make("application/json")) }) {} /** * @since 1.0.0 * @category fiber refs */ export const withFieldMimeTypes: { /** * @since 1.0.0 * @category fiber refs */ (mimeTypes: ReadonlyArray): (effect: Effect.Effect) => Effect.Effect /** * @since 1.0.0 * @category fiber refs */ (effect: Effect.Effect, mimeTypes: ReadonlyArray): Effect.Effect } = dual( 2, (effect: Effect.Effect, mimeTypes: ReadonlyArray): Effect.Effect => Effect.provideService(effect, FieldMimeTypes, Chunk.fromIterable(mimeTypes)) )