UNPKG

@push.rocks/smartrequest

Version:

A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.

168 lines (139 loc) 4.37 kB
const lineBreak = '\r\n'; const defaultContentType = 'application/octet-stream'; const textEncoder = new TextEncoder(); export interface IMultipartFormDataAppendOptions { filename: string; contentType?: string; } interface IMultipartFormDataPart { name: string; value: Uint8Array; filename?: string; contentType?: string; } const escapeContentDispositionValue = (value: string): string => { return value .replace(/\\/g, '\\\\') .replace(/\r/g, '%0D') .replace(/\n/g, '%0A') .replace(/"/g, '%22'); }; const getFilename = (filename: string): string => { const normalizedFilename = filename.replace(/\\/g, '/'); return normalizedFilename.slice(normalizedFilename.lastIndexOf('/') + 1); }; const createBoundary = (): string => { const randomBytes = new Uint8Array(12); globalThis.crypto.getRandomValues(randomBytes); const randomHex = Array.from(randomBytes, (byte) => byte.toString(16).padStart(2, '0'), ).join(''); return `--------------------------${randomHex}`; }; /** * Minimal multipart/form-data encoder for the values accepted by SmartRequest. */ export class MultipartFormData { private readonly boundary: string; private readonly parts: IMultipartFormDataPart[] = []; constructor(boundary = createBoundary()) { if ( boundary.length > 70 || !/^[0-9A-Za-z'()+_,./:=?-]+$/.test(boundary) ) { throw new Error('Multipart boundary contains invalid characters.'); } this.boundary = boundary; } public append( name: string, value: string | Uint8Array, options?: IMultipartFormDataAppendOptions, ): void { let filename: string | undefined; let contentType: string | undefined; if (typeof value !== 'string') { filename = getFilename(options?.filename || 'file'); contentType = options?.contentType; this.validateContentType(contentType); } this.parts.push({ name, value: typeof value === 'string' ? textEncoder.encode(value) : value, filename, contentType, }); } public resolveMissingContentTypes( resolver: (filename: string) => string | null, ): void { for (const part of this.parts) { if (part.filename && !part.contentType) { part.contentType = resolver(part.filename) || defaultContentType; this.validateContentType(part.contentType); } } } public getHeaders(): Record<string, string> { return { 'content-type': `multipart/form-data; boundary=${this.boundary}`, }; } public getContentLength(): number { let length = textEncoder.encode(`--${this.boundary}--${lineBreak}`).byteLength; for (const part of this.parts) { length += this.getPartHeader(part).byteLength + part.value.byteLength + lineBreak.length; } return length; } public toArrayBuffer(): ArrayBuffer { const buffer = new ArrayBuffer(this.getContentLength()); const body = new Uint8Array(buffer); let offset = 0; for (const chunk of this.getChunks()) { body.set(chunk, offset); offset += chunk.byteLength; } return buffer; } public *getChunks(): Iterable<Uint8Array> { const lineBreakBuffer = textEncoder.encode(lineBreak); for (const part of this.parts) { yield this.getPartHeader(part); yield part.value; yield lineBreakBuffer; } yield textEncoder.encode(`--${this.boundary}--${lineBreak}`); } private getPartHeader(part: IMultipartFormDataPart): Uint8Array { const dispositionParts = [ 'form-data', `name="${escapeContentDispositionValue(part.name)}"`, ]; const headerLines = [`--${this.boundary}`]; if (part.filename) { dispositionParts.push( `filename="${escapeContentDispositionValue(part.filename)}"`, ); } headerLines.push( `Content-Disposition: ${dispositionParts.join('; ')}`, ); if (part.filename) { headerLines.push( `Content-Type: ${part.contentType || defaultContentType}`, ); } return textEncoder.encode( `${headerLines.join(lineBreak)}${lineBreak}${lineBreak}`, ); } private validateContentType(contentType: string | undefined): void { if (contentType && /[\r\n]/.test(contentType)) { throw new Error('Multipart content type must be a single-line value.'); } } }