| /** | |
| * Parses and persists HTTP `multipart/form-data` request bodies. | |
| * | |
| * `Multipart` turns incoming byte streams into typed form parts. Text parts | |
| * become decoded fields, while upload parts stay as streamed files until they | |
| * are collected or written to scoped temporary files. The persisted | |
| * representation can then be decoded with schemas for handlers that receive | |
| * fields and uploaded files together. This module also includes multipart error | |
| * types, schema helpers for persisted files, and parser limit settings. | |
| * | |
| * @since 4.0.0 | |
| */ | |
| import * as Arr from "../../Array.js"; | |
| import * as Cause from "../../Cause.js"; | |
| import * as Channel from "../../Channel.js"; | |
| import * as Context from "../../Context.js"; | |
| import * as Data from "../../Data.js"; | |
| import * as Effect from "../../Effect.js"; | |
| import * as Exit from "../../Exit.js"; | |
| import * as FileSystem from "../../FileSystem.js"; | |
| import { constant, dual } from "../../Function.js"; | |
| import * as Inspectable from "../../Inspectable.js"; | |
| import * as Option from "../../Option.js"; | |
| import * as Path from "../../Path.js"; | |
| import * as Predicate from "../../Predicate.js"; | |
| import * as Pull from "../../Pull.js"; | |
| import * as Schema from "../../Schema.js"; | |
| import * as SchemaTransformation from "../../SchemaTransformation.js"; | |
| import * as Stream from "../../Stream.js"; | |
| import * as UndefinedOr from "../../UndefinedOr.js"; | |
| import * as IncomingMessage from "./HttpIncomingMessage.js"; | |
| import * as MP from "./Multipasta.js"; | |
| /** | |
| * Type identifier used to brand multipart part values. | |
| * | |
| * @category type IDs | |
| * @since 4.0.0 | |
| */ | |
| export const TypeId = "~effect/http/Multipart"; | |
| /** | |
| * Returns `true` when a value is a multipart `Part`. | |
| * | |
| * @category guards | |
| * @since 4.0.0 | |
| */ | |
| export const isPart = u => Predicate.hasProperty(u, TypeId); | |
| /** | |
| * Returns `true` when a value is a multipart text `Field`. | |
| * | |
| * @category guards | |
| * @since 4.0.0 | |
| */ | |
| export const isField = u => isPart(u) && u._tag === "Field"; | |
| /** | |
| * Returns `true` when a value is a multipart `File`. | |
| * | |
| * @category guards | |
| * @since 4.0.0 | |
| */ | |
| export const isFile = u => isPart(u) && u._tag === "File"; | |
| /** | |
| * Returns `true` when a value is a persisted multipart file. | |
| * | |
| * @category guards | |
| * @since 4.0.0 | |
| */ | |
| export const isPersistedFile = u => Predicate.hasProperty(u, TypeId) && Predicate.isTagged(u, "PersistedFile"); | |
| const MultipartErrorTypeId = "~effect/http/Multipart/MultipartError"; | |
| /** | |
| * Error reason carried by a `MultipartError`. | |
| * | |
| * **Details** | |
| * | |
| * It identifies parser and limit failures such as oversized files or fields, too | |
| * many parts, total body size limits, parse errors, and internal errors. | |
| * | |
| * @category errors | |
| * @since 4.0.0 | |
| */ | |
| export class MultipartErrorReason extends Data.Error {} | |
| /** | |
| * Error raised while parsing, streaming, or persisting multipart form data. | |
| * | |
| * **Details** | |
| * | |
| * The `reason` field contains the concrete `MultipartErrorReason`. | |
| * | |
| * @category errors | |
| * @since 4.0.0 | |
| */ | |
| export class MultipartError extends /*#__PURE__*/Data.TaggedError("MultipartError") { | |
| /** | |
| * Creates a multipart error from a reason tag and optional cause. | |
| * | |
| * @since 4.0.0 | |
| */ | |
| static fromReason(reason, cause) { | |
| return new MultipartError({ | |
| reason: new MultipartErrorReason({ | |
| _tag: reason, | |
| cause | |
| }) | |
| }); | |
| } | |
| /** | |
| * Marks this value as a multipart error for runtime guards. | |
| * | |
| * @since 4.0.0 | |
| */ | |
| [MultipartErrorTypeId] = MultipartErrorTypeId; | |
| /** | |
| * Uses the concrete multipart error reason as the public message. | |
| * | |
| * @since 4.0.0 | |
| */ | |
| get message() { | |
| return this.reason._tag; | |
| } | |
| } | |
| /** | |
| * Schema for persisted multipart files. | |
| * | |
| * **Details** | |
| * | |
| * The encoded form contains the field key, original file name, content type, and | |
| * filesystem path. | |
| * | |
| * @category schemas | |
| * @since 4.0.0 | |
| */ | |
| export const PersistedFileSchema = /*#__PURE__*/Schema.declare(isPersistedFile, { | |
| typeConstructor: { | |
| _tag: "effect/http/PersistedFile" | |
| }, | |
| generation: { | |
| runtime: `Multipart.PersistedFileSchema`, | |
| Type: `Multipart.PersistedFile`, | |
| importDeclaration: `import * as Multipart from "effect/unstable/http/Multipart"` | |
| }, | |
| expected: "PersistedFile", | |
| toCodecJson: () => Schema.link()(Schema.Struct({ | |
| key: Schema.String, | |
| name: Schema.String, | |
| contentType: Schema.String.annotate({ | |
| contentEncoding: "binary" | |
| }), | |
| path: Schema.String | |
| }), SchemaTransformation.transform({ | |
| decode: ({ | |
| contentType, | |
| key, | |
| name, | |
| path | |
| }) => new PersistedFileImpl(key, name, contentType, path), | |
| encode: file => ({ | |
| key: file.key, | |
| name: file.name, | |
| contentType: file.contentType, | |
| path: file.path | |
| }) | |
| })) | |
| }); | |
| /** | |
| * Schema for an array of persisted multipart files. | |
| * | |
| * @category schemas | |
| * @since 4.0.0 | |
| */ | |
| export const FilesSchema = /*#__PURE__*/Schema.Array(PersistedFileSchema); | |
| /** | |
| * Schema for exactly one persisted multipart file. | |
| * | |
| * **Details** | |
| * | |
| * The encoded form is a one-element file array, while the decoded value is the | |
| * single `PersistedFile`. | |
| * | |
| * @category schemas | |
| * @since 4.0.0 | |
| */ | |
| export const SingleFileSchema = /*#__PURE__*/FilesSchema.check(Schema.isLengthBetween(1, 1)).pipe(/*#__PURE__*/Schema.decodeTo(PersistedFileSchema, /*#__PURE__*/SchemaTransformation.transform({ | |
| decode: ([file]) => file, | |
| encode: file => [file] | |
| }))); | |
| /** | |
| * Creates a decoder for persisted multipart data using the supplied schema. | |
| * | |
| * **Details** | |
| * | |
| * The returned function decodes an unknown input into the schema output and fails | |
| * with `SchemaError` when validation fails. | |
| * | |
| * @category schemas | |
| * @since 4.0.0 | |
| */ | |
| export const schemaPersisted = schema => Schema.decodeUnknownEffect(schema); | |
| /** | |
| * Creates a decoder for a JSON-encoded field in persisted multipart data. | |
| * | |
| * **Details** | |
| * | |
| * The selected field is parsed from a JSON string and decoded with the supplied | |
| * schema. | |
| * | |
| * @category schemas | |
| * @since 4.0.0 | |
| */ | |
| export const schemaJson = (schema, options) => { | |
| const fromJson = Schema.fromJsonString(schema); | |
| return dual(2, (persisted, field) => Effect.map(Schema.decodeUnknownEffect(Schema.Struct({ | |
| [field]: fromJson | |
| }))(persisted, options), _ => _[field])); | |
| }; | |
| /** | |
| * Builds the low-level multipart parser configuration from request headers and | |
| * the current fiber context. | |
| * | |
| * **Details** | |
| * | |
| * Parser limits are read from the multipart references, including maximum parts, | |
| * field size, file size, total body size, and field MIME type overrides. | |
| * | |
| * @category configuration | |
| * @since 4.0.0 | |
| */ | |
| export const makeConfig = headers => Effect.withFiber(fiber => { | |
| const mimeTypes = Context.get(fiber.context, FieldMimeTypes); | |
| return Effect.succeed({ | |
| headers, | |
| maxParts: fiber.getRef(MaxParts), | |
| maxFieldSize: Number(fiber.getRef(MaxFieldSize)), | |
| maxPartSize: UndefinedOr.map(fiber.getRef(MaxFileSize), Number), | |
| maxTotalSize: UndefinedOr.map(fiber.getRef(IncomingMessage.MaxBodySize), Number), | |
| isFile: mimeTypes.length === 0 ? undefined : info => !mimeTypes.some(_ => info.contentType.includes(_)) && MP.defaultIsFile(info) | |
| }); | |
| }); | |
| /** | |
| * Creates a channel that parses multipart byte chunks into multipart parts. | |
| * | |
| * **Details** | |
| * | |
| * The channel consumes non-empty batches of `Uint8Array` chunks and emits | |
| * non-empty batches of parsed `Part` values, failing with `MultipartError` for | |
| * parser and limit failures. | |
| * | |
| * @category Parsers | |
| * @since 4.0.0 | |
| */ | |
| export const makeChannel = headers => Channel.fromTransform(upstream => Effect.map(makeConfig(headers), config => { | |
| let partsBuffer = []; | |
| let exit = Option.none(); | |
| const parser = MP.make({ | |
| ...config, | |
| onField(info, value) { | |
| partsBuffer.push(new FieldImpl(info.name, info.contentType, MP.decodeField(info, value))); | |
| }, | |
| onFile(info) { | |
| let chunks = []; | |
| let finished = false; | |
| const pullChunks = Channel.fromPull(Effect.succeed(Effect.suspend(function loop() { | |
| if (!Arr.isReadonlyArrayNonEmpty(chunks)) { | |
| return finished ? Cause.done() : Effect.flatMap(pump, loop); | |
| } | |
| const chunk = chunks; | |
| chunks = []; | |
| return Effect.succeed(chunk); | |
| }))); | |
| partsBuffer.push(new FileImpl(info, pullChunks)); | |
| return function (chunk) { | |
| if (chunk === null) { | |
| finished = true; | |
| } else { | |
| chunks.push(chunk); | |
| } | |
| }; | |
| }, | |
| onError(error_) { | |
| exit = Option.some(Exit.fail(convertError(error_))); | |
| }, | |
| onDone() { | |
| exit = Option.some(Exit.fail(Cause.Done())); | |
| } | |
| }); | |
| const pump = upstream.pipe(Effect.flatMap(chunk => { | |
| for (let i = 0; i < chunk.length; i++) { | |
| parser.write(chunk[i]); | |
| } | |
| return Effect.void; | |
| }), Effect.catchCause(cause => { | |
| if (Pull.isDoneCause(cause)) { | |
| parser.end(); | |
| } else { | |
| exit = Option.some(Exit.failCause(cause)); | |
| } | |
| return Effect.void; | |
| })); | |
| return pump.pipe(Effect.flatMap(function loop() { | |
| if (!Arr.isReadonlyArrayNonEmpty(partsBuffer)) { | |
| if (Option.isSome(exit)) { | |
| return exit.value; | |
| } | |
| return Effect.flatMap(pump, loop); | |
| } | |
| const parts = partsBuffer; | |
| partsBuffer = []; | |
| return Effect.succeed(parts); | |
| })); | |
| })); | |
| function convertError(cause) { | |
| switch (cause._tag) { | |
| case "ReachedLimit": | |
| { | |
| switch (cause.limit) { | |
| case "MaxParts": | |
| { | |
| return MultipartError.fromReason("TooManyParts", cause); | |
| } | |
| case "MaxFieldSize": | |
| { | |
| return MultipartError.fromReason("FieldTooLarge", cause); | |
| } | |
| case "MaxPartSize": | |
| { | |
| return MultipartError.fromReason("FileTooLarge", cause); | |
| } | |
| case "MaxTotalSize": | |
| { | |
| return MultipartError.fromReason("BodyTooLarge", cause); | |
| } | |
| } | |
| } | |
| default: | |
| { | |
| return MultipartError.fromReason("Parse", cause); | |
| } | |
| } | |
| } | |
| class PartBase extends Inspectable.Class { | |
| [TypeId]; | |
| constructor() { | |
| super(); | |
| this[TypeId] = TypeId; | |
| } | |
| } | |
| class FieldImpl extends PartBase { | |
| _tag = "Field"; | |
| key; | |
| contentType; | |
| value; | |
| constructor(key, contentType, value) { | |
| super(); | |
| this.key = key; | |
| this.contentType = contentType; | |
| this.value = value; | |
| } | |
| toJSON() { | |
| return { | |
| _id: "@effect/platform/Multipart/Part", | |
| _tag: "Field", | |
| key: this.key, | |
| contentType: this.contentType, | |
| value: this.value | |
| }; | |
| } | |
| } | |
| class FileImpl extends PartBase { | |
| _tag = "File"; | |
| key; | |
| name; | |
| contentType; | |
| content; | |
| contentEffect; | |
| constructor(info, channel) { | |
| super(); | |
| this.key = info.name; | |
| this.name = info.filename ?? info.name; | |
| this.contentType = info.contentType; | |
| this.content = Stream.fromChannel(channel); | |
| this.contentEffect = channel.pipe(collectUint8Array, Effect.mapError(cause => MultipartError.fromReason("InternalError", cause))); | |
| } | |
| toJSON() { | |
| return { | |
| _id: "@effect/platform/Multipart/Part", | |
| _tag: "File", | |
| key: this.key, | |
| name: this.name, | |
| contentType: this.contentType | |
| }; | |
| } | |
| } | |
| const defaultWriteFile = (path, file) => Effect.flatMap(FileSystem.FileSystem, fs => Effect.mapError(Stream.run(file.content, fs.sink(path)), cause => MultipartError.fromReason("InternalError", cause))); | |
| /** | |
| * Runs a channel of byte chunks and collects all output into a single | |
| * `Uint8Array`. | |
| * | |
| * **Gotchas** | |
| * | |
| * This materializes the full content in memory. | |
| * | |
| * @category converting | |
| * @since 4.0.0 | |
| */ | |
| export const collectUint8Array = self => Channel.runFold(self, constant(new Uint8Array(0)), (accumulator, chunk) => { | |
| const totalLength = chunk.reduce((sum, element) => sum + element.length, accumulator.length); | |
| const newAccumulator = new Uint8Array(totalLength); | |
| newAccumulator.set(accumulator, 0); | |
| let offset = accumulator.length; | |
| for (const element of chunk) { | |
| newAccumulator.set(element, offset); | |
| offset += element.length; | |
| } | |
| return newAccumulator; | |
| }); | |
| /** | |
| * Persists a stream of multipart parts into a record. | |
| * | |
| * **Details** | |
| * | |
| * Text fields are collected as strings, and file parts are written to files in a | |
| * scoped temporary directory. | |
| * | |
| * **Gotchas** | |
| * | |
| * Persisted file paths remain valid for the lifetime of the scope. | |
| * | |
| * @category converting | |
| * @since 4.0.0 | |
| */ | |
| export const toPersisted = (stream, writeFile = defaultWriteFile) => Effect.gen(function* () { | |
| const fs = yield* FileSystem.FileSystem; | |
| const path_ = yield* Path.Path; | |
| const dir = yield* fs.makeTempDirectoryScoped(); | |
| const persisted = 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], part.value]; | |
| } else { | |
| ; | |
| persisted[part.key].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].push(filePart); | |
| } else { | |
| persisted[part.key] = [filePart]; | |
| } | |
| return writeFile(path, file); | |
| }); | |
| return persisted; | |
| }).pipe(Effect.catchTag("PlatformError", cause => Effect.fail(MultipartError.fromReason("InternalError", cause)))); | |
| class PersistedFileImpl extends PartBase { | |
| _tag = "PersistedFile"; | |
| key; | |
| name; | |
| contentType; | |
| path; | |
| constructor(key, name, contentType, path) { | |
| super(); | |
| this.key = key; | |
| this.name = name; | |
| this.contentType = contentType; | |
| this.path = path; | |
| } | |
| toJSON() { | |
| return { | |
| _id: "@effect/platform/Multipart/Part", | |
| _tag: "PersistedFile", | |
| key: this.key, | |
| name: this.name, | |
| contentType: this.contentType, | |
| path: this.path | |
| }; | |
| } | |
| } | |
| /** | |
| * Creates a context containing multipart parser limit settings. | |
| * | |
| * **Details** | |
| * | |
| * The context can provide maximum part count, field size, file size, total body | |
| * size, and MIME types that should be parsed as fields. | |
| * | |
| * @category references | |
| * @since 4.0.0 | |
| */ | |
| export const limitsServices = options => { | |
| const map = new Map(); | |
| if (options.maxParts !== undefined) { | |
| map.set(MaxParts.key, options.maxParts); | |
| } | |
| if (options.maxFieldSize !== undefined) { | |
| map.set(MaxFieldSize.key, FileSystem.Size(options.maxFieldSize)); | |
| } | |
| if (options.maxFileSize !== undefined) { | |
| map.set(MaxFileSize.key, UndefinedOr.map(options.maxFileSize, FileSystem.Size)); | |
| } | |
| if (options.maxTotalSize !== undefined) { | |
| map.set(IncomingMessage.MaxBodySize.key, UndefinedOr.map(options.maxTotalSize, FileSystem.Size)); | |
| } | |
| if (options.fieldMimeTypes !== undefined) { | |
| map.set(FieldMimeTypes.key, options.fieldMimeTypes); | |
| } | |
| return Context.makeUnsafe(map); | |
| }; | |
| /** | |
| * Context reference for the maximum number of multipart parts allowed. | |
| * | |
| * **Details** | |
| * | |
| * The default is `undefined`, meaning no explicit part-count limit. | |
| * | |
| * @category references | |
| * @since 4.0.0 | |
| */ | |
| export const MaxParts = /*#__PURE__*/Context.Reference("effect/http/Multipart/MaxParts", { | |
| defaultValue: () => undefined | |
| }); | |
| /** | |
| * Context reference for the maximum size of a multipart field value. | |
| * | |
| * **Details** | |
| * | |
| * The default limit is 10 MiB. | |
| * | |
| * @category references | |
| * @since 4.0.0 | |
| */ | |
| export const MaxFieldSize = /*#__PURE__*/Context.Reference("effect/http/Multipart/MaxFieldSize", { | |
| defaultValue: /*#__PURE__*/constant(/*#__PURE__*/FileSystem.Size(10 * 1024 * 1024)) | |
| }); | |
| /** | |
| * Context reference for the maximum size of a multipart file part. | |
| * | |
| * **Details** | |
| * | |
| * The default is `undefined`, meaning no explicit per-file limit. | |
| * | |
| * @category references | |
| * @since 4.0.0 | |
| */ | |
| export const MaxFileSize = /*#__PURE__*/Context.Reference("effect/http/Multipart/MaxFileSize", { | |
| defaultValue: () => undefined | |
| }); | |
| /** | |
| * Context reference for MIME type fragments that should be parsed as multipart | |
| * fields instead of files. | |
| * | |
| * **Details** | |
| * | |
| * The default treats `application/json` parts as fields. | |
| * | |
| * @category references | |
| * @since 4.0.0 | |
| */ | |
| export const FieldMimeTypes = /*#__PURE__*/Context.Reference("effect/http/Multipart/FieldMimeTypes", { | |
| defaultValue: /*#__PURE__*/constant(["application/json"]) | |
| }); | |
| //# sourceMappingURL=Multipart.js.map |
Xet Storage Details
- Size:
- 16.5 kB
- Xet hash:
- b38e99a352a095960879509fd983775a02c2de3dc969fc21474c67aef3c44c31
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.