[]previous and next page

Request body limits (streaming uploads)

security.limits.maxBodyBytes is the largest accepted body, in bytes; the default is 1 MiB and any positive value is accepted, so 512 1024 1024 is valid for a file…

For agentsUpdated

security.limits.maxBodyBytes is the largest accepted body, in bytes; the default is 1 MiB and any positive value is accepted, so 512 * 1024 * 1024 is valid for a file upload. The body is not buffered: the limit is a pass-through stream that counts bytes, so a route reads the chunks as they arrive and a body over the limit is cut off after about the limit (the producer is not read further). For a long upload also raise requestTimeoutMs (the time to the response head), since the upload itself counts against it.

A route's own limit, and the ceiling#

apiHandler({ maxBodyBytes }) gives one API route its own limit. The route's value is the limit for that route only, so a route that accepts 2 MiB works while the app's default stays at 1 MiB. The value is clamped to security.limits.maxRouteBodyBytes, the ceiling: 512 MiB by default, and never below maxBodyBytes. A declared Content-Length over the route's limit gives 413 before the handler runs; a streamed body is cut off at the limit while it is read. A 413 from an API route is a JSON error, and a page's is the HTML error page.

Reading the body#

Need Use What it gives
The bytes exactly as sent (a webhook signature) await event.rawBody() A Uint8Array. A UTF-8 byte order mark and invalid UTF-8 are kept. Bounded by the route's limit.
Text readTextLimited(request, max) Decoded with the charset parameter: utf-8 (the default), utf-16le, utf-16be, iso-8859-1 or latin1. Any other charset is 415. The decoding is strict: bytes that are not valid text in the charset are 400, never repaired with U+FFFD (two different byte strings cannot become one value).
JSON readJson(request), readJsonLimited(request, max) Parsed safely. JSON is UTF-8 (RFC 8259): a charset parameter other than utf-8 is 415, bytes that are not UTF-8 are 400. A non-JSON type is 415, invalid JSON is 400.
Forms readFormLimited(request, caps?) Multipart or urlencoded, checked against the form caps below. A field name with a replacement character (bytes that were not UTF-8) is 400; a body that does not parse is 400.

A body can be read once through these helpers or through request.text() and the other Request readers. The helpers keep the bytes they read: event.rawBody(), readJson and readTextLimited called again on the same request return the same bytes, and readJson after event.rawBody() sees the same body. A second read through request.text(), request.json() or another Request reader is a 500 whose message says the body was already consumed and names event.rawBody() and request.clone(). Call request.clone() before the first read if two readers need the stream. In development that message is shown; in production a 5xx has the status text only, and the error is still reported.

A body over the limit is a BodyTooLargeError (a 413 that carries limit). readJsonLimited, readTextLimited, readFormLimited and the handler's own reads throw it. On an API route the answer is the JSON error; the message of that answer is Payload Too Large unless apiHandler({ bodyTooLargeMessage }) names another. The app needs no mapping of its own. A server function has its own limit: see Limits.

Content-Encoding#

Content-Encoding: gzip and deflate are decoded, and the body limit applies to the decoded bytes, so a zip bomb is cut off with 413 and a corrupt stream is 400. identity and no header pass unchanged. Any other coding (br, zstd, a list of codings) is 415: a JSON error for an API route or a server function, and the plain error page otherwise.

Form caps#

readFormLimited bounds the parsed entries: maxFields (fields and files together), maxFileCount, maxFileBytes (per file), maxFieldBytes (per text field, in UTF-8 bytes) and maxTotalFieldBytes (all text fields). The defaults are 1000 entries, 20 files, 10 MiB per file, 64 KiB per field and 1 MiB of text fields. Set them for the app in the form block of security.limits, and override any of them for one read with the second argument:

the security option of createHandlerts
const security = {
  limits: { form: { maxFields: 100, maxFileBytes: 5 * 1024 * 1024 } },
};

A violation is a 413 with data.code set to FORM_TOO_MANY_FIELDS, FORM_TOO_MANY_FILES, FORM_FILE_TOO_LARGE, FORM_FIELD_TOO_LARGE or FORM_FIELDS_TOO_LARGE. The whole body is still parsed into memory, up to the body limit, before the caps are checked: the caps bound what the parser keeps, and a streaming parser is not part of this version. The server-function form path (serverFn with FormData) is not capped by these options yet.

Verifying a webhook over the raw bytes#

A signature covers the bytes the sender produced. Reading the body as text and encoding it again changes those bytes when the body has a byte order mark or bytes that are not valid UTF-8, so the check fails on a valid request. Verify over event.rawBody() instead:

src/routes/api/hook.tsts
import { HttpError, json, type RequestEvent } from "@shadow-js/dusk/server";

/** This route authenticates by signature, so the browser-oriented CSRF check is off for it alone. */
export const csrf = false;

const encoder = new TextEncoder();

function fromHex(hex: string): Uint8Array<ArrayBuffer> | undefined {
  if (!/^(?:[0-9a-f]{2})+$/i.test(hex)) return undefined;
  const bytes = new Uint8Array(hex.length / 2);
  for (let index = 0; index < bytes.length; index += 1) {
    bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
  }
  return bytes;
}

export const POST = async (event: RequestEvent) => {
  const secret = event.env["WEBHOOK_SECRET"];
  if (typeof secret !== "string") throw new Error("WEBHOOK_SECRET is not configured");
  // The bytes as sent, read once and bounded by the route's limit (1 MiB unless the route sets maxBodyBytes).
  const bytes = await event.rawBody();
  const signature = fromHex(event.request.headers.get("x-signature") ?? "");
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"],
  );
  if (signature === undefined || !(await crypto.subtle.verify("HMAC", key, signature, bytes))) {
    throw new HttpError(401, "Invalid signature");
  }
  return json({ received: true });
};

To cap this one route below the app's limit, export it as apiHandler({ maxBodyBytes: 64 * 1024 }, ...) instead.