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.
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.