Formzilla is a Fastify plugin to handle multipart/form-data content.
Even though other plugins for the same purpose exist, like @fastify/multipart and fastify-multer, when dealing with mixed content, they don't play well with JSON schemas which are Fastify's built-in mechanism for request validation and documentation. Formzilla is intended to work seamlessly with JSON schemas and @fastify/swagger.
Let's say you have an endpoint that accepts multipart/form-data with the following schema.
const postCreateSchema = {
consumes: ["multipart/form-data"],
body: {
type: "object",
properties: {
content: {
type: "string"
},
media: {
type: "string",
format: "binary"
},
poll: {
type: "object",
properties: {
first: { type: "string" },
second: { type: "string" }
},
required: ["first", "second"]
}
}
}
};You will find that neither @fastify/multipart nor fastify-multer will process this schema correctly, unless you add a preValidation hook to convert your request body into the correct schema. I created Formzilla to solve this exact problem.
import fastify, { FastifyInstance } from "fastify";
import formDataParser from "formzilla";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
const postCreateSchema = {
consumes: ["multipart/form-data"],
body: {
type: "object",
properties: {
content: {
type: "string"
},
media: {
type: "string",
format: "binary"
},
poll: {
type: "object",
properties: {
first: { type: "string" },
second: { type: "string" }
},
required: ["first", "second"]
}
}
}
};
const server: FastifyInstance = fastify({ logger: true });
server.register(formDataParser);
server.register(fastifySwagger, {
mode: "dynamic",
openapi: {
info: {
title: "Formzilla Demo",
version: "1.0.0"
}
}
});
server.register(fastifySwaggerUi, {
routePrefix: "/swagger"
});
server.register(async (instance, options) => {
instance.post("/create-post", {
schema: postCreateSchema,
handler: (request, reply) => {
console.log(request.body);
/*
request.body will look like this:
{
content: "Test.",
poll: {
first: "Option 1",
second: "Option 2"
},
media: {
originalName: "flame-wolf.png",
encoding: "7bit",
mimeType: "image/png",
path?: <string>, // Only when using DiscStorage
stream?: <Readable>, // Only when using StreamStorage
data?: <Buffer>, // Only when using BufferStorage
error?: <Error> // Only if any errors occur during processing
}
}
*/
reply.status(200).send();
}
});
});
server.listen(
{
port: +(process.env.PORT as string) || 1024,
host: process.env.HOST || "::"
},
(err, address) => {
if (err) {
console.log(err.message);
process.exit(1);
}
console.log(`Listening on ${address}`);
}
);npm install formzillaI guess this goes without saying, but you must register the plugin before registering your application routes.
- Formzilla 2.x will not work with Fastify versions 4.8 and above. Use Formzilla 3.x with Fastify versions >= 4.8.
- Formzilla 1.x
optionshave been moved tooptions.limitsin Formzilla 2.x. - File content is stored by default in
file.streamas aReadablein Formzilla 2.x whereas in Formzilla 1.x it was stored infile.dataas aBuffer.
These are the valid keys for the options object parameter accepted by Formzilla:
limits: Same as thelimitsconfiguration option for busboy.
const formLimits = {
fieldNameSize: number, // Max field name size (in bytes). Default: 100.
fieldSize: number, // Max field value size (in bytes). Default: 1048576 (1MB).
fields: number, // Max number of non-file fields. Default: Infinity.
fileSize: number, // For multipart forms, the max file size (in bytes). Default: Infinity.
files: number, // For multipart forms, the max number of file fields. Default: Infinity.
parts: number, // For multipart forms, the max number of parts (fields + files). Default: Infinity.
headerPairs: number // For multipart forms, the max number of header key-value pairs to parse. Default: 2000 (same as node's http module).
};
server.register(formDataParser, {
limits: formLimits
});-
storage: Where to store the files, if any, included in the request. Formzilla provides the following built-in options. It is possible to write custom storage plugins of your own.StreamStorage: The default storage option used by Formzilla. Stores file contents as aReadablein thestreamproperty of the file. Example:
server.register(formDataParser, {
storage: new StreamStorage()
});BufferStorage: Emulates Formzilla 1.x behaviour by storing file contents as aBufferin thedataproperty of the file. Example:
server.register(formDataParser, {
storage: new BufferStorage()
});DiscStorage: Saves the file to the disc. Accepts a parameter that can be either aformzilla.FileSaveTargetor a function that accepts aformzilla.Fileparameter and returns aformzilla.FileSaveTarget. By default, Formzilla will save the file to the operating system's TEMP directory. Example:
server.register(formDataParser, {
storage: new DiscStorage(file => {
return {
directory: path.join(__dirname, "public"),
fileName: file.originalName.toUpperCase()
};
})
});CallbackStorage: For advanced users. Accepts a callback function that takes three parameters: astring, aReadable, and abusboy.FileInfo. The callback function must consume theReadableand return either aformzilla.Fileor a promise that resolves to aformzilla.File. Example:
// The following example uploads the incoming stream
// directly to a cloud server. The call to `resolve` is
// nested inside the cloud API's callback function to ensure
// that the `path` property of the `FileInternal` object
// is populated correctly.
server.register(formDataParser, {
storage: new CallbackStorage((name, stream, info) => {
return new Promise(resolve => {
const file = new FileInternal(name, info);
var uploader = cloudinary.v2.uploader.upload_stream((err, res) => {
file.error = err;
file.path = res?.secure_url;
resolve(file);
});
stream.pipe(uploader);
});
})
});Despite its name, StreamStorage does not stream end-to-end — the full file is buffered in a PassThrough before the handler runs. Both StreamStorage and BufferStorage therefore hold the entire file in memory, making your endpoint vulnerable to memory-exhaustion (DoS) attacks from large or concurrent uploads.
CallbackStorage must consume the stream inside the callback, otherwise the request will stall. It is recommended only if you are comfortable with Node.js streams and need to transform or pipe the upload to another destination (e.g. a cloud bucket) without touching disc.
For most use cases, prefer DiscStorage: write the file to a temporary location, upload it to a cloud provider like Cloudinary from your handler, then delete the temp file.
- File data will not be available in
request.bodyuntil thepreHandlerrequest lifecycle stage. So if you want to access the files inside apreValidationhook, userequest.__files__instead. This is a temporary property that gets removed from the request object at thepreHandlerstage. It is done this way for security purposes.