From 88de1c3264b2bbf6a5dcb49e544067f6b3467864 Mon Sep 17 00:00:00 2001 From: Georgiy Slobodenyuk Date: Sat, 7 Mar 2026 20:56:39 -0500 Subject: [PATCH 1/3] feat: support experimental.serverActions.bodySizeLimit from next.config --- packages/vinext/src/config/next-config.ts | 29 ++++++- packages/vinext/src/index.ts | 1 + packages/vinext/src/server/app-dev-server.ts | 10 ++- tests/next-config.test.ts | 82 +++++++++++++++++++- 4 files changed, 117 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index d8b800c4..1642c7a6 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -11,6 +11,28 @@ import fs from "node:fs"; import { PHASE_DEVELOPMENT_SERVER } from "../shims/constants.js"; import { normalizePageExtensions } from "../routing/file-matcher.js"; +/** + * Parse a body size limit value (string or number) into bytes. + * Accepts Next.js-style strings like "1mb", "500kb", "10mb". + * Returns the default 1MB if the value is not provided or invalid. + */ +export function parseBodySizeLimit(value: string | number | undefined | null): number { + if (value === undefined || value === null) return 1 * 1024 * 1024; + if (typeof value === "number") return value; + if (typeof value !== "string") return 1 * 1024 * 1024; + const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/i); + if (!match) return 1 * 1024 * 1024; + const num = parseFloat(match[1]); + const unit = match[2].toLowerCase(); + switch (unit) { + case "b": return Math.floor(num); + case "kb": return Math.floor(num * 1024); + case "mb": return Math.floor(num * 1024 * 1024); + case "gb": return Math.floor(num * 1024 * 1024 * 1024); + default: return 1 * 1024 * 1024; + } +} + export interface HasCondition { type: "header" | "cookie" | "query" | "host"; key: string; @@ -169,6 +191,8 @@ export interface ResolvedNextConfig { allowedDevOrigins: string[]; /** Extra allowed origins for server action CSRF validation (from experimental.serverActions.allowedOrigins). */ serverActionsAllowedOrigins: string[]; + /** Parsed body size limit for server actions in bytes (from experimental.serverActions.bodySizeLimit). Defaults to 1MB. */ + serverActionsBodySizeLimit: number; } const CONFIG_FILES = [ @@ -286,6 +310,7 @@ export async function resolveNextConfig( aliases: {}, allowedDevOrigins: [], serverActionsAllowedOrigins: [], + serverActionsBodySizeLimit: 1 * 1024 * 1024, }; } @@ -334,7 +359,7 @@ export async function resolveNextConfig( ? config.allowedDevOrigins : []; - // Resolve serverActions.allowedOrigins from experimental config + // Resolve serverActions.allowedOrigins and bodySizeLimit from experimental config const experimental = config.experimental as Record | undefined; const serverActionsConfig = experimental?.serverActions as | Record @@ -344,6 +369,7 @@ export async function resolveNextConfig( ) ? (serverActionsConfig.allowedOrigins as string[]) : []; + const serverActionsBodySizeLimit = parseBodySizeLimit(serverActionsConfig?.bodySizeLimit as string | number | undefined); // Warn about unsupported webpack usage. We preserve alias injection and // extract MDX settings, but all other webpack customization is still ignored. @@ -394,6 +420,7 @@ export async function resolveNextConfig( aliases, allowedDevOrigins, serverActionsAllowedOrigins, + serverActionsBodySizeLimit, }; } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 221fb58d..d01624b2 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2379,6 +2379,7 @@ hydrate(); headers: nextConfig?.headers, allowedOrigins: nextConfig?.serverActionsAllowedOrigins, allowedDevOrigins: nextConfig?.allowedDevOrigins, + bodySizeLimit: nextConfig?.serverActionsBodySizeLimit, }, instrumentationPath); } if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) { diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index c2fd2b77..85f7ec4f 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -31,6 +31,8 @@ export interface AppRouterConfig { allowedOrigins?: string[]; /** Extra origins allowed for dev server access (from allowedDevOrigins). */ allowedDevOrigins?: string[]; + /** Body size limit for server actions in bytes (from experimental.serverActions.bodySizeLimit). */ + bodySizeLimit?: number; } /** @@ -57,6 +59,7 @@ export function generateRscEntry( const rewrites = config?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] }; const headers = config?.headers ?? []; const allowedOrigins = config?.allowedOrigins ?? []; + const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024; // Build import map for all page and layout files const imports: string[] = []; const importMap: Map = new Map(); @@ -1290,12 +1293,13 @@ function __isExternalUrl(url) { } /** - * Maximum server-action request body size (1 MB). - * Matches the Next.js default for serverActions.bodySizeLimit. + * Maximum server-action request body size. + * Configurable via experimental.serverActions.bodySizeLimit in next.config. + * Defaults to 1MB, matching the Next.js default. * @see https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions#bodysizelimit * Prevents unbounded request body buffering. */ -var __MAX_ACTION_BODY_SIZE = 1 * 1024 * 1024; +var __MAX_ACTION_BODY_SIZE = ${JSON.stringify(bodySizeLimit)}; /** * Read a request body as text with a size limit. diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index d654d26d..d8855638 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { loadNextConfig, resolveNextConfig } from "../packages/vinext/src/config/next-config.js"; +import { loadNextConfig, parseBodySizeLimit, resolveNextConfig } from "../packages/vinext/src/config/next-config.js"; import { PHASE_PRODUCTION_BUILD, PHASE_DEVELOPMENT_SERVER } from "../packages/vinext/src/shims/constants.js"; function makeTempDir(): string { @@ -225,3 +225,83 @@ module.exports = withPlugin({ basePath: "/wrapped" });`, expect(config.mdx?.remarkPlugins).toEqual([fakeRemarkPlugin]); }); }); + +describe("parseBodySizeLimit", () => { + it("parses megabyte strings", () => { + expect(parseBodySizeLimit("10mb")).toBe(10 * 1024 * 1024); + expect(parseBodySizeLimit("1mb")).toBe(1 * 1024 * 1024); + }); + + it("parses kilobyte strings", () => { + expect(parseBodySizeLimit("500kb")).toBe(500 * 1024); + }); + + it("parses gigabyte strings", () => { + expect(parseBodySizeLimit("1gb")).toBe(1 * 1024 * 1024 * 1024); + }); + + it("parses byte strings", () => { + expect(parseBodySizeLimit("2048b")).toBe(2048); + }); + + it("passes through numeric values directly", () => { + expect(parseBodySizeLimit(2097152)).toBe(2097152); + }); + + it("is case-insensitive", () => { + expect(parseBodySizeLimit("10MB")).toBe(10 * 1024 * 1024); + expect(parseBodySizeLimit("500KB")).toBe(500 * 1024); + }); + + it("handles fractional values", () => { + expect(parseBodySizeLimit("1.5mb")).toBe(Math.floor(1.5 * 1024 * 1024)); + }); + + it("returns default 1MB for undefined", () => { + expect(parseBodySizeLimit(undefined)).toBe(1 * 1024 * 1024); + }); + + it("returns default 1MB for null", () => { + expect(parseBodySizeLimit(null)).toBe(1 * 1024 * 1024); + }); + + it("returns default 1MB for invalid strings", () => { + expect(parseBodySizeLimit("invalid")).toBe(1 * 1024 * 1024); + expect(parseBodySizeLimit("")).toBe(1 * 1024 * 1024); + expect(parseBodySizeLimit("10tb")).toBe(1 * 1024 * 1024); + }); +}); + +describe("resolveNextConfig serverActionsBodySizeLimit", () => { + it("defaults to 1MB when no config is provided", async () => { + const resolved = await resolveNextConfig(null); + expect(resolved.serverActionsBodySizeLimit).toBe(1 * 1024 * 1024); + }); + + it("defaults to 1MB when serverActions is not configured", async () => { + const resolved = await resolveNextConfig({ env: {} }); + expect(resolved.serverActionsBodySizeLimit).toBe(1 * 1024 * 1024); + }); + + it("parses bodySizeLimit from experimental.serverActions", async () => { + const resolved = await resolveNextConfig({ + experimental: { + serverActions: { + bodySizeLimit: "10mb", + }, + }, + }); + expect(resolved.serverActionsBodySizeLimit).toBe(10 * 1024 * 1024); + }); + + it("accepts numeric bodySizeLimit", async () => { + const resolved = await resolveNextConfig({ + experimental: { + serverActions: { + bodySizeLimit: 5242880, + }, + }, + }); + expect(resolved.serverActionsBodySizeLimit).toBe(5242880); + }); +}); From 226f033f4feca07a5fa388c651a605a5e9c0cc5c Mon Sep 17 00:00:00 2001 From: Georgiy Slobodenyuk Date: Sun, 8 Mar 2026 13:33:25 -0400 Subject: [PATCH 2/3] fix: address PR review feedback for parseBodySizeLimit - Accept bare number strings (e.g. "1048576") as bytes, matching Next.js bytes library behavior - Add tb and pb unit support - Throw on zero/negative values to match Next.js validation behavior - Remove unreachable type guard (value is always string at that point) - Update tests: add tb/pb/bare-number/validation cases, fix "10tb" assertion Co-Authored-By: Claude Sonnet 4.6 --- packages/vinext/src/config/next-config.ts | 28 +++++++++++++++-------- tests/next-config.test.ts | 19 ++++++++++++++- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 1642c7a6..2d7efdd7 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -13,24 +13,34 @@ import { normalizePageExtensions } from "../routing/file-matcher.js"; /** * Parse a body size limit value (string or number) into bytes. - * Accepts Next.js-style strings like "1mb", "500kb", "10mb". + * Accepts Next.js-style strings like "1mb", "500kb", "10mb", bare number strings like "1048576" (bytes), + * and numeric values. Supports b, kb, mb, gb, tb, pb units. * Returns the default 1MB if the value is not provided or invalid. + * Throws if the parsed value is less than 1. */ export function parseBodySizeLimit(value: string | number | undefined | null): number { if (value === undefined || value === null) return 1 * 1024 * 1024; - if (typeof value === "number") return value; - if (typeof value !== "string") return 1 * 1024 * 1024; - const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/i); + if (typeof value === "number") { + if (value < 1) throw new Error(`Body size limit must be a positive number, got ${value}`); + return value; + } + const trimmed = value.trim(); + const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?$/i); if (!match) return 1 * 1024 * 1024; const num = parseFloat(match[1]); - const unit = match[2].toLowerCase(); + const unit = (match[2] ?? "b").toLowerCase(); + let bytes: number; switch (unit) { - case "b": return Math.floor(num); - case "kb": return Math.floor(num * 1024); - case "mb": return Math.floor(num * 1024 * 1024); - case "gb": return Math.floor(num * 1024 * 1024 * 1024); + case "b": bytes = Math.floor(num); break; + case "kb": bytes = Math.floor(num * 1024); break; + case "mb": bytes = Math.floor(num * 1024 * 1024); break; + case "gb": bytes = Math.floor(num * 1024 * 1024 * 1024); break; + case "tb": bytes = Math.floor(num * 1024 * 1024 * 1024 * 1024); break; + case "pb": bytes = Math.floor(num * 1024 * 1024 * 1024 * 1024 * 1024); break; default: return 1 * 1024 * 1024; } + if (bytes < 1) throw new Error(`Body size limit must be a positive number, got ${bytes}`); + return bytes; } export interface HasCondition { diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index d8855638..0e794c7e 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -268,7 +268,24 @@ describe("parseBodySizeLimit", () => { it("returns default 1MB for invalid strings", () => { expect(parseBodySizeLimit("invalid")).toBe(1 * 1024 * 1024); expect(parseBodySizeLimit("")).toBe(1 * 1024 * 1024); - expect(parseBodySizeLimit("10tb")).toBe(1 * 1024 * 1024); + }); + + it("parses terabyte strings", () => { + expect(parseBodySizeLimit("10tb")).toBe(10 * 1024 * 1024 * 1024 * 1024); + }); + + it("parses petabyte strings", () => { + expect(parseBodySizeLimit("1pb")).toBe(1 * 1024 * 1024 * 1024 * 1024 * 1024); + }); + + it("accepts bare number strings as bytes", () => { + expect(parseBodySizeLimit("1048576")).toBe(1048576); + expect(parseBodySizeLimit("2097152")).toBe(2097152); + }); + + it("throws for zero or negative numeric values", () => { + expect(() => parseBodySizeLimit(0)).toThrow(); + expect(() => parseBodySizeLimit(-1)).toThrow(); }); }); From 48e5ab982fe6016401a1f39afc86f310313b8abf Mon Sep 17 00:00:00 2001 From: Georgiy Slobodenyuk Date: Sun, 8 Mar 2026 14:06:45 -0400 Subject: [PATCH 3/3] fix: warn on invalid bodySizeLimit string instead of silently falling back Co-Authored-By: Claude Sonnet 4.6 --- packages/vinext/src/config/next-config.ts | 7 ++++++- tests/next-config.test.ts | 11 ++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 2d7efdd7..98201750 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -26,7 +26,12 @@ export function parseBodySizeLimit(value: string | number | undefined | null): n } const trimmed = value.trim(); const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?$/i); - if (!match) return 1 * 1024 * 1024; + if (!match) { + console.warn( + `[vinext] Invalid bodySizeLimit value: "${value}". Expected a number or a string like "1mb", "500kb". Falling back to 1MB.`, + ); + return 1 * 1024 * 1024; + } const num = parseFloat(match[1]); const unit = (match[2] ?? "b").toLowerCase(); let bytes: number; diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index 0e794c7e..18650520 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -265,9 +265,18 @@ describe("parseBodySizeLimit", () => { expect(parseBodySizeLimit(null)).toBe(1 * 1024 * 1024); }); - it("returns default 1MB for invalid strings", () => { + it("returns default 1MB and warns for invalid strings", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(parseBodySizeLimit("invalid")).toBe(1 * 1024 * 1024); + expect(parseBodySizeLimit("10mbb")).toBe(1 * 1024 * 1024); + expect(warn).toHaveBeenCalledTimes(2); + expect(warn.mock.calls[0][0]).toContain("Invalid bodySizeLimit"); + warn.mockRestore(); + // empty string also falls through to the regex (no match), so it warns too + const warn2 = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(parseBodySizeLimit("")).toBe(1 * 1024 * 1024); + expect(warn2).toHaveBeenCalledTimes(1); + warn2.mockRestore(); }); it("parses terabyte strings", () => {