Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: Next.js validates that the parsed value is >= 1 and throws an error for negative numbers or zero (see packages/next/src/server/config.ts). This implementation silently accepts 0, negative numbers, and "0mb" as valid. Consider adding validation:

if (value <= 0) return 1 * 1024 * 1024;

or even throwing, to match Next.js behavior:

if (value === null || isNaN(value) || value < 1) {
  throw new Error(
    'Server Actions Size Limit must be a valid number or filesize format larger than 1MB: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit'
  );
}

Not a blocker, but something to consider for parity.

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}`);
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor inconsistency: numeric 0.5 passes (it's >= 1 as false wait — 0.5 < 1 is true, so this throws, which is correct). But a numeric 1.5 is accepted as-is (1.5 bytes), while a string "1.5b" gets Math.floor'd to 1. This is consistent with how Next.js's bytes library works (numeric passthrough, string parsing floors), so it's fine — just noting it for awareness.

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}`);
Copy link
Contributor

Choose a reason for hiding this comment

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

This bytes < 1 check after the switch can only trigger for string inputs like "0b" or "0.5b" (which floors to 0). For numeric inputs, the value < 1 check on line 24 already covers this.

The string "0mb" also produces bytes = 0 and throws here, which is good. But "0.001kb" produces Math.floor(0.001 * 1024) = 1 — which passes. This is fine in practice, just noting the edge for awareness.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: the bytes < 1 check catches "0b", "0mb", and "0.5b" (floors to 0), which is good — but none of these string-zero cases are tested. The test only covers numeric 0 and -1. Consider adding a quick assertion for string edge cases:

expect(() => parseBodySizeLimit("0b")).toThrow();
expect(() => parseBodySizeLimit("0mb")).toThrow();

Not blocking.

return bytes;
}

export interface HasCondition {
type: "header" | "cookie" | "query" | "host";
key: string;
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -286,6 +325,7 @@ export async function resolveNextConfig(
aliases: {},
allowedDevOrigins: [],
serverActionsAllowedOrigins: [],
serverActionsBodySizeLimit: 1 * 1024 * 1024,
};
}

Expand Down Expand Up @@ -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<string, unknown> | undefined;
const serverActionsConfig = experimental?.serverActions as
| Record<string, unknown>
Expand All @@ -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.
Expand Down Expand Up @@ -394,6 +435,7 @@ export async function resolveNextConfig(
aliases,
allowedDevOrigins,
serverActionsAllowedOrigins,
serverActionsBodySizeLimit,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 7 additions & 3 deletions packages/vinext/src/server/app-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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<string, string> = new Map();
Expand Down Expand Up @@ -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)};
Copy link
Contributor

Choose a reason for hiding this comment

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

JSON.stringify(bodySizeLimit) works correctly here since bodySizeLimit is always a number — it'll produce e.g. 10485760 in the generated code. Just noting for future readers that JSON.stringify on a number is equivalent to String() / template interpolation, so this is fine.


/**
* Read a request body as text with a size limit.
Expand Down
108 changes: 107 additions & 1 deletion tests/next-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
});
});
Loading