UNPKG

ufiber

Version:

Next-gen webserver for node-js developer

307 lines (305 loc) 7.97 kB
import { kCtxReq, kMatch } from "../consts.js"; import { tryDecode } from "../utils/url.js"; import { getQuery } from "../utils/query.js"; import { UwsReadable } from "./readable.js"; import { FormData, formParse } from "../utils/body.js"; import qs from "node:querystring"; //#region src/http/request.ts const tryDecodeURIComponent = (str) => tryDecode(str, decodeURIComponent); const discardedDuplicates = [ "age", "authorization", "content-length", "content-type", "etag", "expires", "from", "host", "if-modified-since", "if-unmodified-since", "last-modified", "location", "max-forwards", "proxy-authorization", "referer", "retry-after", "server", "user-agent" ]; var Request = class { req; res; /** * The URL pathname (without host or query). * * Always begins with `/`. * * @example * "/users/15" */ path; /** * HTTP method in uppercase (e.g. `POST`, `GET`) */ method; /** * The raw query string including the leading `?`, or empty string if none. * * @example * "?page=2&limit=10" * "" */ urlQuery; isSSL; [kCtxReq] = { body: Object.create(null), headers: Object.create(null), routeIndex: 0 }; [kMatch]; #stream; #rawHeader = []; constructor({ req, res, bodyLimit, methods, isSSL }) { this.req = req; this.res = res; this.method = req.getCaseSensitiveMethod(); const q = req.getQuery(); this.path = req.getUrl(); this.urlQuery = q ? "?" + q : ""; this.isSSL = isSSL; req.forEach((key, value) => { this.#rawHeader.push([key, value]); }); if ([ "POST", "PUT", "PATCH" ].includes(this.method) || methods && methods.includes(this.method)) this.#stream = new UwsReadable(res, bodyLimit); } destroy() { this[kCtxReq].body = Object.create(null); this.#stream?.destroy(/* @__PURE__ */ new Error("Request cancelled during body read")); } /** * The full request URL including protocol, host, pathname, and query. * * @example * "http://localhost:3000/users/15?active=true" */ get url() { const host = this.getHeader("Host"); return (this.isSSL ? "https://" : "http://") + host + this.path + this.urlQuery; } query(key) { return getQuery(this.url, key); } queries(key) { return getQuery(this.url, key, true); } /** * Returns the value of a named route parameter. * * @example * ```ts * ctx.param('id'); // "123" * ``` */ param(field) { const paramKey = this[kMatch][0][this[kCtxReq].routeIndex][1][field]; const param = this.#getParamValue(paramKey); return param && /%/.test(param) ? tryDecodeURIComponent(param) : param; } /** * Returns an object containing all route parameters for the current route. * * @example * ```ts * ctx.params(); // { id: "123", name: "John" } * ``` */ params() { const decoded = {}; const keys = Object.keys(this[kMatch][0][this[kCtxReq].routeIndex][1]); for (const key of keys) { const value = this.#getParamValue(this[kMatch][0][this[kCtxReq].routeIndex][1][key]); if (value !== void 0) decoded[key] = /%/.test(value) ? tryDecodeURIComponent(value) : value; } return decoded; } /** * Resolves the parameter value from the match result. */ #getParamValue = (paramKey) => this[kMatch][1] ? this[kMatch][1][paramKey] : paramKey; /** * Lazily normalizes and caches all request headers. */ #buildHeader() { const store = this[kCtxReq].headers; if (Object.keys(store).length > 0) return; for (const [keyRaw, value] of this.#rawHeader) { const key = keyRaw.toLowerCase(); if (store[key]) { if (discardedDuplicates.includes(key)) continue; if (key === "cookie") { store[key] += "; " + value; continue; } if (key === "set-cookie") { store[key].push(value); continue; } store[key] += ", " + value; continue; } if (key === "set-cookie") store[key] = [value]; else store[key] = value; } } /** * Returns raw header pairs in their original order. */ get rawHeaders() { const arr = []; for (const [k, v] of this.#rawHeader) arr.push(k, v); return arr; } getHeader(field) { this.#buildHeader(); const headers = this[kCtxReq].headers; if (!field) return headers; const key = field.toLowerCase(); if (key === "referrer" || key === "referer") return headers["referrer"] || headers["referer"]; return headers[key]; } /** * Returns a readable stream of the request body. * * @example * ```ts * const file = fs.createWriteStream('upload.bin'); * ctx.stream.pipe(file); * ``` */ get stream() { if (this.#stream && !this.#stream.destroyed) return this.#stream; throw new Error(`Cannot access request body stream for HTTP method '${this.method}'.`); } /** * Reads and returns the request body as a UTF-8 string. * * @example * ```ts * const text = await ctx.textParse(); * console.log('Body:', text); * ``` */ async textParse() { const body = this[kCtxReq].body; if (body.text) return body.text; const text = (await this.stream.getBuffer()).toString("utf-8").trim(); body.text = text; return text; } /** * Reads and returns the request body as an ArrayBuffer. * * @example * ```ts * const arrayBuffer = await ctx.arrayBuffer(); * console.log(arrayBuffer.byteLength); * ``` */ async arrayBuffer() { const body = this[kCtxReq].body; if (body.arrayBuffer) return body.arrayBuffer; const buffer = await this.stream.getBuffer(); const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); body.arrayBuffer = arrayBuffer; return arrayBuffer; } /** * Reads the body as a Blob (Node 18+). * * @example * ```ts * const blob = await ctx.blobParse(); * console.log('Blob size:', blob.size); * ``` * * @returns {Promise<Blob>} */ async blobParse() { const body = this[kCtxReq].body; if (body.blob) return body.blob; const type = this.getHeader("Content-Type") || "application/octet-stream"; const arrayBuffer = await this.arrayBuffer(); const blob = new Blob([arrayBuffer], { type }); body.blob = blob; return blob; } /** * Parses and returns the request body as JSON. * * @template T * @returns {Promise<T>} The parsed JSON body. * @throws {SyntaxError} If body is empty or malformed. * * @example * ```ts * const data = await ctx.jsonParse(); * console.log(data.user, data.email); * ``` */ async jsonParse() { const body = this[kCtxReq].body; if (body.json) return body.json; const text = await this.textParse(); if (!text) throw new SyntaxError("Empty request body, expected JSON"); try { const json = JSON.parse(text); body.json = json; return json; } catch (err) { throw new SyntaxError(`Invalid JSON body: ${err.message}`); } } /** * Parses form submissions (URL-encoded or multipart/form-data). * * @param {FormOption} [options] - Optional multipart parser settings. * @returns {Promise<FormData>} * @throws {TypeError} If content type is unsupported. * * @example * ```ts * const form = await ctx.formParse(); * console.log(form.get('username')); * ``` */ async formParse(options) { const cType = (this.getHeader("content-type") || "").toLowerCase(); const body = this[kCtxReq].body; if (body.formData) return body.formData; if (cType.startsWith("application/x-www-form-urlencoded")) { const form = new FormData(); const text = await this.textParse(); if (!text) throw new SyntaxError("Empty form data"); try { const parsed = qs.parse(text); for (const [k, v] of Object.entries(parsed)) if (Array.isArray(v)) for (const item of v) form.append(k, item); else form.append(k, v); body.formData = form; return form; } catch (error) { throw new SyntaxError("Malformed URL-encoded data"); } } else if (cType.startsWith("multipart/form-data")) { const form = formParse(await this.stream.getBuffer(), cType, options); body.formData = form; return form; } throw new TypeError(`Content-Type '${cType}' not supported for form parsing`); } }; //#endregion export { Request };