import { errors, readerFromStreamReader } from "./deps.ts";import { isMediaType } from "./isMediaType.ts";import { FormDataReader } from "./multipart.ts";import type { ServerRequestBody } from "./types.d.ts";import { assert } from "./util.ts";
export type BodyType = | "bytes" | "form" | "form-data" | "json" | "text" | "reader" | "stream" | "undefined";
export type BodyBytes = { readonly type: "bytes"; readonly value: Promise<Uint8Array>;};export type BodyJson = { readonly type: "json"; readonly value: Promise<any>;};export type BodyForm = { readonly type: "form"; readonly value: Promise<URLSearchParams>;};export type BodyFormData = { readonly type: "form-data"; readonly value: FormDataReader;};export type BodyText = { readonly type: "text"; readonly value: Promise<string>;};export type BodyUndefined = { readonly type: "undefined"; readonly value: undefined;};export type BodyReader = { readonly type: "reader"; readonly value: Deno.Reader;};export type BodyStream = { readonly type: "stream"; readonly value: ReadableStream<Uint8Array>;};
export type Body = | BodyBytes | BodyJson | BodyForm | BodyFormData | BodyText | BodyUndefined;
type BodyValueGetter = () => Body["value"];
export interface BodyOptionsContentTypes { bytes?: string[]; json?: string[]; form?: string[]; formData?: string[]; text?: string[];}
export interface BodyOptions<T extends BodyType = BodyType> { limit?: number; type?: T; contentTypes?: BodyOptionsContentTypes;}
export interface BodyContentTypes { json?: string[]; form?: string[]; text?: string[];}
const DEFAULT_LIMIT = 10_485_760;
const defaultBodyContentTypes = { json: ["json", "application/*+json", "application/csp-report"], form: ["urlencoded"], formData: ["multipart"], text: ["text"],};
function resolveType( contentType: string, contentTypes: BodyOptionsContentTypes,): BodyType { const contentTypesJson = [ ...defaultBodyContentTypes.json, ...(contentTypes.json ?? []), ]; const contentTypesForm = [ ...defaultBodyContentTypes.form, ...(contentTypes.form ?? []), ]; const contentTypesFormData = [ ...defaultBodyContentTypes.formData, ...(contentTypes.formData ?? []), ]; const contentTypesText = [ ...defaultBodyContentTypes.text, ...(contentTypes.text ?? []), ]; if (contentTypes.bytes && isMediaType(contentType, contentTypes.bytes)) { return "bytes"; } else if (isMediaType(contentType, contentTypesJson)) { return "json"; } else if (isMediaType(contentType, contentTypesForm)) { return "form"; } else if (isMediaType(contentType, contentTypesFormData)) { return "form-data"; } else if (isMediaType(contentType, contentTypesText)) { return "text"; } return "bytes";}
const decoder = new TextDecoder();
export class RequestBody { #body: ReadableStream<Uint8Array> | null; #formDataReader?: FormDataReader; #headers: Headers; #jsonBodyReviver?: (key: string, value: unknown) => unknown; #stream?: ReadableStream<Uint8Array>; #readAllBody?: Promise<Uint8Array>; #readBody: () => Promise<Uint8Array>; #type?: "bytes" | "form-data" | "reader" | "stream" | "undefined";
#exceedsLimit(limit: number): boolean { if (!limit || limit === Infinity) { return false; } if (!this.#body) { return false; } const contentLength = this.#headers.get("content-length"); if (!contentLength) { return true; } const parsed = parseInt(contentLength, 10); if (isNaN(parsed)) { return true; } return parsed > limit; }
#parse(type: BodyType, limit: number): BodyValueGetter { switch (type) { case "form": this.#type = "bytes"; if (this.#exceedsLimit(limit)) { return () => Promise.reject(new RangeError(`Body exceeds a limit of ${limit}.`)); } return async () => new URLSearchParams( decoder.decode(await this.#valuePromise()).replace(/\+/g, " "), ); case "form-data": this.#type = "form-data"; return () => { const contentType = this.#headers.get("content-type"); assert(contentType); const readableStream = this.#body ?? new ReadableStream(); return this.#formDataReader ?? (this.#formDataReader = new FormDataReader( contentType, readerFromStreamReader( (readableStream as ReadableStream<Uint8Array>).getReader(), ), )); }; case "json": this.#type = "bytes"; if (this.#exceedsLimit(limit)) { return () => Promise.reject(new RangeError(`Body exceeds a limit of ${limit}.`)); } return async () => JSON.parse( decoder.decode(await this.#valuePromise()), this.#jsonBodyReviver, ); case "bytes": this.#type = "bytes"; if (this.#exceedsLimit(limit)) { return () => Promise.reject(new RangeError(`Body exceeds a limit of ${limit}.`)); } return () => this.#valuePromise(); case "text": this.#type = "bytes"; if (this.#exceedsLimit(limit)) { return () => Promise.reject(new RangeError(`Body exceeds a limit of ${limit}.`)); } return async () => decoder.decode(await this.#valuePromise()); default: throw new TypeError(`Invalid body type: "${type}"`); } }
#validateGetArgs( type: BodyType | undefined, contentTypes: BodyOptionsContentTypes, ) { if (type === "reader" && this.#type && this.#type !== "reader") { throw new TypeError( `Body already consumed as "${this.#type}" and cannot be returned as a reader.`, ); } if (type === "stream" && this.#type && this.#type !== "stream") { throw new TypeError( `Body already consumed as "${this.#type}" and cannot be returned as a stream.`, ); } if (type === "form-data" && this.#type && this.#type !== "form-data") { throw new TypeError( `Body already consumed as "${this.#type}" and cannot be returned as a stream.`, ); } if (this.#type === "reader" && type !== "reader") { throw new TypeError( "Body already consumed as a reader and can only be returned as a reader.", ); } if (this.#type === "stream" && type !== "stream") { throw new TypeError( "Body already consumed as a stream and can only be returned as a stream.", ); } if (this.#type === "form-data" && type !== "form-data") { throw new TypeError( "Body already consumed as form data and can only be returned as form data.", ); } if (type && Object.keys(contentTypes).length) { throw new TypeError( `"type" and "contentTypes" cannot be specified at the same time`, ); } }
#valuePromise() { return this.#readAllBody ?? (this.#readAllBody = this.#readBody()); }
constructor( { body, readBody }: ServerRequestBody, headers: Headers, jsonBodyReviver?: (key: string, value: unknown) => unknown, ) { this.#body = body; this.#headers = headers; this.#jsonBodyReviver = jsonBodyReviver; this.#readBody = readBody; }
get( { limit = DEFAULT_LIMIT, type, contentTypes = {} }: BodyOptions = {}, ): Body | BodyReader | BodyStream { this.#validateGetArgs(type, contentTypes); if (type === "reader") { if (!this.#body) { this.#type = "undefined"; throw new TypeError( `Body is undefined and cannot be returned as "reader".`, ); } this.#type = "reader"; return { type, value: readerFromStreamReader(this.#body.getReader()), }; } if (type === "stream") { if (!this.#body) { this.#type = "undefined"; throw new TypeError( `Body is undefined and cannot be returned as "stream".`, ); } this.#type = "stream"; const streams = ((this.#stream ?? this.#body) as ReadableStream<Uint8Array>) .tee(); this.#stream = streams[1]; return { type, value: streams[0] }; } if (!this.has()) { this.#type = "undefined"; } else if (!this.#type) { const encoding = this.#headers.get("content-encoding") ?? "identity"; if (encoding !== "identity") { throw new errors.UnsupportedMediaType( `Unsupported content-encoding: ${encoding}`, ); } } if (this.#type === "undefined" && (!type || type === "undefined")) { return { type: "undefined", value: undefined }; } if (!type) { const contentType = this.#headers.get("content-type"); assert( contentType, "The Content-Type header is missing from the request", ); type = resolveType(contentType, contentTypes); } assert(type); const body: Body = Object.create(null); Object.defineProperties(body, { type: { value: type, configurable: true, enumerable: true, }, value: { get: this.#parse(type, limit), configurable: true, enumerable: true, }, }); return body; }
has(): boolean { return this.#body != null; }}