Body Parser

Parse JSON, form-urlencoded, multipart, and raw request bodies. Upload files to local disk or a Drive storage disk with built-in validation. Supports method spoofing, size limits, and per-parser configuration.

Overview

@tekir/bodyparser ships a single bodyParser() middleware that handles all common content types automatically. It detects the Content-Type header and applies the appropriate parser: JSON, form-urlencoded, multipart, or raw.

  • JSON: application/json and variants
  • Form: application/x-www-form-urlencoded
  • Multipart: multipart/form-data with file uploads
  • Raw: custom content types (XML, YAML, etc.)

Installation

bun add @tekir/bodyparser

bodyParser() Middleware

Register the middleware in your kernel file.

start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { bodyParser } from '@tekir/bodyparser'

export default function({ router }: TekirApp) {
  router.useRouter([bodyParser()])
}
import type { HttpContext } from '@tekir/core'

export async function uploadAvatar(ctx: HttpContext) {
  const { response } = ctx
  const avatar = ctx.file('avatar', { size: '2mb', extnames: ['jpg', 'jpeg', 'png', 'webp'] })

  if (!avatar) {
    return response.badRequest({ message: 'No file uploaded' })
  }

  if (avatar.hasErrors) {
    return response.unprocessableEntity({ errors: avatar.errors })
  }

  await avatar.move('./storage/uploads/avatars')
  return response.ok({ path: avatar.filePath })
}

Configuration

Pass a BodyParserConfig object to bodyParser() to override the defaults. Each parser can be configured independently.

import { bodyParser } from '@tekir/bodyparser'

router.useRouter([
  bodyParser({
    allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],

    convertEmptyStringsToNull: true,
    trimWhitespace: true,

    json: {
      limit: '1mb',
      strict: true,
      types: [
        'application/json',
        'application/json-patch+json',
        'application/vnd.api+json',
        'application/csp-report',
      ],
    },

    form: {
      limit: '1mb',
      types: ['application/x-www-form-urlencoded'],
      queryString: {
        depth: 5,
        parameterLimit: 1000,
      },
    },

    multipart: {
      maxFileSize: '8mb',
      maxFiles: 20,
      maxFields: 1000,
      maxParts: 1000,
      limit: '20mb',
      spillThreshold: '1mb',
      tmpDir: '/tmp/uploads',
      autoProcess: true,
      processManually: [],
      types: ['multipart/form-data'],
    },

    raw: {
      types: ['application/xml', 'text/xml'],
      limit: '1mb',
    },
  })
])

Allowed Methods

The allowedMethods array defines which HTTP methods should have their request bodies parsed. Defaults to POST, PUT, PATCH, DELETE. GET and HEAD are skipped.

Global Options

  • convertEmptyStringsToNull: converts empty strings to null across all parsers. Useful for nullable database columns. Can be overridden per-parser.
  • trimWhitespace: trims leading/trailing whitespace from all string values. Can be overridden per-parser.

JSON Parser

Handles application/json and related content types. Returns 413 if the body exceeds the limit, 422 if strict mode rejects a primitive root value, or 400 for invalid JSON. Parsed payloads are protected against prototype pollution: __proto__, constructor, and prototype keys are stripped recursively before the body reaches your handler.

// JSON requests are parsed automatically
// Content-Type: application/json

// Config options:
json: {
  limit: '1mb',                  // max body size (413 if exceeded)
  strict: true,                  // only objects/arrays at root (422 if primitive)
  encoding: 'utf-8',            // character encoding
  convertEmptyStringsToNull: true,  // override global setting per-parser
  trimWhitespace: true,             // override global setting per-parser
  types: [                       // content types to handle
    'application/json',
    'application/json-patch+json',
    'application/vnd.api+json',
    'application/csp-report',
  ],
}

Form Parser

Handles application/x-www-form-urlencoded requests from HTML forms. Supports nested keys via bracket notation (a[b][c]) and optionally dot notation.

// URL-encoded form data is parsed automatically
// Content-Type: application/x-www-form-urlencoded

// Config options:
form: {
  limit: '1mb',                  // max body size (413 if exceeded)
  encoding: 'utf-8',            // character encoding
  convertEmptyStringsToNull: true,
  trimWhitespace: true,
  types: ['application/x-www-form-urlencoded'],
  queryString: {
    depth: 5,                    // max nesting depth (a[b][c][d]...)
    parameterLimit: 1000,        // max number of parameters
    allowDots: false,            // parse a.b.c as nested object
    arrayLimit: 20,              // max array index
  },
}

Multipart Parser

Handles file uploads and multipart form data. The body is parsed as a stream and the limits are enforced during the upload, not after the whole payload lands in memory. As soon as a per-file maxFileSize or the total limit is exceeded the parser aborts the stream and throws PayloadTooLargeError (HTTP 413), so a malicious client cannot force the runtime to buffer the full payload first. When the incoming Content-Length already exceeds limit the same error is thrown before any body is read.

Exceeding maxFiles, maxFields, or maxParts also throws PayloadTooLargeError rather than silently dropping the extra parts. The total part limit is checked before the next part body is processed. Catch it in a custom error handler or let the framework convert it into a 413 response.

Large files do not need to fit in memory. A part that grows past spillThreshold (default 1mb) is streamed to a temporary file under tmpDir (defaulting to os.tmpdir()) instead of being held in RAM; smaller parts stay in memory. Spilling requires tmpDir to be set, and if a limit is hit mid-write the partially spilled file is cleaned up.

If you set a custom tmpFileName(), the returned name is basename()-stripped and the final path is verified to stay under tmpDir. Names like ../escape.txt or absolute paths are rejected and the failure surfaces on UploadedFile.errors with rule: 'tmpFileName'.

multipart: {
  maxFileSize: '8mb',           // max size per file (aborts early when exceeded)
  maxFiles: 20,                 // max number of files (throws 413 when exceeded)
  maxFields: 1000,              // max non-file form fields (throws 413 when exceeded)
  maxParts: 1000,               // max total file and field parts (throws 413 when exceeded)
  limit: '20mb',                // total upload size limit (aborts early)
  spillThreshold: '1mb',        // parts above this stream to disk; requires tmpDir
  tmpDir: '/tmp/uploads',       // spill directory (default: os.tmpdir())
  tmpFileName: () => `upload_${Date.now()}`,  // custom temp file names
  encoding: 'utf-8',
  autoProcess: true,            // true | false | ['/uploads', '/api/files']
  processManually: [],          // route patterns to skip auto-processing
  types: ['multipart/form-data'],
  convertEmptyStringsToNull: true,
  trimWhitespace: true,
}

autoProcess & processManually

Control which routes auto-process uploaded files. Set autoProcess to an array of route patterns to enable it only for specific routes, or use processManually to exclude routes from auto-processing.

// Process files only for specific routes
multipart: {
  autoProcess: ['/uploads', '/posts/:id/images'],
}

// Auto-process everywhere EXCEPT specific routes
multipart: {
  autoProcess: true,
  processManually: ['/file-manager', '/projects/:id/assets'],
}

Raw Parser

Handle custom content types like XML, YAML, or other formats. The raw body string is available as ctx.rawBody. Process it with a custom parser in your controller or middleware.

// Raw parser for custom content types (XML, YAML, etc.)
raw: {
  types: ['application/xml', 'text/xml', 'application/yaml'],
  limit: '1mb',
  encoding: 'utf-8',
}

// The raw body string is available as ctx.rawBody
// Parse it in your controller or a custom middleware:
async function handleXml(ctx) {
  const xml = ctx.rawBody  // raw string
  const parsed = myXmlParser(xml)
  // ...
}

Method Spoofing

HTML forms only support GET and POST. Add ?_method=PUT, ?_method=PATCH, or ?_method=DELETE to the form action to spoof other HTTP methods. Method spoofing is opt-in: set methodSpoofing: true on the parser config to enable it. Only real POST requests are upgraded, so a GET request can never be turned into a mutating method and bypass method-based CSRF protection.

<!-- HTML forms only support GET and POST -->
<!-- Use ?_method to spoof PUT, PATCH, DELETE -->

<form method="POST" action="/posts/1?_method=PUT">
  <input type="text" name="title" />
  <button type="submit">Update Post</button>
</form>

<form method="POST" action="/posts/1?_method=DELETE">
  <button type="submit">Delete Post</button>
</form>

UploadedFile

Every uploaded file is wrapped in an UploadedFile instance with metadata, validation state, and methods to read, move, or delete the file.

const file = ctx.file('document')

// File metadata
file.fieldName    // 'document'     , the HTML input name
file.clientName   // 'report.pdf'   , the original filename
file.size         // 204800         , bytes
file.type         // 'application'  , MIME type prefix
file.subtype      // 'pdf'          , MIME type suffix
file.extname      // 'pdf'          , lowercase extension

// Validation state
file.hasErrors    // false
file.isValid      // true
file.errors       // [] or [{ field, rule, message }, ...]

// Location after move() or moveToDisk()
file.filePath     // './storage/uploads/documents/abc123.pdf'
file.fileName     // 'abc123.pdf'
file.tmpPath      // '/tmp/uploads/upload_123456.pdf' (if tmpDir configured)

Reading File Content

Three methods to access file data: toBuffer(), toString(encoding?), and toStream().

const file = ctx.file('csv_data')

// Buffer: for binary processing or hashing
const buffer = file.toBuffer()

// String: for text files
const text = file.toString()           // UTF-8 by default
const latin = file.toString('latin1')  // Any BufferEncoding

// ReadableStream: for streaming to another API
const stream = file.toStream()

Moving Files to Local Disk

move(directory, name?)writes the file to a directory. Creates the directory if it doesn't exist. Generates a UUID filename if none is provided.

// move(directory, name?)
await file.move('./storage/uploads/avatars')
// file.filePath  → './storage/uploads/avatars/f3a1c8d2-...-4e9b.jpg'

await file.move('./storage/uploads/avatars', 'profile.jpg')
// file.filePath  → './storage/uploads/avatars/profile.jpg'

Moving Files to a Drive Disk

moveToDisk(directory, options?) uploads the file to a @tekir/drive disk (local, S3, R2, etc.).

// moveToDisk(directory, options?)
// Requires @tekir/drive to be configured.

const key = await file.moveToDisk('avatars')
const key = await file.moveToDisk('avatars', { disk: 's3' })
const key = await file.moveToDisk('avatars', {
  disk: 's3',
  name: `users/${userId}/avatar.${file.extname}`
})

Deleting Files

await file.delete()

File Validation

Pass size and extnames to ctx.file() for inline validation. Errors are collected on the errors array.

const avatar = ctx.file('avatar', {
  size: '2mb',
  extnames: ['jpg', 'jpeg', 'png']
})

if (avatar?.hasErrors) {
  // [{ field: 'avatar', rule: 'size', message: 'File size 3.2mb exceeds maximum 2.0mb' }]
  return response.unprocessableEntity({ errors: avatar.errors })
}

File Accessors

The body parser installs three method-based accessors on ctx for every request: ctx.file(name) for a single file, ctx.files(name) for a multi-file field, and ctx.allFiles() for every uploaded file across all fields. Non-multipart requests get a no-op fallback (undefined / []), so handlers can call them without an optional-chain or content-type check.

// Three method-based accessors on every request:
//
//   ctx.file(name)      → UploadedFile | undefined
//   ctx.files(name)     → UploadedFile[]
//   ctx.allFiles()      → UploadedFile[]
//
// Set on the context for every request, including non-multipart ones —
// the non-multipart fallbacks return `undefined` / `[]` so handlers
// can call them without an optional-chain or content-type check.

// Get single file with optional validation
const avatar = ctx.file('avatar', { size: '2mb', extnames: ['jpg', 'png'] })

// Get every file under a field (for <input type="file" multiple>)
const docs = ctx.files('attachments', { size: '10mb', extnames: ['pdf', 'docx'] })

// Get every uploaded file across all fields (flat array)
const all = ctx.allFiles()

// For richer collection semantics (`has`, `fields`, `count`,
// per-field iteration) use `parseMultipart` directly — it returns a
// `MultipartFiles` collection instead of the method shortcuts.

For <input type="file" multiple> fields use ctx.files(name):

// HTML: <input type="file" name="photos" multiple>
export async function uploadPhotos(ctx: HttpContext) {
  const photos = ctx.files('photos', { size: '5mb', extnames: ['jpg', 'png', 'webp'] })

  const errors = photos.flatMap(p => p.errors)
  if (errors.length > 0) {
    return response.unprocessableEntity({ errors })
  }

  const keys = await Promise.all(photos.map(p => p.moveToDisk('photos')))
  return response.created({ urls: keys })
}

Need richer collection semantics like has, fields, or count? Call parseMultipart() directly — it returns the underlying MultipartFiles collection.

parseMultipart()

Low-level parser for use outside the middleware, standalone servers, test helpers, or custom middleware.

import { parseMultipart } from '@tekir/bodyparser'

const { body, files } = await parseMultipart(request, {
  maxFileSize: '10mb',
  maxFiles: 5,
  limit: '20mb',
})

Auto-configuration via Provider

Add a config/bodyparser.ts file and BodyParserProvider will register the middleware automatically during boot.

config/bodyparser.ts
// config/bodyparser.ts
import type { BodyParserConfig } from '@tekir/bodyparser'

export default {
  allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  convertEmptyStringsToNull: true,
  trimWhitespace: true,

  json: { limit: '1mb', strict: true },
  form: { limit: '1mb' },
  multipart: { maxFileSize: '10mb', maxFiles: 10, maxFields: 1000, maxParts: 1000, limit: '20mb', spillThreshold: '1mb', tmpDir: '/tmp/uploads' },
  raw: { types: ['application/xml'] },
} satisfies BodyParserConfig