@pronghorn/bodies
v0.1.0
Published
Unified body parsing middleware for Pronghorn. Handles JSON, urlencoded, and multipart/form-data bodies through one consistent context.locals.body/files API, built on Bun's native FormData support.
Downloads
17
Maintainers
Readme
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/bodiesRequires 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.
