/** * Node.js multipart parsing for HTTP `multipart/form-data` request bodies. * * `NodeMultipart` adapts a Node `Readable` plus incoming HTTP headers into * Effect's shared multipart model. It can expose form parts as a stream or * collect a complete persisted form by writing file uploads to scoped temporary * files through the current `FileSystem` and `Path` services. `fileToReadable` * returns the underlying Node readable stream for file parts produced by this * parser. * * @since 4.0.0 */ import * as Effect from "effect/Effect" import type * as FileSystem from "effect/FileSystem" import * as Inspectable from "effect/Inspectable" import type * as Path from "effect/Path" import type * as Scope from "effect/Scope" import * as Stream from "effect/Stream" import * as Multipart from "effect/unstable/http/Multipart" import * as MP from "effect/unstable/http/Multipasta/Node" import * as NFS from "node:fs" import type { IncomingHttpHeaders } from "node:http" import type { Readable } from "node:stream" import * as NodeStreamP from "node:stream/promises" import * as NodeStream from "./NodeStream.ts" /** * Parses multipart data from a Node readable request body and headers into a * stream of `Multipart.Part` values, converting parser failures to * `MultipartError`. * * @category constructors * @since 4.0.0 */ export const stream = ( source: Readable, headers: IncomingHttpHeaders ): Stream.Stream => Multipart.makeConfig(headers as any).pipe( Effect.map((config) => NodeStream.fromReadable({ evaluate() { const parser = MP.make(config) source.pipe(parser) return parser as any }, onError: (error) => convertError(error as any) }) ), Stream.unwrap, Stream.map(convertPart) ) /** * Parses multipart data from a Node readable request body and persists file * parts using the current `FileSystem`, `Path`, and `Scope` services. * * @category constructors * @since 4.0.0 */ export const persisted = ( source: Readable, headers: IncomingHttpHeaders ): Effect.Effect< Multipart.Persisted, Multipart.MultipartError, Scope.Scope | FileSystem.FileSystem | Path.Path > => Multipart.toPersisted(stream(source, headers), (path, file) => Effect.tryPromise({ try: (signal) => NodeStreamP.pipeline((file as FileImpl).file, NFS.createWriteStream(path), { signal }), catch: (cause) => Multipart.MultipartError.fromReason("InternalError", cause) })) /** * Returns the underlying Node readable stream for a multipart file produced by * the Node multipart parser. * * @category converting * @since 4.0.0 */ export const fileToReadable = (file: Multipart.File): Readable => (file as FileImpl).file // ----------------------------------------------------------------------------- // Internal // ----------------------------------------------------------------------------- const convertPart = (part: MP.Part): Multipart.Part => part._tag === "Field" ? new FieldImpl(part.info, part.value) : new FileImpl(part) abstract class PartBase extends Inspectable.Class { readonly [Multipart.TypeId]: typeof Multipart.TypeId constructor() { super() this[Multipart.TypeId] = Multipart.TypeId } } class FieldImpl extends PartBase implements Multipart.Field { readonly _tag = "Field" readonly key: string readonly contentType: string readonly value: string constructor( info: MP.PartInfo, value: Uint8Array ) { super() this.key = info.name this.contentType = info.contentType this.value = MP.decodeField(info, value) } toJSON(): unknown { return { _id: "@effect/platform/Multipart/Part", _tag: "Field", key: this.key, value: this.value, contentType: this.contentType } } } class FileImpl extends PartBase implements Multipart.File { readonly _tag = "File" readonly key: string readonly name: string readonly contentType: string readonly content: Stream.Stream readonly contentEffect: Effect.Effect readonly file: MP.FileStream constructor(file: MP.FileStream) { super() this.file = file this.key = file.info.name this.name = file.filename ?? file.info.name this.contentType = file.info.contentType this.content = NodeStream.fromReadable({ evaluate: () => file, onError: (cause) => Multipart.MultipartError.fromReason("InternalError", cause) }) this.contentEffect = NodeStream.toUint8Array(() => file, { onError: (cause) => Multipart.MultipartError.fromReason("InternalError", cause) }) } toJSON(): unknown { return { _id: "@effect/platform/Multipart/Part", _tag: "File", key: this.key, name: this.name, contentType: this.contentType } } } function convertError(cause: MP.MultipartError): Multipart.MultipartError { switch (cause._tag) { case "ReachedLimit": { switch (cause.limit) { case "MaxParts": { return Multipart.MultipartError.fromReason("TooManyParts", cause) } case "MaxFieldSize": { return Multipart.MultipartError.fromReason("FieldTooLarge", cause) } case "MaxPartSize": { return Multipart.MultipartError.fromReason("FileTooLarge", cause) } case "MaxTotalSize": { return Multipart.MultipartError.fromReason("BodyTooLarge", cause) } } } default: { return Multipart.MultipartError.fromReason("Parse", cause) } } }