fix(actions): enforce serverActions.bodySizeLimit on fetch actions (#1828)#1834
Conversation
…1828) Oversized fetch (client-invoked) server-action requests were rejected with a bare 413 Payload Too Large response, which bypasses the client error boundary and surfaces the wrong status/content-type. Next.js instead throws the body-exceeded error before the action runs and, for fetch actions, emits a 500 Flight response carrying the rejected action result so the nearest error boundary catches it. Mirror that: when an RSC fetch action body exceeds the configured limit (content-length pre-check or stream read), render a Flight stream with returnValue { ok: false } and no page root, returning status 500. The no-JS progressive form path keeps its 413 (no client boundary to catch it). Closes #1828
|
/bigbonk review for issues |
|
commit: |
There was a problem hiding this comment.
Review: enforce serverActions.bodySizeLimit on fetch actions
The overall approach is right and matches Next.js: throw the body-exceeded error before the action runs, emit a 500 Flight response carrying returnValue: { ok: false } and no page root so the client error boundary catches it, and keep the 413 on the no-JS progressive form-POST path. The clearRequestContext deferral via createServerActionRscResponse (fires after the stream drains) is consistent with the other RSC action paths, and the unit tests are well-targeted.
However, there is one blocking correctness issue that means this PR likely does not actually pass the upstream suites it claims to fix.
Blocking: error message format does not match Next.js (breaks the upstream tests)
formatBodySizeLimit reconstructs a size label from the parsed byte count, producing values like 2 MB / 1 MB (uppercase unit, space-separated). Next.js emits the verbatim bodySizeLimit config string instead:
// next/src/server/app-render/action-handler.ts
`Body exceeded ${bodySizeLimit} limit.\n` + ...The upstream suites this PR says it fixes assert the log message literally:
// test/e2e/app-dir/actions/app-action-size-limit-invalid.test.ts (and -node-middleware variant)
expect.stringContaining('Error: Body exceeded 2mb limit') // config is bodySizeLimit: '2mb'With bodySizeLimit: '2mb', vinext stores 2097152 bytes and formatBodySizeLimit(2097152) returns "2 MB", so the emitted message is Body exceeded 2 MB limit. — which does not contain Body exceeded 2mb limit. It diverges on both casing (MB vs mb) and spacing (2 MB vs 2mb). The default (1mb) has the same problem (1 MB vs 1mb).
The PR's own unit test only asserts the reconstructed form ("Body exceeded 10 B limit"), so it passes locally while the upstream e2e assertion would still fail. The 60-test targeted suite being green does not exercise the upstream message format.
Fix: thread the original config string through to the runtime (like Next.js does) instead of reconstructing it. The raw value is available at parse time in config/next-config.ts:1281 (serverActionsConfig?.bodySizeLimit) but is currently discarded after parseBodySizeLimit. Plumb a serverActionsBodySizeLimitLabel (the verbatim string, defaulting to "1mb" when unset) alongside the byte count through app-rsc-entry.ts -> HandleServerActionRscRequestOptions, and use it in createBodyExceededError. Then update the unit test to assert the verbatim string and, ideally, port the upstream e2e assertion so this regression is caught.
The code comment at app-server-action-execution.ts:409-411 already acknowledges this divergence ("Next.js surfaces the original config string here ... we reconstruct an equivalent label") — but the label is not actually equivalent, which is precisely the bug.
Non-blocking observations
- Parity check (good): the App Router prod server delegates to the built RSC entry, so this fix is inherited there. The Pages Router
bodySizeLimitsemantics are separate (API routes), so no change is needed inserver/prod-server.tsfor this. Worth confirming there's no other fetch-action 413 path left behind, but the two call sites here look complete. formatBodySizeLimitcan be deleted once the config string is threaded through — it has no other callers and removing it avoids a second, subtly-wrong size formatter in the codebase.
Summary
Architecture and response shaping are correct. The single blocker is the error-message format mismatch, which means the stated upstream suites are not actually satisfied. Plumb the verbatim config string through rather than reconstructing it from bytes, then assert the verbatim string in tests.
|
Review posted on PR #1834 (CHANGES_REQUESTED). SummaryThe PR's architecture is correct — it mirrors Next.js by emitting a 500 Flight response ( One blocking issue found:
Fix: thread the original I verified |
#1828) Address bonk review: the body-exceeded error message must contain the verbatim serverActions.bodySizeLimit config string (e.g. "2mb") to match Next.js byte-for-byte — the upstream suites assert the log message literally ('Body exceeded 2mb limit'). The previous reconstruction from the parsed byte count produced '2 MB', diverging on casing and spacing. Thread serverActionsBodySizeLimitLabel (the verbatim config value, defaulting to "1mb") through next-config -> index -> app-rsc-entry -> the RSC action handler options, and use it directly in createBodyExceededError. Removes the lossy formatBodySizeLimit reconstructor. Tests assert the verbatim string.
|
Addressed the blocking finding in 54b2ee2. The body-exceeded error now uses the verbatim Tests updated: the execution unit test asserts the verbatim /bigbonk review for issues |
There was a problem hiding this comment.
Re-review: verbatim bodySizeLimit string in body-exceeded error (commit 54b2ee2)
The blocking finding from the previous review is resolved correctly for the targeted suites. I verified against the upstream source (next/src/server/app-render/action-handler.ts) and the e2e test:
// action-handler.ts
const bodySizeLimit = serverActions?.bodySizeLimit ?? defaultBodySizeLimit
...
`Body exceeded ${bodySizeLimit} limit.\n` + ...// test/e2e/app-dir/actions/app-action-size-limit-invalid.test.ts (config: bodySizeLimit: '2mb')
expect.stringContaining('Error: Body exceeded 2mb limit')Next.js interpolates the raw config value verbatim, so '2mb' → Body exceeded 2mb limit.. The new serverActionsBodySizeLimitLabel plumbing produces exactly that, byte-for-byte. The lossy formatBodySizeLimit reconstructor is fully removed (no dangling references), and the unit test now asserts the verbatim Body exceeded 2mb limit instead of the old reconstructed 10 B form. I ran the targeted cases locally and confirmed the #1828 content-length/stream-limit tests and the three next-config label tests pass. The architecture (500 Flight response, returnValue: { ok: false }, deferred clearRequestContext) is unchanged and still correct.
Non-blocking: default label diverges from Next.js (1mb vs 1 MB)
When serverActions.bodySizeLimit is unset, this PR defaults serverActionsBodySizeLimitLabel to "1mb", so the error reads Body exceeded 1mb limit.. Next.js uses a different default literal:
const defaultBodySizeLimit = '1 MB' // note: uppercase + spaceso upstream emits Body exceeded 1 MB limit. for the unconfigured case. This is the same class of divergence (casing + spacing) that the original blocker was about — just on the default path instead of the configured one. The two suites this PR fixes both set '2mb', so they don't exercise the default and won't catch it, but any future test relying on the default-limit message (or anyone diffing logs against Next.js) would see a mismatch.
Suggested: default the label to "1 MB" (matching Next.js's defaultBodySizeLimit literal) rather than "1mb", and update the next-config default-case assertion to "1 MB". Low risk, keeps the message identical to upstream on every path.
Numeric config (good)
For a numeric bodySizeLimit (e.g. 5242880), String(5242880) → "5242880", which matches Next.js interpolating the number directly (Body exceeded 5242880 limit.). The next-config test locks this in.
Summary
The blocking issue is fixed for the suites in scope — verbatim string threading is the right approach and matches upstream. The only remaining nit is the default label (1mb should be 1 MB to match defaultBodySizeLimit); non-blocking since the targeted tests configure '2mb'.
|
Review posted on PR #1834 (COMMENTED). SummaryThe blocking finding from the prior review is resolved correctly in commit
One non-blocking divergence foundThe default label diverges from Next.js. When |
…1828) Address bonk re-review nit: when serverActions.bodySizeLimit is unset, Next.js emits 'Body exceeded 1 MB limit.' (its defaultBodySizeLimit = '1 MB' literal). Default serverActionsBodySizeLimitLabel to '1 MB' (was '1mb') so the unconfigured-case message matches upstream byte-for-byte too.
|
Addressed the non-blocking default-label nit in 1ad3387. When /bigbonk review for issues |
There was a problem hiding this comment.
Re-review: default bodySizeLimit label → 1 MB (commit 1ad3387)
The non-blocking default-label nit from the previous re-review is resolved correctly. Verified the full chain and ran the targeted suites locally — all green.
What I checked
- Default literal matches Next.js. When
serverActions.bodySizeLimitis unset, the label now defaults to"1 MB"(uppercase + space), matching Next.js'const defaultBodySizeLimit = '1 MB'. The unconfigured-case message now readsBody exceeded 1 MB limit.byte-for-byte. - Default is consistent across both fallback sites.
next-config.ts:1189(object default) andnext-config.ts:1295(unset-branch), plus the runtime fallback atapp-rsc-entry.ts:195(config?.bodySizeLimitLabel ?? "1 MB"), all use"1 MB"— no remaining"1mb"default anywhere. The verbatim label threads cleanly:next-config→index.ts:2519→app-rsc-entry(__MAX_ACTION_BODY_SIZE_LABEL) →maxActionBodySizeLabel→createBodyExceededError(app-server-action-execution.ts:931), which interpolates it verbatim. - Configured/numeric paths untouched.
"2mb"→"2mb"and5242880→"5242880"assertions are unchanged and still correct. The only remaining"1mb"reference is theparseBodySizeLimit("1mb")parse test (expected — that exercises parsing, not the label) and config-string examples in comments/error text. - Tests updated. The
next-configdefault-case assertion now expects"1 MB", and thedetectNextIntlConfigexpected-config object was updated to match.
Verification
tests/next-config.test.ts(bodySizeLimit label cases) — passtests/app-server-action-execution.test.ts(60 tests, incl. #1828 content-length/stream-limit) — passvp checkon all three changed files — clean (format, lint, types)
Summary
LGTM. The default path now matches upstream on every code path (configured, numeric, and unset). No further issues — nothing blocking.
|
Review posted on PR #1834 (COMMENTED). SummaryThe non-blocking default-label nit from the prior re-review is resolved correctly in commit
LGTM, nothing blocking. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Re-review (commit 1ad3387): verified against upstream Next.js source + e2e fixture
I traced the full flow end-to-end against the local Next.js canary clone and the actual upstream test this PR closes. No blocking issues. LGTM.
Confirmed correct against authoritative upstream
I verified each claim in the PR against action-handler.ts and the e2e fixture rather than relying on the green local suite:
- Status 500 is correct.
test/e2e/app-dir/actions/app-action-size-limit-invalid.test.ts:139assertsexpect(actionResponse.status()).toBe(500) // TODO: 413?. Upstream throwsApiError(413, ...)ataction-handler.ts:920, but the generic fetch-action catch (action-handler.ts:1257-1287) overrides it tores.statusCode = 500and emits a Flight response with the rejectedactionResultandskipPageRenderingwhen nothing revalidated. vinext mirrors this exactly: 500, RSC content-type,returnValue: { ok: false }, noroot. - The server-log assertion is satisfied. The test asserts on server logs, not the client error object:
logsmust containexpect.stringContaining('Error: Body exceeded 2mb limit')and the docs-link string (lines 151-158). The PR'sconsole.error("[vinext] Server action error:", error)with the verbatim-label message (Body exceeded 2mb limit.\nTo configure the body size limit...) produces exactly that. The verbatim2mbthreading (next-config->index->app-rsc-entry-> handler options) is the right fix and matchesBody exceeded ${bodySizeLimit} limit.ataction-handler.ts:922. - Default label
1 MBmatches upstream.action-handler.ts:901usesconst defaultBodySizeLimit = '1 MB'. The unset path now defaults to"1 MB"atnext-config.ts(object default + unset-branch) and the runtime fallback inapp-rsc-entry.ts. Numeric config (String(5242880)) also matches upstream's direct number interpolation. Thenext-configtest locks all three (string/numeric/default). - Error boundary still renders. Upstream asserts
#errorshows "Something went wrong!" (the routeerror.js). vinext's browser entry throws vianormalizeServerActionThrownValue(...)forok: false+ noroot(app-browser-entry.ts:1688-1690); since 500 is not a valid access-fallback status (only 401/403/404 perhttp-access-fallback.ts), it surfaces as a generic error and renderserror.js. Correct outcome. - 413 retained on the progressive (no-JS) path (
app-server-action-execution.ts:744-748, 760-765). Correct — no client error boundary there to catch a Flight error. - Dev/prod parity holds. App Router prod delegates to the built RSC entry, so the fix is inherited. There is no separate fetch-action 413 path in
prod-server.ts/dev-server.ts(those handle Pages Router API-route limits separately), so nothing else needs updating.
Verification run locally
tests/app-server-action-execution.test.ts(60 tests, incl. both #1828 cases) +tests/next-config.test.ts(171 tests) — 231 passed.vp checkon all four changed source files — clean (format, lint, types).
Minor non-blocking observation (pre-existing, not introduced here)
For an RSC fetch-action response, Next.js's client reducer ignores the HTTP status and surfaces the real serialized action error from the Flight payload (response.a, server-action-reducer.ts:261). vinext's browser entry instead synthesizes a NEXT_HTTP_ERROR_FALLBACK;500 error and discards returnValue.data (app-browser-action-result.ts:88). The error boundary still renders correctly (so the upstream test passes), but the surfaced error object differs from Next.js. This is pre-existing browser-entry behavior unrelated to this PR and does not affect the suites in scope — worth a separate follow-up if exact client error-object parity is ever desired.
Nothing blocking. The fix is correct and faithfully mirrors upstream.
|
Review posted on PR #1834 (COMMENTED). SummaryI verified the final state (commit Confirmed correct against authoritative upstream:
Verification: 231 tests pass across both targeted suites; One non-blocking observation (pre-existing, not introduced by this PR): vinext's browser entry synthesizes a |
Problem
serverActions.bodySizeLimitwas not enforced for fetch (client-invoked) server actions: oversized requests were rejected with a bare413 Payload Too Largeplain-text response. That bypasses the React client error boundary and surfaces the wrong status/content-type, so the four upstream suites failed:app-action-size-limit-invalid.test.ts— "should error for requests that exceed the size limit" (plaintext + multipart)app-action-size-limit-invalid-node-middleware.test.ts— same two, with node-runtime middlewareFix
Next.js throws the body-exceeded error before the action runs and, for fetch actions, emits a 500 Flight response carrying the rejected action result so the nearest client error boundary catches it (renders the route's
error.js). vinext now mirrors that:handleServerActionRscRequest, both oversized-body checks (content-length pre-check and stream-read limit) now call a newrenderFetchActionBodyExceededResponse, which renders a Flight stream withreturnValue: { ok: false }and no page root (the action never ran, nothing revalidated), returning status 500 with the RSC content-type.normalizeServerActionThrownValue(...)when a fetch action result hasreturnValue.ok === falseand no root, which propagates to the error boundary — no client change needed.Body exceeded {limit} limit.+ docs link) so it reads identically in logs.The no-JS progressive form-POST path keeps its existing 413 — there is no client error boundary there to catch a Flight error, so a 413 is the right behavior.
Tests
Added two unit cases to
tests/app-server-action-execution.test.tscovering the content-length and stream-limit triggers: assert status 500, RSC content-type, that the action is never loaded, and that the body-exceeded error is embedded inreturnValue. Existing 413 assertions on the progressive path are unchanged.vp checkclean on both changed files; targeted suite (60 tests) green.Closes #1828