@aegisx/fastify-multipart
v1.0.1
Published
Production-ready Fastify plugin for multipart/form-data with clean API and full Swagger UI support
Downloads
1,098
Maintainers
Readme
@aegisx/fastify-multipart
Production-ready Fastify plugin for handling multipart/form-data with a clean API and full Swagger UI support. This plugin solves the common issue where text fields become objects with { value: "string" } instead of plain strings, ensuring perfect compatibility with Swagger UI forms.
Features
- ✅ Clean API: Text fields are plain strings, not wrapped objects
- ✅ Full Swagger UI Support: Works perfectly with Swagger UI form submissions
- ✅ Compatible API: Drop-in replacement for @fastify/multipart
- ✅ Automatic Cleanup: Temporary files are cleaned up automatically
- ✅ TypeScript Support: Full TypeScript definitions included
- ✅ Streaming Support: Efficient file handling with streams
- ✅ Configurable Limits: Control file sizes, field counts, and more
Requirements
- Node.js >= 18
- Fastify 4.x or 5.x
Installation
npm install @aegisx/fastify-multipartQuick Start
const fastify = require('fastify')()
const multipart = require('@aegisx/fastify-multipart')
// Register the plugin
await fastify.register(multipart)
// Create an upload route
fastify.post('/upload', async (request, reply) => {
const { files, fields } = await request.parseMultipart()
// fields.name is a plain string, not { value: "string" }
console.log('Name:', fields.name)
console.log('Description:', fields.description)
// Handle uploaded files
for (const file of files) {
console.log('File:', file.filename, file.size, 'bytes')
// Save file or process it
const buffer = await file.toBuffer()
}
return { success: true }
})API Documentation
Plugin Options
await fastify.register(multipart, {
limits: {
fileSize: 1024 * 1024 * 10, // 10MB (default)
files: 10, // Max number of files (default: 10)
fields: 20, // Max number of fields (default: 20)
fieldNameSize: 100, // Max field name length (default: 100)
fieldSize: 1024 * 1024, // Max field value size (default: 1MB)
headerPairs: 2000 // Max header pairs (default: 2000)
},
tempDir: '/tmp', // Temp directory (default: os.tmpdir())
autoContentTypeParser: true // Auto-register parser (default: true)
})Request Methods
request.parseMultipart()
Parse multipart form data. Returns a promise with files and fields.
const { files, fields, _tempFiles } = await request.parseMultipart()
// fields are plain strings
console.log(fields.category) // "electronics"
console.log(fields.description) // "Product description"
// files array contains file objects
for (const file of files) {
console.log(file.filename)
console.log(file.mimetype)
console.log(file.size)
}request.file()
Get the first uploaded file or null.
const file = request.file()
if (file) {
const buffer = await file.toBuffer()
}request.files()
Get all uploaded files as an array.
const files = request.files()
for (const file of files) {
const stream = file.createReadStream()
// Process stream...
}request.parts()
Get an async iterator for streaming multipart parts.
for await (const part of request.parts()) {
if (part.type === 'file') {
// Handle file stream
console.log('File:', part.filename)
// part.stream is a readable stream
} else {
// Handle field
console.log('Field:', part.fieldname, part.value)
}
}request.cleanupTempFiles()
Manually cleanup temporary files (automatic cleanup happens on response).
await request.cleanupTempFiles()File Object
Each file object has the following properties and methods:
{
filename: 'image.jpg', // Original filename
encoding: '7bit', // File encoding
mimetype: 'image/jpeg', // MIME type
size: 102400, // Size in bytes (getter)
toBuffer(): Promise<Buffer>, // Read file into buffer
createReadStream(): Readable, // Create read stream
_tempPath: '/tmp/upload_xxx' // Temp file path (internal)
}Error Handling
The plugin exports error constructors via fastify.multipartErrors:
fastify.post('/upload', async (request, reply) => {
try {
const { files, fields } = await request.parseMultipart()
// Process upload...
} catch (err) {
if (err instanceof fastify.multipartErrors.FileSizeLimit) {
return reply.code(413).send({ error: 'File too large' })
}
if (err instanceof fastify.multipartErrors.FilesLimit) {
return reply.code(413).send({ error: 'Too many files' })
}
throw err
}
})Swagger UI Integration
This plugin works perfectly with Swagger UI form submissions. Here's the recommended setup:
const fastify = require('fastify')()
const multipart = require('@aegisx/fastify-multipart')
const swagger = require('@fastify/swagger')
const swaggerUI = require('@fastify/swagger-ui')
// Register Swagger first
await fastify.register(swagger, { /* options */ })
await fastify.register(swaggerUI, { /* options */ })
// Register multipart plugin with validation bypass
await fastify.register(multipart, {
autoContentTypeParser: false // Important!
})
// Custom content type parser
fastify.addContentTypeParser('multipart/form-data', function (request, payload, done) {
done(null, payload)
})
// Bypass validation for multipart routes (prevents validation errors)
fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {
return function validate(data) {
// Skip body validation for upload routes
if (httpPart === 'body' && url && url.includes('/upload')) {
return { value: data }
}
return { value: data }
}
})
fastify.post('/upload/products', {
schema: {
summary: 'Create product with image',
consumes: ['multipart/form-data'],
body: {
type: 'object',
properties: {
name: { type: 'string' },
category: { type: 'string' },
description: { type: 'string' },
image: { type: 'string', format: 'binary' }
},
required: ['name', 'category']
}
}
}, async (request, reply) => {
const { files, fields } = await request.parseMultipart()
// Manual validation since schema validation is bypassed
if (!fields.name || !fields.category) {
return reply.code(400).send({ error: 'Name and category are required' })
}
// Text fields are plain strings - works perfectly with Swagger UI!
console.log('Name:', fields.name) // "Product Name"
console.log('Category:', fields.category) // "Electronics"
return { success: true, data: fields }
})Why This Setup?
The custom validator bypass prevents Fastify from trying to validate multipart form data against JSON schemas, which causes the "Value must be a string" errors you might have seen. With this setup:
✅ Swagger UI displays the form correctly
✅ No validation errors
✅ Text fields are plain strings
✅ Perfect user experience
Migration from @fastify/multipart
Migrating from @fastify/multipart is straightforward:
Before (with @fastify/multipart):
const multipart = require('@fastify/multipart')
await fastify.register(multipart, { attachFieldsToBody: true })
fastify.post('/upload', async (request, reply) => {
// Fields are wrapped objects
const name = request.body.name.value // { value: "John" }
const email = request.body.email.value // { value: "[email protected]" }
// Files need separate handling
const files = request.files()
})After (with @aegisx/fastify-multipart):
const multipart = require('@aegisx/fastify-multipart')
await fastify.register(multipart)
fastify.post('/upload', async (request, reply) => {
const { files, fields } = await request.parseMultipart()
// Fields are plain strings!
const name = fields.name // "John"
const email = fields.email // "[email protected]"
// Files are included in the same result
})Comparison with @fastify/multipart
| Feature | @aegisx/fastify-multipart | @fastify/multipart |
|---------|---------------------------|-------------------|
| Text fields format | Plain strings ✅ | Wrapped objects { value } |
| Swagger UI compatibility | Full support ✅ | Requires workarounds |
| API simplicity | Single method returns all ✅ | Multiple methods needed |
| TypeScript support | Full definitions ✅ | Full definitions ✅ |
| Automatic cleanup | Yes ✅ | Yes ✅ |
| Streaming support | Yes ✅ | Yes ✅ |
| Field validation | Direct validation ✅ | Complex validation |
| Node.js support | >= 18 | >= 14 |
| CI/CD tested | Node 18, 20, 22 ✅ | Varies |
TypeScript Usage
import fastify from 'fastify'
import multipart, { MultipartFile, MultipartParseResult } from '@aegisx/fastify-multipart'
const app = fastify()
await app.register(multipart)
app.post('/upload', async (request, reply) => {
const { files, fields }: MultipartParseResult = await request.parseMultipart()
// TypeScript knows fields are Record<string, string>
const name: string = fields.name
// TypeScript knows files array structure
files.forEach((file: MultipartFile) => {
console.log(file.filename)
})
return { success: true }
})Advanced Examples
Handle Large Files with Streaming
fastify.post('/upload-large', async (request, reply) => {
for await (const part of request.parts()) {
if (part.type === 'file') {
// Stream directly to storage instead of loading into memory
const writeStream = fs.createWriteStream(`./uploads/${part.filename}`)
await pipeline(part.stream, writeStream)
}
}
return { success: true }
})Custom Error Handling
fastify.setErrorHandler((error, request, reply) => {
if (error instanceof fastify.multipartErrors.FileSizeLimit) {
reply.status(413).send({
statusCode: 413,
error: 'Payload Too Large',
message: `File size limit exceeded: ${error.message}`
})
} else {
reply.send(error)
}
})Conditional File Processing
fastify.post('/upload-images', async (request, reply) => {
const { files, fields } = await request.parseMultipart()
const imageFiles = files.filter(file =>
file.mimetype.startsWith('image/')
)
if (imageFiles.length === 0) {
return reply.code(400).send({ error: 'No images uploaded' })
}
// Process only image files
for (const image of imageFiles) {
const buffer = await image.toBuffer()
// Process image...
}
return { processed: imageFiles.length }
})Troubleshooting
Common Issues
Swagger Validation Error: "Value must be a string"
This happens when Fastify tries to validate multipart form data against JSON schemas.
Solution: Use the validation bypass setup shown in the Swagger Integration section:
await fastify.register(multipart, { autoContentTypeParser: false }) fastify.addContentTypeParser('multipart/form-data', function (request, payload, done) { done(null, payload) }) fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => { return function validate(data) { if (httpPart === 'body' && url && url.includes('/upload')) { return { value: data } } return { value: data } } })"Unexpected end of form" Error
This can happen if the content type parser conflicts with the plugin.
Solution: Set
autoContentTypeParser: falseand register manually:await fastify.register(multipart, { autoContentTypeParser: false })File Size Limit Exceeded
// Increase file size limit await fastify.register(multipart, { limits: { fileSize: 1024 * 1024 * 50 } // 50MB })Too Many Files
// Increase file count limit await fastify.register(multipart, { limits: { files: 20 } })Field Value Too Large
// Increase field size limit await fastify.register(multipart, { limits: { fieldSize: 1024 * 1024 * 5 } // 5MB })
Debug Mode
Enable debug logging to troubleshoot issues:
const fastify = require('fastify')({ logger: true })Quick Test
Use this simple test to verify the plugin works:
const fastify = require('fastify')()
const multipart = require('@aegisx/fastify-multipart')
await fastify.register(multipart, { autoContentTypeParser: false })
fastify.addContentTypeParser('multipart/form-data', (req, payload, done) => done(null, payload))
fastify.post('/test', async (request) => {
const { fields, files } = await request.parseMultipart()
return { fieldsType: typeof fields.name, filesCount: files.length }
})
// Test with curl:
// curl -X POST http://localhost:3000/test -F "name=test" -F "[email protected]"Development
Requirements
- Node.js >= 18
- npm or yarn
- Docker (optional, for matrix testing)
Setup
# Clone the repository
git clone https://github.com/aegisx-platform/fastify-multipart.git
cd fastify-multipart
# Install dependencies
npm install
# Run tests
npm test
# Run linting
npm run lint
# Run examples
npm run example:basic
npm run example:swagger
npm run example:completeTesting
The plugin is tested against multiple Node.js versions and Fastify versions:
- Node.js: 18, 20, 22
- Fastify: 4.x, 5.x
Local Matrix Testing
Test against different Node.js versions locally using Docker:
# Test with Node 18
./test-node-18.sh
# Test all combinations (requires Docker)
./test-matrix.sh
# Test with nvm (requires nvm installed)
./test-matrix-nvm.shManual Testing
# Test with specific Node version using Docker
docker run --rm -v "$(pwd)":/app -w /app node:18-alpine sh -c "npm ci && npm test"
docker run --rm -v "$(pwd)":/app -w /app node:20-alpine sh -c "npm ci && npm test"
# Test with specific Fastify version
npm install [email protected] && npm test
npm install [email protected] && npm testCI/CD
The project uses GitHub Actions for continuous integration:
- CI: Runs on every push and pull request
- Matrix Testing: Tests against Node.js 18, 20, 22 with Fastify 4.x and 5.x
- Security Audit: Checks for vulnerabilities
- Semantic Release: Automated version management and publishing
Commit Convention
This project follows Conventional Commits:
# Format
<type>(<scope>): <subject>
# Examples
feat(plugin): add support for custom temp directory
fix(multipart): resolve file size limit error handling
docs(readme): update installation instructions
chore(deps): update fastify to v5Types: feat, fix, docs, style, refactor, test, chore Scopes: plugin, multipart, swagger, examples, docs, tests, ci, deps
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'feat(plugin): add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
MIT License - see LICENSE file for details.
