@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
text/typescript
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.');
}
}
}