@pronghorn/bodies
Bodies
Bodies is a lightweight, TypeScript-first body parsing middleware built as an external plugin for Pronghorn. It unifies JSON, urlencoded, and multipart/form-data request bodies behind a single context.locals.body/context.locals.files API, built directly on Bun's native FormData support.
Built as a standalone package (
@pronghorn/bodies), fills the file-upload gap left by Pronghorn's schema validation, which only ever parsed JSON.
Why Bodies
Pronghorn's validate() middleware calls request.json() directly, meaning there was no way to receive file uploads, form-encoded submissions, or mixed text+file payloads. Bodies fixes that without hand-rolling a multipart parser, since Bun already implements Request.formData() natively.
- One middleware handles
application/json,application/x-www-form-urlencoded, andmultipart/form-datatransparently. - Uploaded files are wrapped in an
UploadedFilehelper with a one-line.save(path)method, no manualArrayBufferjuggling. - Repeated field names (checkboxes, multi-file inputs) are automatically grouped into arrays.
- Configurable
maxSize,maxFileSize, andallowedMimeTypesguard against oversized or unexpected uploads. - GET/HEAD requests are skipped automatically since they carry no body.
- Zero runtime dependencies,
pronghornis only a peer dependency for types.
Installation
bun add @pronghorn/bodies
Requires Bun >=1.3.0 and pronghorn >=0.1.2 as a peer dependency (used for typing the middleware only).
Quick Start
import { createApp, badRequest } from 'pronghorn'
import { bodyParser } from '@pronghorn/bodies'
const app = createApp()
app.use(bodyParser({ maxSize: 10 * 1024 * 1024, maxFileSize: 5 * 1024 * 1024 }))
app.post('/upload', async context => {
const { avatar } = context.locals.files as { avatar?: UploadedFile }
const { username } = context.locals.body as { username: string }
if (!avatar) throw badRequest('Missing avatar file')
await avatar.save(`./uploads/${username}-${avatar.name}`)
return context.json({ saved: avatar.name, size: avatar.size })
})
await app.listen(4000)
Core Concepts
JSON bodies
Detected via Content-Type: application/json, parsed with the same fallback-to-{} behavior as Pronghorn's built-in validate() middleware.
app.post('/users', context => {
const { name, email } = context.locals.body as { name: string; email: string }
return context.json({ created: { name, email } }, 201)
})
Urlencoded bodies
Detected via Content-Type: application/x-www-form-urlencoded, parsed through the same code path as multipart since both resolve through Bun's FormData API.
app.post('/subscribe', context => {
const { email } = context.locals.body as { email: string }
return context.json({ subscribed: email })
})
Multipart bodies and file uploads
Detected via Content-Type: multipart/form-data. Text fields land in context.locals.body; file fields land in context.locals.files, each wrapped as an UploadedFile.
app.post('/documents', async context => {
const { title } = context.locals.body as { title: string }
const { document } = context.locals.files as { document?: UploadedFile }
if (!document) throw badRequest('Missing document')
await document.save(`./storage/${document.name}`)
return context.json({ title, fileName: document.name, size: document.size })
})
Multiple files with the same field name
Repeated <input type="file" multiple> submissions (or repeated form keys in general) are grouped into an array automatically.
app.post('/gallery', async context => {
const { photos } = context.locals.files as { photos?: UploadedFile | UploadedFile[] }
const list = Array.isArray(photos) ? photos : photos ? [photos] : []
for (const photo of list) await photo.save(`./gallery/${photo.name}`)
return context.json({ uploaded: list.length })
})
Size and type limits
Limits are enforced before your handler runs; violations throw a 400 Bad Request via Pronghorn's HttpError, caught by the standard errorHandler middleware.
app.use(bodyParser({
maxSize: 20 * 1024 * 1024, // total request body cap
maxFileSize: 5 * 1024 * 1024, // per-file cap
allowedMimeTypes: ['image/png', 'image/jpeg']
}))
Middleware Options
| Option | Type | Default | Description |
|---|---|---|---|
maxSize |
number |
— | Maximum total request body size in bytes, checked against Content-Length |
maxFileSize |
number |
— | Maximum size in bytes for any individual uploaded file |
allowedMimeTypes |
string[] |
— | Whitelist of accepted MIME types for uploaded files |
API Reference
bodyParser(options?: BodyParserOptions): Middleware — global middleware factory, register via app.use(bodyParser(options)).
| Locals property | Type | Description |
|---|---|---|
context.locals.body |
Record<string, string | string[]> | unknown |
Parsed text fields (JSON object, or form fields) |
context.locals.files |
Record<string, UploadedFile | UploadedFile[]> |
Parsed file fields from multipart bodies |
UploadedFile
| Property/Method | Type | Description |
|---|---|---|
name |
string |
Original filename supplied by the client |
type |
string |
MIME type reported by the client |
size |
number |
File size in bytes |
blob |
Blob |
The underlying File/Blob object, for streaming or manual processing |
save(destinationPath) |
Promise<number> |
Writes the file to disk via Bun.write, returns bytes written |
Lower-level building blocks are also exported for advanced use outside the middleware:
import { parseBody, wrapUploadedFile } from '@pronghorn/bodies'
const { body, files } = await parseBody(request, { maxFileSize: 5 * 1024 * 1024 })
Architecture
Bodies is split into three modules, each with a single responsibility.
| Module | Responsibility |
|---|---|
parser.ts |
Content-type dispatch (JSON vs. urlencoded/multipart), grouping repeated fields into arrays, enforcing size/type limits |
files.ts |
Wraps native File objects from FormData entries into the UploadedFile shape with a .save() helper backed by Bun.write |
middleware.ts |
Wires parsing into context.locals.body/context.locals.files, skips GET/HEAD, and converts parsing errors into HttpError(400) |
Both urlencoded and multipart bodies are parsed through the same request.formData() call, since Bun's implementation transparently supports both encodings under one API.
Security Notes
- Always set
maxSizeandmaxFileSizein production, unbounded uploads are a denial-of-service vector. - Use
allowedMimeTypeswhenever you know the expected file types (e.g. avatar uploads should only accept image types). UploadedFile.save()writes exactly to the path you provide, sanitizedocument.namebefore using it in a destination path to avoid path traversal.- Parsing errors are surfaced as
400 Bad Requestthrough Pronghorn'sHttpError, never silently swallowed.
License
WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.