EdgeAIG's picture
download
raw
16.5 kB
/**
* 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.