UNPKG

graphql-upload-ts

Version:

TypeScript-first middleware and Upload scalar for GraphQL multipart requests (file uploads) with support for Apollo Server, Express, Koa, and more.

344 lines (288 loc) 10.4 kB
import type { IncomingMessage, ServerResponse } from 'node:http'; import busboy, { type Busboy } from 'busboy'; import createError, { type HttpError } from 'http-errors'; import objectPath from 'object-path'; import { type ReadStreamOptions, WriteStream } from './fs-capacitor'; import { ignoreStream } from './ignore-stream'; import { type FileUpload, Upload } from './upload'; import { DEFAULT_MAX_FIELD_SIZE, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_FILES } from './validation'; export const GRAPHQL_MULTIPART_REQUEST_SPEC_URL = 'https://github.com/jaydenseric/graphql-multipart-request-spec' as const; export interface UploadOptions { /** * Maximum allowed size for non-file form fields (in bytes). * This limits the size of text fields like 'operations' and 'map' JSON. * @default 1_000_000 (1MB) */ maxFieldSize?: number; /** * Maximum allowed size for uploaded files (in bytes). * This limits the size of actual file content being uploaded. * @default 5_000_000 (5MB) */ maxFileSize?: number; /** * Maximum number of files allowed in a single request. * @default Infinity */ maxFiles?: number; } export interface GraphQLOperation { query: string; operationName?: string | null; variables?: Record<string, unknown> | null; } export type IncomingReq = Pick< IncomingMessage, 'headers' | 'pipe' | 'unpipe' | 'once' | 'resume' | 'readableEnded' > & { body?: string; rawBody?: string; req?: IncomingMessage; }; export async function processRequest<T = GraphQLOperation | GraphQLOperation[]>( request: IncomingReq, response: Pick<ServerResponse, 'once'>, options?: UploadOptions ): Promise<T> { const { maxFieldSize = DEFAULT_MAX_FIELD_SIZE, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxFiles = DEFAULT_MAX_FILES, } = options ?? {}; return new Promise((resolve, reject) => { let released = false; let exitError: Error | undefined; let operations: T | undefined; let operationsPath: ReturnType<typeof objectPath> | undefined; let map: Map<string, Upload> | undefined; const parser: Busboy = busboy({ headers: request.headers, defParamCharset: 'utf8', limits: { fieldSize: maxFieldSize, fields: 2, // Only operations and map. fileSize: maxFileSize, files: maxFiles, }, }); function exit(error: Error | HttpError, isParserError = false): void { if (exitError) return; exitError = error; if (map) { for (const upload of map.values()) { if (!upload.file) upload.reject(exitError); } } // If the error came from the parser, don’t cause it to be emitted again. if (isParserError) { parser.destroy(); } else { parser.destroy(exitError); } request.unpipe(parser as unknown as NodeJS.ReadWriteStream); // With a sufficiently large request body, subsequent events in the same // event frame cause the stream to pause after the parser is destroyed. To // ensure that the request resumes, the call to .resume() is scheduled for // later in the event loop. setImmediate(() => request.resume()); reject(exitError); } parser.on('field', (fieldName, value, { valueTruncated }) => { if (valueTruncated) return exit( createError( 413, `The ‘${fieldName}’ multipart field value exceeds the ${maxFieldSize} byte size limit.` ) ); switch (fieldName) { case 'operations': try { operations = JSON.parse(value); } catch (_error) { return exit( createError( 400, `Invalid JSON in the ‘operations’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).` ) ); } // `operations` should be an object or an array. Note that arrays // and `null` have an `object` type. if (typeof operations !== 'object' || !operations) return exit( createError( 400, `Invalid type for the ‘operations’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).` ) ); operationsPath = objectPath(operations); break; case 'map': { if (!operations) return exit( createError( 400, `Disordered multipart fields; ‘map’ should follow ‘operations’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).` ) ); let parsedMap: Record<string, unknown>; try { parsedMap = JSON.parse(value); } catch (_error) { return exit( createError( 400, `Invalid JSON in the ‘map’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).` ) ); } // `map` should be an object. if (typeof parsedMap !== 'object' || !parsedMap || Array.isArray(parsedMap)) return exit( createError( 400, `Invalid type for the ‘map’ multipart field (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).` ) ); const mapEntries = Object.entries(parsedMap); // Check max files is not exceeded, even though the number of files // to parse might not match the map provided by the client. if (mapEntries.length > maxFiles) return exit(createError(413, `${maxFiles} max file uploads exceeded.`)); map = new Map(); for (const [fieldName, paths] of mapEntries) { if (!Array.isArray(paths)) return exit( createError( 400, `Invalid type for the ‘map’ multipart field entry key ‘${fieldName}’ array (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).` ) ); map.set(fieldName, new Upload()); for (const [index, path] of paths.entries()) { if (typeof path !== 'string') return exit( createError( 400, `Invalid type for the ‘map’ multipart field entry key ‘${fieldName}’ array index ‘${index}’ value (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).` ) ); try { operationsPath?.set(path, map.get(fieldName)); } catch (_error) { return exit( createError( 400, `Invalid object path for the ‘map’ multipart field entry key ‘${fieldName}’ array index ‘${index}’ value ‘${path}’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).` ) ); } } } resolve(operations as T); } } }); parser.on('file', (fieldName, stream, { filename, encoding, mimeType: mimetype }) => { if (!map) { ignoreStream(stream); return exit( createError( 400, `Disordered multipart fields; files should follow ‘map’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).` ) ); } const upload = map.get(fieldName); if (!upload) { // The file is extraneous. As the rest can still be processed, just // ignore it and don’t exit with an error. ignoreStream(stream); return; } let fileError: Error | undefined; const capacitor = new WriteStream(); capacitor.on('error', () => { stream.unpipe(); stream.resume(); }); stream.on('limit', () => { fileError = createError( 413, `File truncated as it exceeds the ${maxFileSize} byte size limit.` ); stream.unpipe(); capacitor.destroy(fileError); }); stream.on('error', (error) => { fileError = error; stream.unpipe(); capacitor.destroy(fileError); }); const file: FileUpload = { fieldName, filename, mimetype, encoding, createReadStream(options?: ReadStreamOptions) { const error = fileError || (released ? exitError : null); if (error) throw error; return capacitor.createReadStream(options); }, capacitor, }; Object.defineProperty(file, 'capacitor', { enumerable: false, configurable: false, writable: false, }); stream.pipe(capacitor); upload.resolve(file); }); parser.once('filesLimit', () => exit(createError(413, `${maxFiles} max file uploads exceeded.`)) ); parser.once('finish', () => { request.unpipe(parser as unknown as NodeJS.ReadWriteStream); request.resume(); if (!operations) return exit( createError( 400, `Missing multipart field ‘operations’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).` ) ); if (!map) return exit( createError(400, `Missing multipart field ‘map’ (${GRAPHQL_MULTIPART_REQUEST_SPEC_URL}).`) ); for (const upload of map.values()) if (!upload.file) upload.reject(createError(400, 'File missing in the request.')); }); // Use the `on` method instead of `once` as in edge cases the same parser // could have multiple `error` events and all must be handled to prevent the // Node.js process exiting with an error. One edge case is if there is a // malformed part header as well as an unexpected end of the form. parser.on('error', (error: Error) => { exit(error, true); }); response.once('close', () => { released = true; if (map) { for (const upload of map.values()) { if (upload.file) { // Release resources and clean up temporary files. upload.file.capacitor.release(); } } } }); request.once('close', () => { if (!request.readableEnded) exit(createError(499, 'Request disconnected during file upload stream parsing.')); }); request.pipe(parser as unknown as NodeJS.WritableStream); }); }