Skip to content

feat: maxRequestBodySize server option#216

Merged
pi0 merged 7 commits into
mainfrom
fix/readbody-size-limit
Jul 2, 2026
Merged

feat: maxRequestBodySize server option#216
pi0 merged 7 commits into
mainfrom
fix/readbody-size-limit

Conversation

@pi0x

@pi0x pi0x commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Adds an opt-in maxRequestBodySize server option that caps the size of an incoming request body, enforced across Node, Bun, and Deno.

Motivation

srvx had no way to bound request-body memory. The buffered read path (request.text() / request.json()) accumulated every chunk with no limit, and the streamed/native paths (arrayBuffer(), blob(), bytes(), formData(), request.body) had none either — so a large or malicious upload (multipart formData() being the most common heavy case) could grow process memory unbounded. Bun ships a native maxRequestBodySize (default 128 MiB); Node and Deno have no default limit.

What this does

New generic ServerOptions.maxRequestBodySize (bytes, default undefined = no limit). When a body exceeds it, reading aborts and rejects with a 413-style error (statusCode: 413, status: 413, code: "ERR_BODY_TOO_LARGE") that a handler or onError can map to 413 Payload Too Large.

The limit covers all read paths — buffered (text()/json()) and streamed (request.body, and therefore arrayBuffer() / blob() / bytes() / formData()).

Per-runtime enforcement

Runtime How
Node srvx wraps request.body's ReadableStream with a pull-based byte-limiter (preserves backpressure, cancels upstream on overflow); the buffered readBody() fast path keeps its own cheap check.
Bun forwarded to Bun's native maxRequestBodySize — Bun responds 413 before the handler runs. An explicit bun.maxRequestBodySize still wins.
Deno Deno.serve has no native option, so srvx rebuilds the request with a size-limited body stream (method/url/headers/signal preserved).

The shared limiter primitives live in a runtime-agnostic src/_body-limit.ts. Also threaded through toNodeHandler(handler, { maxRequestBodySize }) for handlers embedded in an existing Node server.

Hot path / backward compat

Default undefinedno limit, identical to today. When unset, no body-stream wrapping and no per-request allocation occurs on any runtime (a separate commit also reverted an incidental per-request object allocation so the Node default path matches main exactly, ~25 ns/ctor).

Tests

  • test/node-max-body-size.test.ts — 9 cases: per-method (text/json/arrayBuffer/bytes/blob/formData/streaming) over-limit → 413, within-limit → 200, and no-limit-by-default backward-compat.
  • test/bun-max-body-size.test.ts + test/deno-max-body-size.test.ts — spawn the target runtime against test/fixtures/max-body-server.ts and assert 413 over / 200 within.

All suites green (node/bun/deno adapter suites + body-size tests: 191 passed, 8 skipped). pnpm lint and pnpm typecheck clean. Docs updated in docs/1.guide/5.options.md.

Notes for review

  • Bun semantics differ slightly: Bun rejects at the protocol level (413, empty body, handler never runs), whereas Node/Deno surface ERR_BODY_TOO_LARGE as a catchable rejection. Inherent to using Bun's native option.
  • Deno rebuilds the Request per bodied request when a limit is set (opt-in only) — a small allocation on that path; happy to gate it further if preferred.
  • Option name matches Bun's maxRequestBodySize for consistency; unreleased, so no alias kept.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an optional request body size limit for Node.js servers.
    • Large buffered request bodies can now be rejected with a clear 413-style error when they exceed the configured limit.
  • Bug Fixes

    • Improved handling of request body buffering so oversized payloads stop reading sooner.
    • Existing behavior remains unchanged when no limit is set.

The `readBody` fallback used by `NodeRequest.text()`/`.json()` (when the
body is not consumed as a stream) accumulated all chunks with no size
limit, so a large/malicious body could grow memory unbounded.

Add an opt-in `maxBodySize` (bytes) `ServerOptions` field. When set, the
buffered fallback tracks accumulated length and, once exceeded, pauses the
stream and rejects with a 413-style error (`statusCode`/`status: 413`,
`code: "ERR_BODY_TOO_LARGE"`). Default is unchanged (no limit), so this is
non-breaking. Streaming (`request.body`) is unaffected.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0x
pi0x requested a review from pi0 as a code owner July 2, 2026 10:08
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0c8b9d38-7238-46d1-b62c-14e8ec5b7f2c

📥 Commits

Reviewing files that changed from the base of the PR and between e8b062b and afbec2b.

📒 Files selected for processing (15)
  • docs/1.guide/5.options.md
  • src/_body-limit.ts
  • src/adapters/_node/adapter.ts
  • src/adapters/_node/request.ts
  • src/adapters/bun.ts
  • src/adapters/deno.ts
  • src/adapters/node.ts
  • src/types.ts
  • test/_max-body-size.ts
  • test/bench-node/_run.mjs
  • test/bench-node/srvx-fast-maxbody.mjs
  • test/bun-max-body-size.test.ts
  • test/deno-max-body-size.test.ts
  • test/fixtures/max-body-server.ts
  • test/node-max-body-size.test.ts
👮 Files not reviewed due to content moderation or server errors (15)
  • src/_body-limit.ts
  • src/adapters/_node/adapter.ts
  • src/adapters/_node/request.ts
  • src/adapters/node.ts
  • test/node-max-body-size.test.ts
  • test/deno-max-body-size.test.ts
  • docs/1.guide/5.options.md
  • test/bench-node/_run.mjs
  • src/adapters/bun.ts
  • src/types.ts
  • test/fixtures/max-body-server.ts
  • test/bench-node/srvx-fast-maxbody.mjs
  • src/adapters/deno.ts
  • test/_max-body-size.ts
  • test/bun-max-body-size.test.ts

📝 Walkthrough
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: an optional maxBodySize limit for the Node buffered body fallback.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/readbody-size-limit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/srvx@216

commit: afbec2b

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/adapters/_node/request.ts (1)

212-220: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider a Content-Length fast path to avoid reading any body bytes for obviously oversized requests.

Checking req.headers["content-length"] against maxBodySize before attaching data listeners would reject clearly-oversized requests immediately, without consuming any socket data.

♻️ Suggested addition
 function readBody(req: NodeServerRequest, maxBodySize?: number): Promise<any> {
   // https://github.com/GoogleCloudPlatform/functions-framework-nodejs/...
   if ("rawBody" in req && Buffer.isBuffer(req.rawBody)) {
     if (maxBodySize !== undefined && req.rawBody.length > maxBodySize) {
       return Promise.reject(createBodyTooLargeError(maxBodySize));
     }
     return Promise.resolve(req.rawBody);
   }
+
+  if (maxBodySize !== undefined) {
+    const contentLength = Number(req.headers["content-length"]);
+    if (Number.isFinite(contentLength) && contentLength > maxBodySize) {
+      return Promise.reject(createBodyTooLargeError(maxBodySize));
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/_node/request.ts` around lines 212 - 220, The readBody helper
currently waits to inspect the request body before rejecting oversized payloads,
which misses an early Content-Length check. Update readBody in the Node request
adapter to inspect req.headers["content-length"] before attaching any data
listeners and immediately reject via createBodyTooLargeError(maxBodySize) when
the declared size is obviously too large; keep the existing rawBody handling and
fallback streaming behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/adapters/_node/request.ts`:
- Around line 229-242: In onData within request body handling, oversized uploads
should not call req.pause() because it can leave the socket tied up; instead,
change the maxBodySize overflow path to either keep draining and discarding the
remaining body or mark the connection for closure and destroy the socket after
responding with 413. Update the cleanup/error path around
createBodyTooLargeError so the request is fully consumed or closed instead of
merely paused.

---

Nitpick comments:
In `@src/adapters/_node/request.ts`:
- Around line 212-220: The readBody helper currently waits to inspect the
request body before rejecting oversized payloads, which misses an early
Content-Length check. Update readBody in the Node request adapter to inspect
req.headers["content-length"] before attaching any data listeners and
immediately reject via createBodyTooLargeError(maxBodySize) when the declared
size is obviously too large; keep the existing rawBody handling and fallback
streaming behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fcb0ed68-7870-4d97-ba89-fd10048ca8f8

📥 Commits

Reviewing files that changed from the base of the PR and between f413589 and e8b062b.

📒 Files selected for processing (4)
  • src/adapters/_node/request.ts
  • src/adapters/node.ts
  • src/types.ts
  • test/node-max-body-size.test.ts

Comment thread src/adapters/_node/request.ts
@pi0
pi0 marked this pull request as draft July 2, 2026 10:36
Constructing NodeRequest reused the passed context object for
`runtime.node` before maxBodySize was added; the maxBodySize change
switched it to a fresh `{ req, res }` literal to keep maxBodySize off
the public shape, adding a ~4-5 ns allocation to every request on the
hot path. Reuse `ctx` directly again: maxBodySize may ride along but is
intentionally not part of the public `runtime.node` type, and consumers
only read req/res. Restores main's single-allocation profile (measured
~25 ns/ctor, identical to main).

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0x

pi0x commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: hot-path allocation fix + Bun/Deno default comparison

Perf fix (pushed): The initial commit changed runtime.node from reusing the passed context object to allocating a fresh { req, res } literal per request (to keep maxBodySize off the public shape). Benchmarking showed this added ~4–5 ns to every request on the hot path, even with no limit configured. The follow-up commit reverts to node: ctxmaxBodySize rides along but is intentionally excluded from the public runtime.node type, and all consumers only read req/res. Measured constructor cost is now ~25 ns/ctor, identical to main (within noise). The per-chunk readBody guard was already correctly skipped when maxBodySize is unset, so the default path is now truly zero-overhead.

Do other runtimes impose a default?

Runtime Default request-body limit
Bun (Bun.serve) 128 MiB (maxRequestBodySize, 134,217,728 bytes)
Deno (Deno.serve) None (open feature request denoland/deno#3515)
Node (raw http) None (only maxHeaderSize ~16 KB for headers)

So there's precedent (Bun) for a safe default, but Deno and raw Node have none. Left as undefined (opt-in) here because this limit currently only covers the buffered .text()/.json() fallback — streaming (request.body) is unaffected — so a default-on limit would apply asymmetrically and could surprise streaming apps. If a default is desired for parity with Bun, 128 MiB would match, but I'd suggest deciding that separately from this guard mechanism.

@pi0 pi0 changed the title feat(node): optional maxBodySize guard for buffered body fallback (proposal) feat(node): optional maxBodySize guard for buffered body fallback Jul 2, 2026
The guard previously only covered the buffered readBody() fallback used
by text()/json(). Reads that route through the native Request —
arrayBuffer(), blob(), bytes(), formData() — and direct streaming of
request.body bypassed it entirely, leaving the largest/most common heavy
path (multipart uploads via formData) uncapped.

Enforce the limit at the single choke point every consumer funnels
through: wrap request.body's ReadableStream with a pull-based limiter
that counts bytes and errors with the same 413-style error once the
limit is exceeded, cancelling the upstream Node stream. This covers the
native Request methods and streaming while preserving backpressure. The
readBody() fast path keeps its own cheap check. When maxBodySize is
undefined (default) the body stream is not wrapped, so the hot path is
unchanged.

Also thread maxBodySize through toNodeHandler(handler, { maxBodySize })
so handlers embedded in an existing Node server can opt in.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0x

pi0x commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Update: maxBodySize now covers all body-read paths + toNodeHandler

The initial commit only enforced the limit in the buffered readBody() fallback (text()/json()). Reads that route through the native RequestarrayBuffer(), blob(), bytes(), formData() — and direct streaming of request.body bypassed it, which meant the most common heavy path (multipart file uploads via formData()) was uncapped.

c41c1bc closes that gap by enforcing at the single choke point every consumer funnels through: request.body's ReadableStream is wrapped with a pull-based limiter that counts bytes and errors with the same 413-style error (ERR_BODY_TOO_LARGE, statusCode: 413) once exceeded, cancelling the upstream Node stream. Because it's pull-based it preserves backpressure and stops reading as soon as the limit is hit rather than buffering first. The readBody() fast path keeps its own cheap check.

Verified end-to-end that arrayBuffer(), bytes(), blob(), formData(), streaming, and text()/json() all return HTTP 413 with the limit set, and within-limit requests still succeed. Tests expanded to 9 (per-method + multipart + within-limit + backward-compat); full node suites pass (145 passed / 6 skipped).

Hot path unchanged: when maxBodySize is undefined (default) the body stream is not wrapped — no overhead. Combined with 8f2bc6a (reuse the request context object), the default path has zero added allocation vs main.

Also threaded through toNodeHandler(handler, { maxBodySize }) so handlers embedded in an existing Node server can opt in. Still Node-only; other runtimes ignore the option (docs updated to say "Currently only enforced for the Node.js runtime").

maxBodySize was Node-only. Make it a generic server option enforced on
every runtime that srvx can enforce it on:

- Extract the body-limit primitives into a runtime-agnostic module
  (src/_body-limit.ts): createBodyTooLargeError, limitBodyStream (used by
  the Node body getter) and limitRequestBody (rebuilds a native Request
  with a size-limited body, preserving method/url/headers/signal).
- Bun: map maxBodySize to Bun's native `maxRequestBodySize` (Bun returns
  413 before the handler runs); an explicit `bun.maxRequestBodySize` still
  wins.
- Deno: Deno.serve has no native option, so enforce in the fetch wrapper by
  rebuilding the request with a limited body stream. No-op when unset or
  bodyless, so the default hot path is unchanged.

Verified end-to-end on all three runtimes (413 over the limit, 200 within)
with automated tests: Node (vitest), plus Bun/Deno via spawned fixtures.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0x

pi0x commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Update: maxBodySize is now a cross-runtime option (Bun + Deno)

Promoted maxBodySize from Node-only to a generic server option, enforced on every runtime srvx can enforce it on:

  • Extracted the body-limit primitives into a runtime-agnostic module src/_body-limit.tscreateBodyTooLargeError, limitBodyStream (used by the Node body getter), and limitRequestBody (rebuilds a native Request with a size-limited body, preserving method/url/headers/signal).
  • Bun: mapped to Bun's native maxRequestBodySize — Bun returns 413 before the handler runs. An explicit bun.maxRequestBodySize still wins. (Bun's own default is 128 MiB; srvx leaves it unset unless you set maxBodySize.)
  • Deno: Deno.serve has no native body-size option, so srvx enforces it in the fetch wrapper by rebuilding the request with a limited body stream. No-op when unset or bodyless, so the default hot path is unchanged.
  • Node: unchanged (srvx stream limiter + fast-path readBody check).

Docs updated to a per-runtime support table.

Verified end-to-end on all three runtimes (413 over the limit, 200 within), with automated tests: Node via vitest (9 cases), and Bun/Deno via spawned fixtures (test/bun-max-body-size.test.ts, test/deno-max-body-size.test.ts, sharing test/fixtures/max-body-server.ts). Full suites green: node/bun/deno adapter suites + all body-size tests (191 passed / 8 skipped).

Match Bun's native `maxRequestBodySize` option name (which the Bun adapter
forwards to directly) and be explicit that the limit applies to the request
body. The option is unreleased, so no alias is kept.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0x pi0x changed the title feat(node): optional maxBodySize guard for buffered body fallback feat: maxRequestBodySize server option (Node, Bun, Deno) Jul 2, 2026
@pi0 pi0 changed the title feat: maxRequestBodySize server option (Node, Bun, Deno) feat: maxRequestBodySize server option Jul 2, 2026
Same as srvx-fast but with maxRequestBodySize set high enough to never
trigger, to track the fast-path enforcement overhead. Overhead is within
run-to-run noise (the per-chunk check is a length add + compare, dwarfed
by socket I/O + JSON in the end-to-end path).

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0
pi0 marked this pull request as ready for review July 2, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants