Skip to content

Search docs

Jump between documentation pages.

Browse docs

File uploads (multipart/form-data)

DaloyJS treats multipart/form-data as a first-class request shape. Two helpers, fileField() and multipartObject(), let you describe an upload contract once, get runtime validation (size caps, MIME allowlists, filename matchers), an end-to-end-typed handler, and a correct OpenAPI document with multipart/form-data media type and format: "binary" file fields.

Handlers receive each file as the runtime's own File or Blob, so they can pass it on with file.stream() to S3, disk, or another upstream. The request itself is read into memory first, capped at bodyLimitBytes (1 MiB by default), and then parsed with the standard formData() parser. Raise bodyLimitBytes on the upload route itself (see per-route body limits and timeouts) so the rest of the API keeps the default, and for very large files have clients upload straight to object storage with a presigned URL instead.

How an upload is validated
  1. 01requestmultipart/form-data bodyread up to bodyLimitBytes, parsed to File / Blob entries
  2. 02app capsmaxFileBytes · maxFields · maxFiles413 (size) or 400 (counts)
  3. 03field capsfileField(): size · MIME · magic bytesforged image/png rejected, 422
  4. 04handlerTyped bodybody.file.stream() to S3 or disk
App-level caps are evaluated as soon as the body is parsed, so an oversized or over-counted request is rejected before the handler runs. Per-field fileField() checks (size, MIME allowlist, magic bytes) then surface mismatches as a 422 problem+json, leaving the handler a fully typed body.

Quick start

ts
import { z } from "zod";
import { App, fileField, multipartObject } from "@daloyjs/core";

const app = new App({
  // Optional defense-in-depth caps applied to every multipart request.
  multipart: { maxFileBytes: 5_000_000, maxFields: 32, maxFiles: 4 },
});

app.post(
  "/avatars",
  {
    operationId: "uploadAvatar",
    request: {
      body: multipartObject({
        title: z.string().min(1),
        file: fileField({
          maxBytes: 1_000_000,
          accept: ["image/png", "image/jpeg"],
          magicBytes: true,
        }),
      }),
    },
    responses: {
      201: {
        description: "Created",
        body: z.object({ ok: z.boolean() }),
      },
    },
  },
  async ({ body }) => {
    // body.file is a File; body.title is a validated string.
    const stream = body.file.stream(); // pass this to S3, disk, or another upstream
    void stream;
    return { status: 201 as const, body: { ok: true } };
  },
);

fileField() options

  • maxBytes: reject files larger than this many bytes.
  • accept: MIME allowlist. Each entry can be exact ("image/png") or a wildcard ("image/*" / "*/*").
  • filename(name): predicate for filename validation, useful for forcing extensions. When it rejects a file, the error message echoes the name with control, bidi and invisible characters removed and the length capped at 128 characters, so a crafted name cannot spoof logs or error UIs. The name passed to your predicate is the raw, untouched value, after the built-in check below has accepted it.
  • allowUnsafeFilenames: by default (since 1.16.0) a file name containing a path separator (/, \), a NUL or other control character, equal to . or .., longer than 255 characters, containing a bidirectional-override or zero-width character, named after a Windows device (CON, NUL, COM1, with or without an extension), or ending with a dot or space fails validation with a 422, so a handler that joins file.name onto an upload directory cannot be steered outside it (../../.ssh/authorized_keys). Browsers and curl only ever send a basename, so real clients are unaffected; a nameless Blob passes. Set true only when the name is never used as a path and you need the raw value.
  • magicBytes: verify file signatures before the handler receives the upload. true derives known signatures from accept for PNG, JPEG, GIF, WebP, PDF, ZIP, and GZIP. Custom signatures support domain-specific formats.
  • rejectScriptableImages: reject SVG, MVG, MSL, PostScript, and EPS payloads that can execute inside renderers such as ImageMagick. This is enabled automatically when magicBytes is configured. Set it to false only when the renderer is sandboxed. The check reads a bounded prefix of the file (64 KiB since 1.23.0, previously 512 bytes) and sees through UTF-16 encoding (with or without a BOM), leading XML comments, processing instructions and DOCTYPEs, namespace-prefixed roots such as <s:svg>, and extra whitespace in MVG keywords. It also fails closed: a file whose entire probe window is XML prolog material (whitespace, comments, processing instructions, a DOCTYPE) with more bytes following is refused as unverifiable, so padding the prolog cannot push a scriptable root out of view. It is a backstop, not a parser: keep magicBytes on as the primary control, since a signature allowlist refuses every text format outright.
  • optional: when true, accept undefined/null values without raising.
  • format: OpenAPI hint, defaults to "binary".

Magic-byte verification

MIME types come from the client, so use magicBytes when the route only accepts formats with recognizable signatures. Daloy rejects a forged image/png upload whose bytes are not PNG bytes, and also rejects a file whose sniffed signature disagrees with the declared MIME type.

ts
fileField({
  maxBytes: 1_000_000,
  accept: ["image/png", "image/jpeg"],
  magicBytes: true,
});

fileField({
  accept: ["application/x-daloy"],
  magicBytes: [
    { mime: "application/x-daloy", bytes: [0x44, 0x4c, 0x59] },
  ],
});

Strict form fields

By default, multipartObject() validates the fields you declare and drops extra form fields, so only declared fields reach the handler. Pass { strict: true } to reject undeclared fields and emit additionalProperties: false in OpenAPI. Strict mode matches field names against the shape's own keys, so a field named toString or constructor is reported as unknown.

ts
const UploadBody = multipartObject(
  {
    title: z.string().min(1),
    file: fileField({ accept: ["application/pdf"], magicBytes: true }),
  },
  { strict: true },
);

App-level safety caps

The framework already enforces bodyLimitBytes on every request. For multipart bodies you can layer additional limits via AppOptions.multipart:

ts
new App({
  bodyLimitBytes: 1024 * 1024, // 1 MiB stays the default for every route
  multipart: {
    maxFileBytes: 5_000_000, // single-file cap
    maxFields:    32,        // total fields (file + non-file)
    maxFiles:     4,         // total file uploads
  },
});

// Only the upload route accepts 10 MiB (since 1.21.0).
app.post(
  "/avatars",
  { bodyLimitBytes: 10 * 1024 * 1024, requestTimeoutMs: 120_000, request: { body: UploadBody }, responses },
  handler,
);

These caps are evaluated as soon as the body is parsed, so a request that exceeds them is rejected with 413 Payload Too Large (size) or 400 Bad Request (counts) before the handler runs. The multipart caps are App-wide; a per-route body cap is bodyLimitBytes on the route, and a per-route per-file cap is fileField({ maxBytes }).

OpenAPI emission

When the request body is built from multipartObject(), the OpenAPI generator emits multipart/form-data as the request body media type. Each fileField becomes { type: "string", format: "binary" } with optional x-accept, x-max-bytes, and x-magic-bytes annotations so codegen tools and humans both see the constraints.

Validation errors

Field-level failures are returned as a standard 422 Unprocessable Content problem+json document with one entry per failing field, same shape as JSON body validation, so clients have a single error path to handle.

Edit this page on GitHub

Last updated