import {
type MultipartParserOptions,
type MultipartPart,
MaxFileSizeExceededError,
MaxHeaderSizeExceededError,
MaxPartsExceededError,
MaxTotalSizeExceededError,
isMultipartRequest,
parseMultipartRequest,
} from '@remix-run/multipart-parser'
/**
* The base class for errors thrown by the form data parser.
*/
export class FormDataParseError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options)
this.name = 'FormDataParseError'
}
}
/**
* An error thrown when the maximum number of files allowed in a request is exceeded.
*/
export class MaxFilesExceededError extends FormDataParseError {
constructor(maxFiles: number) {
super(`Maximum number of files exceeded: ${maxFiles}`)
this.name = 'MaxFilesExceededError'
}
}
/**
* A file that was uploaded as part of a `multipart/form-data` request.
*/
export class FileUpload extends File {
/**
* The name of the `` field used to upload the file.
*/
readonly fieldName: string
constructor(part: MultipartPart, fieldName: string) {
super(part.content as BlobPart[], part.filename ?? 'file-upload', {
type: part.mediaType ?? 'application/octet-stream',
})
this.fieldName = fieldName
}
}
/**
* A function used for handling file uploads.
*
* @param file The uploaded file
* @returns A value to store in `FormData`, or `void`/`null` to skip
*/
export interface FileUploadHandler {
/**
* Transforms an uploaded file into the value stored in the parsed {@link FormData}.
*/
(file: FileUpload): void | null | string | Blob | Promise
}
function defaultFileUploadHandler(file: FileUpload): File {
// By default just keep the file around in memory.
return file
}
const oneKb = 1024
const oneMb = oneKb * oneKb
const defaultMaxFiles = 20
const defaultMaxFileSize = 2 * oneMb
const defaultMaxParts = 1000
function isParserLimitError(error: unknown): boolean {
return (
error instanceof MaxHeaderSizeExceededError ||
error instanceof MaxFileSizeExceededError ||
error instanceof MaxPartsExceededError ||
error instanceof MaxTotalSizeExceededError
)
}
async function* parseFormDataParts(
request: Request,
parserOptions: MultipartParserOptions,
): AsyncGenerator {
try {
yield* parseMultipartRequest(request, parserOptions)
} catch (error) {
if (error instanceof FormDataParseError || isParserLimitError(error)) {
throw error
}
throw new FormDataParseError('Cannot parse form data', { cause: error })
}
}
function isUrlEncodedRequest(request: Request): boolean {
let contentType = request.headers.get('Content-Type')
return contentType != null && contentType.startsWith('application/x-www-form-urlencoded')
}
function validateUrlEncodedPartCount(partCount: number, maxParts: number): void {
if (partCount > maxParts) {
throw new MaxPartsExceededError(maxParts)
}
}
async function readUrlEncodedBody(
request: Request,
maxParts: number,
maxTotalSize: number,
): Promise {
if (request.body == null) {
return new Uint8Array()
}
let reader = request.body.getReader()
let chunks: Uint8Array[] = []
let partCount = 0
let totalSize = 0
let hasPartBytes = false
try {
while (true) {
let result = await reader.read()
if (result.done) break
totalSize += result.value.length
if (totalSize > maxTotalSize) {
throw new MaxTotalSizeExceededError(maxTotalSize)
}
for (let byte of result.value) {
if (byte === 38) {
if (hasPartBytes) {
validateUrlEncodedPartCount(++partCount, maxParts)
hasPartBytes = false
}
} else {
hasPartBytes = true
}
}
chunks.push(result.value)
}
if (hasPartBytes) {
validateUrlEncodedPartCount(++partCount, maxParts)
}
} finally {
reader.releaseLock()
}
let body = new Uint8Array(totalSize)
let offset = 0
for (let chunk of chunks) {
body.set(chunk, offset)
offset += chunk.length
}
return body
}
let urlEncodedDecoder: TextDecoder | undefined
async function parseUrlEncodedFormData(
request: Request,
maxParts: number,
maxTotalSize: number,
): Promise {
let bytes = await readUrlEncodedBody(request, maxParts, maxTotalSize)
urlEncodedDecoder ??= new TextDecoder()
let searchParams = new URLSearchParams(urlEncodedDecoder.decode(bytes as BufferSource))
let formData = new FormData()
for (let [name, value] of searchParams) {
formData.append(name, value)
}
return formData
}
/**
* Options for parsing form data.
*/
export interface ParseFormDataOptions extends MultipartParserOptions {
/**
* The maximum number of files that can be uploaded in a single request. If this limit is
* exceeded, a `MaxFilesExceededError` will be thrown.
*
* @default 20
*/
maxFiles?: number
}
/**
* Parses a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) body into a [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
* object. This is useful when accessing the data contained in a HTTP `multipart/form-data` request
* generated by a HTML `