UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

247 lines (215 loc) 7.46 kB
import { Readable } from "node:stream"; import { $atom, $hook, $inject, $state, Alepha, type FileLike, isTypeFile, type Static, z, } from "alepha"; import { $logger } from "alepha/logger"; import { HttpError, isMultipart, type ServerRoute } from "alepha/server"; // --------------------------------------------------------------------------------------------------------------------- /** * Multipart configuration atom. */ export const multipartOptions = $atom({ name: "alepha.server.multipart.options", schema: z.object({ limit: z .integer() .meta({ min: 0 }) .describe("Maximum total size of multipart request body in bytes.") .default(10_000_000), fileLimit: z .integer() .meta({ min: 0 }) .describe("Maximum size of a single file in bytes.") .default(5_000_000), fileCount: z .integer() .meta({ min: 1 }) .describe("Maximum number of files allowed in a single request.") .default(10), }), default: { limit: 10_000_000, fileLimit: 5_000_000, fileCount: 10, }, }); export type MultipartOptions = Static<typeof multipartOptions.schema>; declare module "alepha" { interface State { [multipartOptions.key]: MultipartOptions; } } // --------------------------------------------------------------------------------------------------------------------- /** * Parses `multipart/form-data` request bodies into route handler input. * * **This provider buffers.** It reads the entire request body into memory via * `request.formData()` before the handler runs, and each file field is handed * to the handler as an in-memory, `Blob`-backed `FileLike`. It does **not** * stream uploads to the storage layer — by the time `bucket.upload` runs, the * bytes are already fully in RAM. * * This is a deliberate trade-off, kept safe by the `multipartOptions` limits * (default 5 MB/file, 10 MB total, 10 files): the framework targets small * uploads (avatars, attachments, documents), so buffering keeps validation * simple and exact (`blob.size` is known up front) without a streaming * multipart parser. If you need large-file uploads, raise those limits only * alongside a streaming parser — buffering 100 MB per request will exhaust * memory (and blow the Workers memory ceiling). * * @see multipartOptions for the size/count limits that bound the buffering. */ export class ServerMultipartProvider { protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected readonly options = $state(multipartOptions); public readonly onRequest = $hook({ on: "server:onRequest", handler: async ({ route, request }) => { if (request.body) { return; } if (!route.schema?.body) { return; } let webRequest: Request | undefined; if (request.raw.web?.req) { webRequest = request.raw.web.req; } else if (request.raw.node?.req) { webRequest = new Request(request.url, { method: request.method, headers: request.headers, body: Readable.toWeb( request.raw.node.req as Readable, ) as unknown as ReadableStream, duplex: "half", } as RequestInit & { duplex: "half" }); } if (!webRequest) { return; } const contentType = request.headers["content-type"]; // Check content-length before processing to fail fast on oversized requests const contentLength = request.headers["content-length"]; if (contentLength) { const size = Number.parseInt(contentLength, 10); if (!Number.isNaN(size) && size > this.options.limit) { this.log.error( `Multipart request size limit exceeded: ${size} > ${this.options.limit}`, ); throw new HttpError({ status: 413, message: `Request body size limit exceeded. Maximum allowed: ${this.options.limit} bytes`, }); } } if (!contentType?.startsWith("multipart/form-data")) { if (!isMultipart(route)) { return; } throw new HttpError({ status: 415, message: `Invalid content-type: ${contentType} - only "multipart/form-data" is accepted`, }); } request.body = await this.parseMultipart(route, webRequest); }, }); public async parseMultipart( route: ServerRoute, request: Request, ): Promise<Record<string, unknown>> { let formData: FormData; try { formData = await request.formData(); } catch (error) { throw new HttpError( { status: 400, message: "Malformed multipart/form-data", }, error, ); } const body: Record<string, any> = {}; let fileCount = 0; let totalSize = 0; if (route.schema?.body && z.schema.isObject(route.schema.body)) { for (const [key, value] of Object.entries(route.schema.body.properties)) { if (!z.schema.isSchema(value)) { continue; } if (isTypeFile(value)) { const file = formData.get(key); if (file && typeof file === "object" && "arrayBuffer" in file) { const blob = file as Blob; fileCount++; if (fileCount > this.options.fileCount) { this.log.error( `Too many files in multipart request: ${fileCount} > ${this.options.fileCount}`, ); throw new HttpError({ status: 413, message: `Too many files. Maximum allowed: ${this.options.fileCount}`, }); } if (blob.size > this.options.fileLimit) { this.log.error( `File "${key}" exceeds size limit: ${blob.size} > ${this.options.fileLimit}`, ); throw new HttpError({ status: 413, message: `File "${key}" exceeds size limit. Maximum allowed: ${this.options.fileLimit} bytes`, }); } totalSize += blob.size; if (totalSize > this.options.limit) { this.log.error( `Total multipart size exceeds limit: ${totalSize} > ${this.options.limit}`, ); throw new HttpError({ status: 413, message: `Total request size exceeds limit. Maximum allowed: ${this.options.limit} bytes`, }); } body[key] = this.blobToFileLike(blob, key); } } else { const fieldValue = formData.get(key); if (fieldValue !== null) { const stringValue = typeof fieldValue === "string" ? fieldValue : ""; body[key] = this.alepha.codec.decode(value, stringValue); } } } } return body; } protected blobToFileLike(blob: Blob, fieldName: string): FileLike { const isFile = blob instanceof File; return { name: isFile ? blob.name : `${fieldName}_${Date.now()}`, type: blob.type || "application/octet-stream", size: blob.size, lastModified: isFile ? blob.lastModified : Date.now(), stream() { return blob.stream() as unknown as ReadableStream; }, arrayBuffer() { return blob.arrayBuffer(); }, text() { return blob.text(); }, }; } }