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.

200 lines (184 loc) 7.54 kB
import { EventEmitter } from 'node:events'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; import type { HttpError } from 'http-errors'; import { processRequest as defaultProcessRequest, type GraphQLOperation, type IncomingReq, type UploadOptions, } from './process-request'; // --------------------------------------------------------------------------- // Bunway compatibility layer // --------------------------------------------------------------------------- // `graphql-upload-ts` has historically targeted Node's Express/Koa runtime // where the request is an `IncomingMessage` (a Readable stream) and the // response is an EventEmitter-backed `ServerResponse`. Bunway runs on // Bun.serve and exposes a `BunRequest` whose body is available as bytes via // `await req.rawBody()` and a `BunResponse` that has no EventEmitter surface. // // This middleware bridges those two models: it buffers the multipart body, // wraps it in a Node `Readable` that ALSO carries the plain headers / url / // method fields busboy reads synchronously, and shims `.once` / `.on` on the // response so capacitor cleanup can still be wired. // // It does NOT depend on Express or Koa types — consumers pass duck-typed // `BunRequest` / `BunResponse` shapes (see `BunRequestLike` / `BunResponseLike` // below) and the returned middleware has Bunway's `(req, res, next)` shape. /** * Minimal shape of a Bunway `BunRequest` that `graphqlUploadBunway` reads. * Only the surface area actually used is required, so consumers aren't locked * into a specific bunway version or even a specific framework. */ export interface BunRequestLike { /** Original Fetch Request that bun gave the router. Must expose the URL, * method, and Fetch `Headers` used to drive busboy. */ original: { url: string; method: string; headers: { get(name: string): string | null; forEach(cb: (value: string, key: string) => void): void; }; }; /** Returns the original multipart request bytes. Bunway stashes these * before its body-parser consumes the body. */ rawBody(): Promise<Uint8Array>; /** Populated by this middleware on success (the parsed GraphQL operation * with `FileUpload` scalars substituted). Downstream handlers read this. */ body?: unknown; } /** * Minimal shape of a Bunway `BunResponse`. We install `.once` / `.on` shims * if they are missing, and call the existing `.end` / `.status` on errors. */ export interface BunResponseLike { status(code: number): BunResponseLike; end(body?: unknown): unknown; once?(event: string, listener: (...args: unknown[]) => void): BunResponseLike; on?(event: string, listener: (...args: unknown[]) => void): BunResponseLike; } export type BunwayNextFunction = (err?: unknown) => void; export type BunwayHandler = ( req: BunRequestLike, res: BunResponseLike, next: BunwayNextFunction ) => void; type ProcessRequestFn = <T = GraphQLOperation | GraphQLOperation[]>( req: IncomingReq, res: Pick<ServerResponse, 'once'>, options?: UploadOptions ) => Promise<T>; export interface GraphqlUploadBunwayOptions extends UploadOptions { processRequest?: ProcessRequestFn; } /** * Creates a Bunway middleware for handling GraphQL multipart requests. * * @example * ```ts * import bunway from 'bunway'; * import { graphqlUploadBunway } from 'graphql-upload-ts'; * import { createYoga } from 'graphql-yoga'; * * const app = bunway(); * const yoga = createYoga({ schema, multipart: false }); * * app.all( * '/graphql', * graphqlUploadBunway({ maxFileSize: 20_000_000, maxFiles: 10 }), * yogaToBunwayHandler(yoga), * ); * ``` * * On success the middleware sets `req.body` to the parsed GraphQL operation * with `FileUpload` scalars substituted into the `variables` tree, then calls * `next()`. On a parse/validation error it calls `next(error)` with an * `HttpError` the caller can translate to a status code. */ export function graphqlUploadBunway(options: GraphqlUploadBunwayOptions = {}): BunwayHandler { const { processRequest = defaultProcessRequest, ...uploadOptions } = options; return function graphqlUploadBunwayMiddleware( req: BunRequestLike, res: BunResponseLike, next: BunwayNextFunction ): void { const contentType = (req.original.headers.get('content-type') ?? '').toLowerCase(); if (!contentType.startsWith('multipart/')) { next(); return; } // Kick off the async flow in a detached promise so we can keep the // middleware's synchronous signature (matches every other graphql-upload-ts // middleware). void (async () => { let rawBody: Uint8Array; try { rawBody = await req.rawBody(); } catch (err) { next(err); return; } // Plain headers record for busboy (it iterates `req.headers` as an // object). Bunway/Fetch `Headers` expose `get` + `forEach`, not // object-property access, so we materialize a record up-front. const headers: Record<string, string> = {}; req.original.headers.forEach((value, key) => { headers[key] = value; }); // Readable that yields the captured bytes. busboy pipes it; nothing // else should attach data/end listeners first, or it will switch to // flowing mode and the parser will see an empty stream. const nodeReadable = Readable.from([Buffer.from(rawBody)]); const shimReq = nodeReadable as unknown as IncomingMessage & { body: unknown }; (shimReq as unknown as { headers: Record<string, string> }).headers = headers; (shimReq as unknown as { url: string }).url = req.original.url; (shimReq as unknown as { method: string }).method = req.original.method; shimReq.body = null; // Bunway's BunResponse has no EventEmitter surface. Install a minimal // `.once` / `.on` backed by an internal EventEmitter so graphql-upload // can register its capacitor cleanup listeners, and fire 'close' when // `res.end()` runs. const resEmitter = new EventEmitter(); const hadOnce = typeof res.once === 'function'; if (!hadOnce) { (res as unknown as { once: BunResponseLike['once'] }).once = (( event: string, listener: (...args: unknown[]) => void ) => { resEmitter.once(event, listener); return res; }) as BunResponseLike['once']; (res as unknown as { on: BunResponseLike['on'] }).on = (( event: string, listener: (...args: unknown[]) => void ) => { resEmitter.on(event, listener); return res; }) as BunResponseLike['on']; const originalEnd = res.end.bind(res) as BunResponseLike['end']; res.end = ((...args: Parameters<BunResponseLike['end']>) => { const ret = originalEnd(...args); resEmitter.emit('close'); return ret; }) as BunResponseLike['end']; } try { const body = await processRequest( shimReq as unknown as IncomingReq, res as unknown as Pick<ServerResponse, 'once'>, uploadOptions ); req.body = body; shimReq.body = body; next(); } catch (err) { const httpErr = err as HttpError | Error; if ('status' in httpErr && 'expose' in httpErr && (httpErr as HttpError).expose) { res.status((httpErr as HttpError).status); } next(err); } })(); }; }