beta

Multipart

Streaming multipart/form-data uploads - backpressured part iterator, per-file limits, buffered form validation.

@miiajs/multipart reads multipart/form-data request bodies. A route opts in with @Multipart() and then picks one of two ways to read the body: iterate ctx.parts and pipe each file straight to its destination, or call ctx.form() and get the whole form buffered into File objects and strings. The streaming path never holds a file in memory, the buffered path is what makes schema validation possible, and both go through the same parser and the same limits.

Everything the package hands out is a standard web type - a ReadableStream per file part, a File in the buffered form - so storage SDKs accept the parts as they are. There are no storage adapters here, and none are planned. Parsing runs on multipasta behind a bridge that adds backpressure, per-file counting, and the framework's exception mapping; it arrives as a regular dependency, so nothing needs to be installed alongside.

Installation

bun add @miiajs/multipart

@miiajs/core is a required peer dependency. There is no module to register and nothing to configure globally - @Multipart carries its own options.

Streaming with ctx.parts

@Multipart(options?) is a method decorator. It attaches ctx.parts and ctx.form() for the request, rejects a body that is not multipart/form-data with a 400 before the handler runs, and discards whatever the handler left unread once it returns.

Type the handler's context as MultipartContext to see both members:

import { Controller, Post, Status } from '@miiajs/core'
import { Multipart, type MultipartContext } from '@miiajs/multipart'

@Controller('uploads')
export class UploadsController {
  @Post()
  @Status(201)
  @Multipart({ maxFileSize: 10 * 1024 * 1024, maxFiles: 5 })
  async upload(ctx: MultipartContext) {
    const urls: string[] = []
    const fields: Record<string, string> = {}

    for await (const part of ctx.parts) {
      if (part.type === 'field') {
        fields[part.name] = part.value
        continue
      }
      // Your own storage call: the part is a ReadableStream, so it goes
      // wherever a stream goes. The uploads module in examples/full-app
      // writes one to local disk end to end.
      urls.push(await store(part.stream, part.mediaType))
    }

    return { fields, urls }
  }
}

Parts arrive in wire order, and browsers send them in the order the form declares. A handler that needs a field before it starts storing a file therefore needs that input to come first in the form, or the buffered path below.

A part is one of two shapes, discriminated by type:

interface FilePart {
  readonly type: 'file'
  readonly name: string                 // the form field name
  readonly filename?: string            // what the client called the file
  readonly mediaType: string            // part Content-Type, parameters stripped
  readonly headers: Record<string, string | string[]>
  readonly stream: ReadableStream<Uint8Array>
  bytes(): Promise<Uint8Array>          // drains the stream into one buffer
}

interface FieldPart {
  readonly type: 'field'
  readonly name: string
  readonly value: string                // decoded per the part's charset, UTF-8 by default
  readonly headers: Record<string, string | string[]>
}

The package reads content-disposition itself rather than taking the engine's reading of it, so a name or filename written in raw UTF-8 - which is what RFC 7578 prescribes and what browsers send - arrives intact, names outside latin-1 included. filename* in RFC 5987 form is ignored in favour of filename, as it is in the runtimes' own parsers.

Backpressure is real on the streaming path: the request body is only read further when every queue the handler can still drain is empty. That reaches the socket on Bun, Deno, @miiajs/node-server and @miiajs/uws-server alike, so a slow storage target slows the upload down instead of filling memory.

Exactly one part is consumed at a time. Moving the iterator forward - continue, the next loop turn, or leaving the loop - abandons the previous part and errors its stream. Reading it afterwards throws instead of returning a short file, which is deliberate: a silently truncated upload is the worse failure. Collecting the parts into an array and reading them later does not work; finish with a part before asking for the next one.

Buffering with ctx.form()

ctx.form() drains the iterator in one pass and returns a FormResult: files as File objects grouped by part name, fields collapsed to the last value under their name. The result is cached per request, so calling it twice hands back the same object.

It is the iterator, so pick one or the other for a given route: ctx.form() after walking ctx.parts throws, and so does walking ctx.parts after ctx.form(). Both would otherwise answer with nothing in hand, which reads like a request that parsed and simply carried no data.

import { Controller, Post } from '@miiajs/core'
import { Multipart, type MultipartContext } from '@miiajs/multipart'

@Controller('uploads')
export class UploadsController {
  @Post('avatar')
  @Multipart({ maxFileSize: 2 * 1024 * 1024, maxFiles: 1 })
  async avatar(ctx: MultipartContext) {
    const { files, fields } = await ctx.form()
    const [avatar] = files.avatar ?? []
    if (!avatar) return { stored: false }

    return { stored: true, name: avatar.name, size: avatar.size, note: fields.note }
  }
}

Buffering means the file sits in memory for as long as the handler runs, so maxFileSize is doing real work here. An empty <input type="file"> - a part with filename="" and no bytes - is left out of files while staying visible on ctx.parts.

Validating a form

@ValidateForm(schema) validates the buffered form against any ZodLike schema. What the schema sees is a flat object: text fields as strings next to the files under their own names, a single File or a File[] when the name repeats. That is how a multipart body is described in OpenAPI, and it keeps the schema readable:

import { z } from 'zod'
import { Controller, Post } from '@miiajs/core'
import { Multipart, type MultipartContext, ValidateForm } from '@miiajs/multipart'

const UploadSchema = z.object({
  title: z.string().min(1).max(255),
  file: z.instanceof(File),
})

type UploadInput = z.infer<typeof UploadSchema>

@Controller('uploads')
export class UploadsController {
  @Post('form')
  @Multipart({ maxFileSize: 2 * 1024 * 1024, maxFiles: 1 })
  @ValidateForm(UploadSchema)
  async form(ctx: MultipartContext) {
    // After @ValidateForm the cache holds the schema's data, not the raw FormResult.
    const { title, file } = await ctx.form<UploadInput>()
    return { title, size: file.size }
  }
}

A failed parse answers 422 with the schema's issues, the same shape @ValidateBody produces. A successful one replaces the cached form, so ctx.form<T>() returns the schema's output - transformed values included - instead of FormResult.

@ValidateForm needs @Multipart on the same method, in either order: both decorators build the same per-request state, and whichever runs first wins. On a method without @Multipart the middleware throws a configuration error on the first request to that route.

Storage

The package writes files nowhere by itself. A file part is a ReadableStream, the buffered form hands out File objects, and cloud SDKs take both directly - an adapter layer would only wrap what already fits.

Local disk

Nothing here writes to a filesystem, and the code that does is short enough to keep in the handler - but three details in it are worth stating outright. The uploads module in examples/full-app has the whole thing working end to end.

  • Write to a temporary file whose name starts with a dot, then rename it into place. Written straight to its final path, a file is reachable at its public URL while it is still half-written, and a process killed mid-upload leaves a truncated one that looks whole. A leading dot keeps the temporary out of reach: serve-static refuses any path segment that starts with one.
  • Generate the name on disk yourself - a UUID plus a suffix chosen from the media type. If any part of it does come from client input, resolve the final path and check that it is still inside the target directory.
  • Local disk means Node, Bun, or Deno. Edge runtimes have no filesystem to write to; everything else in this package runs there unchanged.

Limits

Every limit lives on @Multipart. Nothing is enforced globally, so a route without the decorator is unaffected by any of this.

OptionTypeDefaultDescription
maxFileSizenumberInfinityMax bytes per file part, counted as they arrive
maxFilesnumberInfinityMax number of file parts that carry data
maxFieldsnumberInfinityMax number of field parts
maxFieldSizenumber1048576 (1 MiB)Max bytes of a single field value
maxFieldNameSizenumber100Max length of a part name
allowedTypesstring[]anythingMedia types a file part may declare, exact or type/*
fieldsBudgetnumber65536 (64 KiB)Room for the field parts in the derived body limit
bodyLimitnumberderivedExplicit ceiling for the whole body

A limit fires the moment it is crossed, mid-stream. The parser stops, the source is left alone for the adapter to dispose of, and the request ends with a 413.

Allowed media types

allowedTypes lists what a file part may declare - an exact image/png, or a subtype wildcard image/*. The list is read the way a part's media type is, trimmed and lower-cased, so ['Image/PNG'] in the options matches an IMAGE/PNG header.

@Post('avatar')
@Multipart({ maxFileSize: 2 * 1024 * 1024, maxFiles: 1, allowedTypes: ['image/png', 'image/jpeg', 'image/webp'] })
async avatar(ctx: MultipartContext) {
  const { files } = await ctx.form()
  return { stored: files.avatar?.length ?? 0 }
}

The check runs at the head of a part, before a byte of its body is read, and a part outside the list ends the request with a 415 carrying { mediaType, allowed }. That is the point of the option: a 40 MB video is refused on its header rather than after the upload finished.

Only file parts are checked. RFC 7578 reads a part that declares no Content-Type as text/plain, so a list of image types would otherwise reject every ordinary text field in the same form. A file that declares nothing is compared as application/octet-stream, and an empty list allows no file at all.

The media type is written by the client, like the filename. allowedTypes is convenience and an early refusal, not a security control - a renamed executable sent as image/png passes it. Anything that has to be true about the bytes needs the bytes: check the file signature after the upload, or hand it to a scanner.

The body budget

maxFileSize caps one file; something has to cap the body as a whole, or ten files of the maximum size still add up. That ceiling is resolved in four steps:

  1. An explicit bodyLimit on @Multipart wins.
  2. Otherwise maxFileSize * maxFiles + fieldsBudget, when both are finite. Two 10 MiB files plus the default field budget give a 20 MiB + 64 KiB body.
  3. Otherwise a @BodyLimit on the route (or on its controller) becomes the multipart budget.
  4. Otherwise nothing of its own. The parser counts nothing, and the route falls back on the same body limit every other route has - its @BodyLimit if it carries one, otherwise the app-wide maxBodySize.

Steps 1 and 2 also write the route's body limit, so a body that declares an oversized Content-Length is rejected by the router before the parser ever starts. Bodies without a declared length - chunked uploads - are counted as the bytes arrive: by the parser under steps 1, 2 and 3, and under step 4 by the adapter, which core hands the route's limit to after matching. See Body size limits for which runtimes apply the route's limit and which fall back to the app-wide ceiling.

The router raises the adapter's body ceiling to the largest route limit in the app, so a route with a generous derived budget lifts the ceiling for every route, including ones that never asked for it. Keep maxFileSize * maxFiles as small as the feature genuinely needs.
Steps 1 and 2 write the route limit into the same metadata @BodyLimit uses, and the last decorator applied wins. A bare @Multipart() writes nothing there, so pairing it with @BodyLimit is step 3 and works in either order. What to avoid is a @Multipart that carries bodyLimit - or a derivable maxFileSize and maxFiles - next to a @BodyLimit on the same method: which of the two values survives then depends on the order the decorators were applied in.

Status codes

ConditionStatusdetails
Body is not multipart/form-data400
Missing or invalid boundary400
Malformed part headers, or a part without a name400
Empty or truncated body400
A file outgrew maxFileSize413{ limit: 'maxFileSize' }
More files than maxFiles413{ limit: 'maxFiles' }
More fields than maxFields413{ limit: 'maxFields' }
A field value over maxFieldSize413{ limit: 'maxFieldSize' }
A part name over maxFieldNameSize413{ limit: 'maxFieldNameSize' }
Body over the resolved budget413{ limit: 'bodyLimit' }
More parts than maxFiles + maxFields413{ limit: 'maxParts' }
A file part outside allowedTypes415{ mediaType, allowed }
Form rejected by @ValidateForm422schema issues

The adapter's own ceiling also ends in a 413, reported by the server package rather than by the parser.

OpenAPI

@Multipart and @ValidateForm write no OpenAPI metadata, so a multipart body is documented by hand with @ApiBody and its contentType option:

import { ApiBody, ApiResponse } from '@miiajs/swagger'

@Post('form')
@ApiBody(
  {
    type: 'object',
    properties: {
      title: { type: 'string', minLength: 1, maxLength: 255 },
      file: { type: 'string', format: 'binary' },
    },
    required: ['title', 'file'],
  },
  { contentType: 'multipart/form-data' },
)
@ApiResponse(413, { description: 'The file, or the body as a whole, exceeded the limit.' })
@Multipart({ maxFileSize: 2 * 1024 * 1024, maxFiles: 1 })
@ValidateForm(UploadSchema)
async form(ctx: MultipartContext) {}

This is a deliberate gap rather than a missing feature. @ValidateForm could publish its schema to the metadata key the spec builder reads, but a form schema describes files as z.instanceof(File), which converts to nothing useful, and publishing it would collide with an @ApiBody written to say format: 'binary' - both write the same key, and decorator order would decide the winner. Writing the body by hand keeps the accurate description in charge.

Testing

The parser needs a real multipart/form-data body, and TestApp.request() JSON-encodes whatever it is given as body. Multipart routes are therefore exercised through app.fetch with a request you build yourself:

import { describe, expect, it } from 'bun:test'
import { Miia, Module } from '@miiajs/core'
import { UploadsController } from '../src/uploads/uploads.controller.js'

@Module({ controllers: [UploadsController] })
class TestModule {}

async function postForm(app: Miia, path: string, form: FormData): Promise<Response> {
  // A Response built from FormData gives both the boundary header and the bytes.
  const encoded = new Response(form)
  const body = await encoded.arrayBuffer()

  return app.fetch(
    new Request(`http://localhost${path}`, {
      method: 'POST',
      headers: {
        'content-type': encoded.headers.get('content-type') as string,
        // A constructed Request carries no Content-Length, and the route body
        // limit is checked against the declared length.
        'content-length': String(body.byteLength),
      },
      body,
    }),
  )
}

describe('POST /uploads/form', () => {
  it('accepts a title and a file', async () => {
    const app = new Miia({ logger: false }).register(TestModule)

    const form = new FormData()
    form.append('title', 'invoice')
    form.append('file', new File(['payload'], 'a.txt', { type: 'text/plain' }))

    const res = await postForm(app, '/uploads/form', form)
    expect(res.status).toBe(200)
  })
})

For a limit test, send the body as a ReadableStream with duplex: 'half' instead: a chunked request has no Content-Length, so the parser's own accounting is what answers, exactly as it would for a real upload.

Known limitations

  • One body, one reader. @Multipart consumes the request body, so it cannot share a route with @ValidateBody, ctx.json(), or ctx.text(). Whichever runs second finds the body used. The same holds for ctx.parts and ctx.form() against each other, and there the package raises the error itself rather than handing back an empty form or an empty loop.
  • One part at a time. Abandoning a part errors its stream, so parts cannot be collected up front and read afterwards. This is a design decision, not an oversight: closing the stream instead would hand out truncated files.
  • ctx.parts exists only under @Multipart. Both members are attached by the decorator's middleware, and MultipartContext is the promise that the decorator is there. On a route without it they are undefined, and the type says otherwise.
  • A derived body limit lifts the app-wide ceiling. The router raises the adapter's body ceiling to the largest route limit registered, so a route deriving maxFileSize * maxFiles + fieldsBudget quietly relaxes it for the whole app. Each route still keeps its own limit, and on @miiajs/node-server a chunked body is judged against that rather than against the ceiling - but everywhere the ceiling is what applies, the generous route is what bounds the others.
  • A limit is enforced when the body is read. A handler that returns without touching a chunked body never gives the counter anything to count, so the client uploads up to the ceiling whatever the route declared. Reading the body is what makes the limit bite.
  • @ApiBody on a route without runtime validation documents a 422 the route never returns. The spec builder treats a declared body schema as validation. It comes from @miiajs/swagger rather than from this package, but the documented path above inherits it. Declaring @ApiResponse(422, ...) explicitly at least makes the entry say something true.
  • Trailing bytes that read as another part yield a phantom field. RFC 2046 permits an epilogue after the final delimiter, and an ordinary one is walked past whatever its size and however the chunks fall. Bytes that themselves look like a part are the exception: the engine reports one more field, carrying the previous part's headers, before the iterator ends. No HTTP client sends either, so this reaches a hand-built body only.

Exports

import { Multipart, ValidateForm, createPartStream } from '@miiajs/multipart'

import type {
  FieldPart,
  FilePart,
  FormResult,
  MultipartContext,
  MultipartOptions,
  MultipartPart,
} from '@miiajs/multipart'

createPartStream(req, options?) is the decorator's engine, exported for use outside a route - a raw Request in a webhook, a queue worker, a test. It returns the same iterator ctx.parts gives and throws the same exceptions.

See also