diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index d8b800c4..98201750 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -11,6 +11,43 @@ 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", 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") { + 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) { + 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; + switch (unit) { + 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 { type: "header" | "cookie" | "query" | "host"; key: string; @@ -169,6 +206,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 +325,7 @@ export async function resolveNextConfig( aliases: {}, allowedDevOrigins: [], serverActionsAllowedOrigins: [], + serverActionsBodySizeLimit: 1 * 1024 * 1024, }; } @@ -334,7 +374,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 +384,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 +435,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..18650520 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,109 @@ 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 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", () => { + 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(); + }); +}); + +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); + }); +});