javascriptnode.jsnestjsmulternodejs-stream

Nest js / node js file upload with multer: Pass a file stream to function handler


Multer gets a file from the request in a readable stream, and with default engines, we can save the file on disk or in RAM. After that, it's accessible in a file object that goes to the route handler. In my case, I want to upload the file to S3, so I need access to the stream, but there is no way of getting that in the handler function.

This issue describes how to solve it by creating a custom engine or using a library: https://github.com/aws/aws-sdk-js-v3/issues/5479.

And this is a working solution, but I don't like that I have to pass a custom engine in the decorator. I'm working on a Nest.js app, and I have a separate module and class to work with AWS S3, which has its own dependencies. As I'm having to pass a custom engine in the decorator, it means I have to recreate all dependencies, which is not the Nest way of doing things.

I have tried to develop a custom engine and was looking for a configuration options - nothing.

I'm looking for a way to pass a readable stream to the handler. Answering ahead - saving on disk, creating a readable stream, and deleting the file is not an option.


Solution

  • Did not find a way to do that with multer, its just how multer works. It will not pass the control to handler until the file stream is not red. I ended up with custom solution, custom decorator. Under the hood multer uses busboy to handle multipart/form-data, so i did the same thing:

    @Injectable()
    export class UploadSingleFileInterceptor implements NestInterceptor {
      constructor(private busboyService: BusboyService) {}
    
      intercept(
        context: ExecutionContext,
        next: CallHandler,
      ): Promise<Observable<unknown>> {
        const ctx = context.switchToHttp();
        const req = ctx.getRequest<Request>();
    
        return new Promise((resolve, reject) => {
          this.busboyService.create({
            request: req,
            headers: req.headers,
            defCharset: FileUploadConstants.DEFAULT_CHARSET,
            limits: {
              parts: FileUploadConstants.MAX_FORM_FIELDS,
              files: FileUploadConstants.MAX_FORM_FILE_FIELDS,
              fields: FileUploadConstants.MAX_FORM_TEXT_FIELDS,
            },
            handleEvents: {
              file(fieldName, stream, { filename, encoding, mimeType }) {
                if (!req.user) {
                  req.user = {};
                }
                const [type] = mimeType.split("/");
                const [format] = filename.split(".").slice(-1);
                const size = Number(req.header(HttpHeaders.CONTENT_LENGTH));
    
                if (!size) {
                  return reject(
                    new BadRequestException(
                      FilesErrors.INVALID_CONTENT_SIZE_LENGTH,
                    ),
                  );
                }
    
                req.user.uploadedFile = {
                  fieldName,
                  stream,
                  encoding,
                  mimeType,
                  type,
                  format,
                  size,
                  name: filename,
                };
    
                resolve(next.handle());
              },
              partsLimit() {
                reject(
                  new BadRequestException(FilesErrors.MULTIPART_FORM_PARTS_LIMIT),
                );
              },
            },
          });
        });
      }
    }
    

    And the busboy service:

    interface IBusboyConfigExtend extends busboy.BusboyConfig {
      request?: Request;
      handleEvents?: Partial<busboy.BusboyEvents>;
    }
    
    @Injectable()
    export class BusboyService {
      private busboy = busboy;
    
      create(options?: IBusboyConfigExtend) {
        const busboy = this.busboy(options);
    
        if (options?.request) {
          options.request.pipe(busboy);
        }
    
        if (options?.handleEvents) {
          for (const event in options.handleEvents) {
            busboy.on(event, options.handleEvents[event]);
          }
        }
    
        return busboy;
      }
    }
    

    It works only with 1 file per request. After applying that interceptor on route I used an parameter decorator to get the file data from the request user object and pass it to the handler, which is what i wanted in the first place.