caffco/async-busboy

View on GitHub
src/index.ts

Summary

Maintainability
B
5 hrs
Test Coverage
A
97%
import type { BusboyConfig, BusboyEvents } from 'busboy';
import Busboy from 'busboy';
import fs, { ReadStream } from 'fs';
import type { IncomingMessage } from 'http';
import os from 'os';
import path from 'path';

export interface ErrorWithCodeAndStatus extends Error {
  code: string;
  status: number;
}

interface AsyncBusboyConfig extends BusboyConfig {
  onFile?: BusboyEvents['file'];
}

interface ReadStreamWithMetadata extends ReadStream {
  fieldname: string;
  filename: string;
  transferEncoding: string;
  encoding: string;
  mimeType: string;
  mime: string;
}

export default function (
  request: IncomingMessage,
  overridenOptions: Partial<AsyncBusboyConfig> = {},
) {
  const options: AsyncBusboyConfig = {
    ...overridenOptions,
    headers: {
      'content-type': '',
      ...request.headers,
      ...overridenOptions.headers,
    },
  };

  const busboy = Busboy(options);

  return new Promise<{
    fields: Record<string, string | Array<string>>;
    files: Array<ReadStreamWithMetadata>;
  }>((resolve, reject) => {
    const fields: Record<string, string | Array<string>> = {};
    const filePromises: Array<Promise<ReadStreamWithMetadata>> = [];

    const onField = onFieldFactory(fields);
    const onFile = options.onFile ?? onFileFactory(filePromises);

    const onError = (err: Error): void => {
      cleanup();
      reject(err);
    };

    const onEnd = (err?: Error): void => {
      if (err) {
        reject(err);
        return;
      }

      Promise.all(filePromises)
        .then((files) => {
          cleanup();
          resolve({ fields, files });
        })
        .catch(reject);
    };

    const cleanup = () => {
      busboy.removeListener('field', onField);
      busboy.removeListener('file', onFile);
      busboy.removeListener('close', cleanup);
      busboy.removeListener('end', cleanup);
      busboy.removeListener('error', onEnd);
      busboy.removeListener('partsLimit', onEnd);
      busboy.removeListener('filesLimit', onEnd);
      busboy.removeListener('fieldsLimit', onEnd);
      busboy.removeListener('finish', onEnd);
    };

    request.on('close', cleanup);

    busboy
      .on('field', onField)
      .on('file', onFile)
      .on('error', onError)
      .on('end', onEnd)
      .on('close', onEnd)
      .on('finish', onEnd)
      .on('partsLimit', () =>
        onError(
          createError({
            message: 'Reach parts limit',
            code: 'Request_parts_limit',
          }),
        ),
      )
      .on('filesLimit', () =>
        onError(
          createError({
            message: 'Reach files limit',
            code: 'Request_files_limit',
          }),
        ),
      )
      .on('fieldsLimit', () =>
        onError(
          createError({
            message: 'Reach fields limit',
            code: 'Request_fields_limit',
          }),
        ),
      );

    request.pipe(busboy);
  });
}

const hasOwnProperty = <T extends object>(target: T, propertyName: string) =>
  Object.prototype.hasOwnProperty.call(target, propertyName);

const onFieldFactory =
  (fields: Record<string, string | Array<string>>): BusboyEvents['field'] =>
  (name, val) => {
    // Don't overwrite prototypes
    if (hasOwnProperty(Object.prototype, name)) {
      return;
    }

    // This looks like a stringified array, let's parse it
    if (name.indexOf('[') > -1) {
      const obj = objectFromBluePrint(getKeyPaths(name), val);
      reconcile(obj, fields);
      return;
    }

    if (!hasOwnProperty(fields, name)) {
      fields[name] = val;
      return;
    }

    if (Array.isArray(fields[name])) {
      (fields[name] as Array<Parameters<BusboyEvents['field']>[0]>).push(val);
    } else {
      (fields[name] as Array<Parameters<BusboyEvents['field']>[0]>) = [
        fields[name] as Parameters<BusboyEvents['field']>[0],
        val,
      ];
    }
  };

const onFileFactory =
  (
    filePromises: Array<Promise<ReadStreamWithMetadata>>,
  ): BusboyEvents['file'] =>
  (
    fieldname,
    file: Parameters<BusboyEvents['file']>[1] & {
      tmpName: string;
    },
    info,
  ) => {
    const tmpName =
      Math.random().toString(16).substring(2) + '-' + info.filename;
    file.tmpName = tmpName;
    const saveTo = path.join(os.tmpdir(), path.basename(tmpName));
    const writeStream = fs.createWriteStream(saveTo);

    const filePromise = new Promise<ReadStreamWithMetadata>((resolve, reject) =>
      writeStream
        .on('open', () =>
          file
            .pipe(writeStream)
            .on('error', reject)
            .on('finish', () => {
              const readStream = fs.createReadStream(
                saveTo,
              ) as ReadStreamWithMetadata;
              readStream.fieldname = fieldname;
              readStream.filename = info.filename;
              readStream.transferEncoding = info.encoding;
              readStream.encoding = info.encoding;
              readStream.mimeType = info.mimeType;
              readStream.mime = info.mimeType;
              resolve(readStream);
            }),
        )
        .on('error', (err) => {
          file.resume().on('error', reject);
          reject(err);
        }),
    );
    filePromises.push(filePromise);
  };

const createError = ({
  message,
  code,
}: {
  message: string;
  code: string;
}): ErrorWithCodeAndStatus => {
  const err = new Error(message) as unknown as ErrorWithCodeAndStatus;
  err.code = code;
  err.status = 413;
  return err;
};

/**
 * Extract a hierarchy array from a stringified formData single input.
 *
 * @example topLevel[sub1][sub2] => [topLevel, sub1, sub2]
 * @param string Stringify representation of a formData Object
 */
const getKeyPaths = (keyPath: string): Array<string> => {
  const [head, ...tail] = keyPath.split('[');
  const keysInKeyPath = tail.map((v) => v.split(']')[0]);
  return [head, ...keysInKeyPath];
};

/**
 * Generate an object given an hierarchy blueprint and the value
 *
 * @example [key1, key2, key3] => { key1: {key2: { key3: value }}};
 * @param arr List of key paths, from `getKeyPaths`
 * @param value The actual value for this key path
 */
const objectFromBluePrint = <T>(arr: Array<string>, value: T): object =>
  arr.reduceRight<object>((acc, next) => {
    if (Number(next).toString() === 'NaN') {
      return { [next]: acc };
    }

    const newAcc = [];
    newAcc[Number(next)] = acc;
    return newAcc;
  }, value as object);

/**
 * Merge formatted data with already formatted data
 *
 * @note This function modifies `target`
 * @param obj Object to be merged into `target`
 * @param target The field object that will be modified
 */
const reconcile = (obj: object, target: object): void => {
  const [[key, val]] = Object.entries(obj);

  if (hasOwnProperty(target, key)) {
    reconcile(val, target[key]);
  } else {
    target[key] = val;
  }
};