npm.io
0.1.0 • Published 3d ago

@pronghorn/bodies

Licence
WTFPL
Version
0.1.0
Deps
0
Size
590 kB
Vulns
0
Weekly
0

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, and multipart/form-data transparently.
  • Uploaded files are wrapped in an UploadedFile helper with a one-line .save(path) method, no manual ArrayBuffer juggling.
  • Repeated field names (checkboxes, multi-file inputs) are automatically grouped into arrays.
  • Configurable maxSize, maxFileSize, and allowedMimeTypes guard against oversized or unexpected uploads.
  • GET/HEAD requests are skipped automatically since they carry no body.
  • Zero runtime dependencies, pronghorn is 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 maxSize and maxFileSize in production, unbounded uploads are a denial-of-service vector.
  • Use allowedMimeTypes whenever you know the expected file types (e.g. avatar uploads should only accept image types).
  • UploadedFile.save() writes exactly to the path you provide, sanitize document.name before using it in a destination path to avoid path traversal.
  • Parsing errors are surfaced as 400 Bad Request through Pronghorn's HttpError, never silently swallowed.

License

WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.

Keywords