Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.

Body reading

Eugene Lazutkin edited this page Apr 20, 2026 · 1 revision

Body reading

The adapter reads JSON bodies from Fetch Request objects with a two-stage size guard: a Content-Length fast-path reject, then a mid-stream byte counter for requests without a declared length (chunked transfer) or whose CL lies.

The flow

Is Content-Length ≤ maxBodyBytes (or absent)?
├─ No  → reject 413 PayloadTooLarge BEFORE reading any bytes
└─ Yes ─┐
        ▼
read request.body.getReader() chunk by chunk
   │
   ▼
running byte total ≤ maxBodyBytes?
├─ No  → cancel the reader, reject 413
└─ Yes → decode UTF-8, accumulate
         │
         ▼
         empty? → return null
         non-empty?
         ├─ JSON.parse OK → return value
         └─ JSON.parse throws → reject 400 BadJsonBody

Why two-stage?

request.json() is a one-liner but has no size cap; a Content-Length: 1GB header plus 1 GB of data will OOM the runtime before throwing. Pure streaming with getReader() is safe but costs allocations for the well-behaved case. The hybrid gives:

  • Zero-byte reject for uploads that honestly declare their size above the cap.
  • Safe mid-stream reject for chunked / CL-liar requests — a running counter catches them before the streaming decoder grows out of bounds.

Under attack load (CL = 2 GB, maxBodyBytes = 1 MiB), the fast-path costs one header read; the streaming fallback would cost at least one chunk-read.

Default cap

const handler = createFetchAdapter(planets);  // 1 MiB by default

maxBodyBytes defaults to 1048576 — matches the bundled node:http handler plus the koa / express adapters. Override per adapter:

// Low-power device — cap at 64 KiB
createFetchAdapter(planets, {maxBodyBytes: 64 * 1024});

// Dedicated bulk-load — allow larger bodies
createFetchAdapter(bulk, {maxBodyBytes: 16 * 1024 * 1024});

Error responses

Condition Status code
Content-Length over cap 413 PayloadTooLarge
Body streamed past cap 413 PayloadTooLarge
Body is not valid JSON 400 BadJsonBody
PUT /-load body is not an array 400 BadLoadBody

Body is null for requests with no payload (empty body on POST, or GET-shaped Requests).

Using the helper directly

src/read-web-body.js is exported as a sub-export if you need cap-enforced JSON reading in other handlers:

import {readJsonBody} from 'dynamodb-toolkit-fetch/read-web-body.js';

const body = await readJsonBody(request, 1024 * 1024);
// body: parsed JSON, or null for empty body
// throws: {status:413,code:'PayloadTooLarge'} or {status:400,code:'BadJsonBody'}

Normally you won't need this — the adapter handles it internally.

Per-adapter body caps

When you mount multiple adapters and want different caps per route prefix, pass maxBodyBytes per instance. Each adapter holds its own cap; they don't share state:

const planets = createFetchAdapter(planetsAdapter, {mountPath: '/planets', maxBodyBytes: 256 * 1024});
const bulk    = createFetchAdapter(bulkAdapter,    {mountPath: '/bulk',    maxBodyBytes: 16 * 1024 * 1024});

See Composition for how to route between them on a single runtime.

Why we don't call reader.cancel() without try/catch

reader.cancel() can reject if the stream source has already errored. The 413 we throw is authoritative — we wrap cancel() in try/catch, swallow its error, and let the 413 propagate. The cancel is just a hint to the runtime that the upstream can stop sending; backpressure and GC handle the dangling bytes.

Interaction with host body parsers

Unlike the express adapter, there's no body-parser middleware to defer to. Fetch-handler hosts (Cloudflare Workers, Bun.serve, Deno.serve, Hono, itty-router) all pass the Request through with an unread body stream. The adapter always reads the body itself via readJsonBody.

If your host router has already consumed request.body (e.g. Hono's c.req.json() before delegating), pass the original raw Request — in Hono that's c.req.raw, itty-router exposes it similarly. See Composition for examples.

Bodies on non-body routes

GET / and DELETE / don't read the body. DELETE /-by-names reads the body only when ?names= is absent. All other body-capable routes always read. The adapter's dispatch logic decides whether to call readJsonBody based on the route; unused body bytes stay in the runtime's buffer until GC.

Clone this wiki locally