Skip to content
Open
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
28 changes: 16 additions & 12 deletions packages/vinext/src/cloudflare/kv-cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
* }
*/

import { Buffer } from "node:buffer";

import type { CacheHandler, CacheHandlerValue, IncrementalCacheValue } from "../shims/cache.js";
import { getRequestExecutionContext, type ExecutionContextLike } from "../shims/request-context.js";

Expand Down Expand Up @@ -66,6 +68,9 @@ const ENTRY_PREFIX = "cache:";
/** Max tag length to prevent KV key abuse. */
const MAX_TAG_LENGTH = 256;

/** Matches a valid base64 string (standard alphabet with optional padding). */
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
Copy link
Contributor

Choose a reason for hiding this comment

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

This regex matches the empty string ("") since * and {0,2} both allow zero occurrences. Buffer.from("", "base64") produces an empty Buffer, which would decode to a zero-length ArrayBuffer — probably not what you want for a cache entry, and the old atob("") also returned "" so this is not a regression, but it's worth noting.

Also, as Copilot flagged: the regex validates the alphabet but not structural validity. For example, "A" (length 1, which is % 4 === 1) is structurally invalid base64 — but you already handle that with the base64.length % 4 !== 0 check on line 396, so this is fine. The regex + length check together are sufficient for correctness.

No change needed — just confirming the validation is adequate as-is.


Comment on lines +71 to +73
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

BASE64_RE only checks the alphabet/padding characters but doesn’t enforce base64 structural validity (e.g., length multiple of 4 / disallowing length % 4 === 1). Because Buffer.from(str, "base64") is permissive, some malformed inputs made of valid characters can decode without throwing, which would bypass the “corrupted entry => cache miss” logic. Consider tightening validation (e.g., a stricter regex or explicit length/padding checks) so invalid base64 reliably triggers the safeBase64ToArrayBuffer() null path.

Copilot uses AI. Check for mistakes.
/**
* Validate a cache tag. Returns null if invalid.
* Note: `:` is rejected because TAG_PREFIX and ENTRY_PREFIX use `:` as a
Expand Down Expand Up @@ -435,26 +440,25 @@ function restoreArrayBuffers(value: IncrementalCacheValue): boolean {
}

function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
return Buffer.from(buffer).toString("base64");
}

/**
* Decode a base64 string to an ArrayBuffer.
* Validates the input against the base64 alphabet before decoding,
* since Buffer.from(str, "base64") silently ignores invalid characters.
*/
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
if (!BASE64_RE.test(base64) || base64.length % 4 !== 0) {
throw new Error("Invalid base64 string");
}
return bytes.buffer;
const buf = Buffer.from(base64, "base64");
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
Copy link
Contributor

Choose a reason for hiding this comment

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

Good use of buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) — this correctly handles the case where Buffer.from(str, "base64") returns a Buffer that shares the underlying ArrayBuffer pool with a different offset/length. Without the slice, you'd get the wrong data.

This is a common Node.js Buffer footgun, nice to see it handled correctly.

}

/**
* Safely decode base64 to ArrayBuffer. Returns null on invalid input
* instead of throwing a DOMException.
* instead of throwing.
*/
function safeBase64ToArrayBuffer(base64: string): ArrayBuffer | null {
try {
Expand Down
46 changes: 41 additions & 5 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,13 @@ const POSTCSS_CONFIG_FILES = [
".postcssrc.yml",
];

/**
* Module-level cache for resolvePostcssStringPlugins — avoids re-scanning per Vite environment.
* Stores the Promise itself so concurrent calls (RSC/SSR/Client config() hooks firing in
* parallel) all await the same in-flight scan rather than each starting their own.
*/
const _postcssCache = new Map<string, Promise<{ plugins: any[] } | undefined>>();

/**
* Resolve PostCSS string plugin names in a project's PostCSS config.
*
Expand All @@ -284,9 +291,20 @@ const POSTCSS_CONFIG_FILES = [
* Returns the resolved PostCSS config object to inject into Vite's
* `css.postcss`, or `undefined` if no resolution is needed.
*/
async function resolvePostcssStringPlugins(
function resolvePostcssStringPlugins(
projectRoot: string,
): Promise<{ plugins: any[] } | undefined> {
if (_postcssCache.has(projectRoot)) return _postcssCache.get(projectRoot)!;

const promise = _resolvePostcssStringPluginsUncached(projectRoot);
_postcssCache.set(projectRoot, promise);
return promise;
}

async function _resolvePostcssStringPluginsUncached(
projectRoot: string,
): Promise<{ plugins: any[] } | undefined> {

// Find the PostCSS config file
let configPath: string | null = null;
for (const name of POSTCSS_CONFIG_FILES) {
Expand All @@ -296,7 +314,9 @@ async function resolvePostcssStringPlugins(
break;
}
}
if (!configPath) return undefined;
if (!configPath) {
return undefined;
}

// Load the config file
let config: any;
Expand Down Expand Up @@ -326,11 +346,15 @@ async function resolvePostcssStringPlugins(

// Only process array-form plugins that contain string entries
// (either bare strings or tuple form ["plugin-name", { options }])
if (!config || !Array.isArray(config.plugins)) return undefined;
if (!config || !Array.isArray(config.plugins)) {
return undefined;
}
const hasStringPlugins = config.plugins.some(
(p: any) => typeof p === "string" || (Array.isArray(p) && typeof p[0] === "string"),
);
if (!hasStringPlugins) return undefined;
if (!hasStringPlugins) {
return undefined;
}

// Resolve string plugin names to actual plugin functions
const req = createRequire(path.join(projectRoot, "package.json"));
Expand Down Expand Up @@ -3547,14 +3571,23 @@ function findFileWithExts(
return null;
}

/** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */
const _mdxScanCache = new Map<string, boolean>();

/**
* Check if the project has .mdx files in app/ or pages/ directories.
*/
function hasMdxFiles(root: string, appDir: string | null, pagesDir: string | null): boolean {
const cacheKey = `${root}\0${appDir ?? ""}\0${pagesDir ?? ""}`;
if (_mdxScanCache.has(cacheKey)) return _mdxScanCache.get(cacheKey)!;
const dirs = [appDir, pagesDir].filter(Boolean) as string[];
for (const dir of dirs) {
if (fs.existsSync(dir) && scanDirForMdx(dir)) return true;
if (fs.existsSync(dir) && scanDirForMdx(dir)) {
_mdxScanCache.set(cacheKey, true);
return true;
}
}
_mdxScanCache.set(cacheKey, false);
return false;
}

Expand Down Expand Up @@ -3592,6 +3625,9 @@ export type { NextConfig } from "./config/next-config.js";
export { clientManualChunks, clientOutputConfig, clientTreeshakeConfig, computeLazyChunks };
export { augmentSsrManifestFromBundle as _augmentSsrManifestFromBundle };
export { resolvePostcssStringPlugins as _resolvePostcssStringPlugins };
export { _postcssCache };
export { hasMdxFiles as _hasMdxFiles };
export { _mdxScanCache };
export { parseStaticObjectLiteral as _parseStaticObjectLiteral };
export { stripServerExports as _stripServerExports };
export { asyncHooksStubPlugin as _asyncHooksStubPlugin };
24 changes: 18 additions & 6 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,15 +357,21 @@ const trustProxy = process.env.VINEXT_TRUST_PROXY === "1" || trustedHosts.size >

/**
* Convert a Node.js IncomingMessage to a Web Request object.
*
* When `urlOverride` is provided, it is used as the path + query string
* instead of `req.url`. This avoids redundant path normalization when the
* caller has already decoded and normalized the pathname (e.g. the App
* Router prod server normalizes before static-asset lookup, and can pass
* the result here so the downstream RSC handler doesn't re-normalize).
*/
function nodeToWebRequest(req: IncomingMessage): Request {
function nodeToWebRequest(req: IncomingMessage, urlOverride?: string): Request {
const rawProto = trustProxy
? (req.headers["x-forwarded-proto"] as string)?.split(",")[0]?.trim()
: undefined;
const proto = rawProto === "https" || rawProto === "http" ? rawProto : "http";
const host = resolveHost(req, "localhost");
const origin = `${proto}://${host}`;
const url = new URL(req.url ?? "/", origin);
const url = new URL(urlOverride ?? req.url ?? "/", origin);

const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
Expand Down Expand Up @@ -601,9 +607,9 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
const rscHandler = resolveAppRouterHandler(rscModule.default);

const server = createServer(async (req, res) => {
const url = req.url ?? "/";
const rawUrl = req.url ?? "/";
// Normalize backslashes (browsers treat /\ as //), then decode and normalize path.
const rawPathname = url.split("?")[0].replaceAll("\\", "/");
const rawPathname = rawUrl.split("?")[0].replaceAll("\\", "/");
let pathname: string;
try {
pathname = normalizePath(decodeURIComponent(rawPathname));
Expand All @@ -630,7 +636,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
// Image optimization passthrough (Node.js prod server has no Images binding;
// serves the original file with cache headers and security headers)
if (pathname === IMAGE_OPTIMIZATION_PATH) {
const parsedUrl = new URL(url, "http://localhost");
const parsedUrl = new URL(rawUrl, "http://localhost");
const defaultAllowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
const params = parseImageParams(parsedUrl, defaultAllowedWidths);
if (!params) {
Expand Down Expand Up @@ -663,8 +669,14 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
}

try {
// Build the normalized URL (pathname + original query string) so the
// RSC handler receives an already-canonical path and doesn't need to
// re-normalize. This deduplicates the normalizePath work done above.
const qs = rawUrl.includes("?") ? rawUrl.slice(rawUrl.indexOf("?")) : "";
Copy link
Contributor

Choose a reason for hiding this comment

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

Edge case: rawUrl could contain multiple ? characters. The query string is extracted correctly here because indexOf("?") finds the first ?, and everything after (including subsequent ? characters) is preserved via slice. This is correct behavior per RFC 3986 §3.4 — just confirming for future readers that this is intentional.

One thing to double-check: if pathname has been decoded via decodeURIComponent (line 615), but qs is taken raw from rawUrl, then the resulting normalizedUrl has a decoded path but percent-encoded query string. This is fine for new URL() on line 374, which handles both. No change needed.

const normalizedUrl = pathname + qs;
Comment on lines +675 to +676
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: This query-string extraction works correctly, but note that if rawUrl contains a ? in a percent-encoded segment (unlikely from a real browser, but possible from a malicious client), indexOf("?") will find the first ? regardless. Since rawUrl comes from req.url which is already the raw request target, this should be fine — just calling it out.

The approach is sound and avoids the cost of new URL() just to split path/query.


// Convert Node.js request to Web Request and call the RSC handler
const request = nodeToWebRequest(req);
const request = nodeToWebRequest(req, normalizedUrl);
const response = await rscHandler(request);

// Stream the Web Response back to the Node.js response
Expand Down
63 changes: 63 additions & 0 deletions tests/kv-cache-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,69 @@ describe("KVCacheHandler", () => {
});
});

// -------------------------------------------------------------------------
// ArrayBuffer base64 roundtrip edge cases
// -------------------------------------------------------------------------

describe("ArrayBuffer base64 roundtrip edge cases", () => {
it("round-trips a large buffer (1 MiB)", async () => {
const size = 1024 * 1024; // 1 MiB
const original = new Uint8Array(size);
for (let i = 0; i < size; i++) {
original[i] = i % 256;
}

await handler.set("large-buf", {
kind: "APP_ROUTE",
body: original.buffer as ArrayBuffer,
status: 200,
headers: { "content-type": "application/octet-stream" },
});

const result = await handler.get("large-buf");
expect(result).not.toBeNull();
const restored = new Uint8Array((result!.value as any).body);
expect(restored.byteLength).toBe(size);
// Verify every byte survived the roundtrip
expect(restored).toEqual(original);
});

it("round-trips a buffer containing null bytes", async () => {
const original = new Uint8Array([0, 0, 0, 72, 101, 108, 108, 111, 0, 0, 0]);

await handler.set("null-bytes", {
kind: "APP_ROUTE",
body: original.buffer as ArrayBuffer,
status: 200,
headers: {},
});

const result = await handler.get("null-bytes");
expect(result).not.toBeNull();
const restored = new Uint8Array((result!.value as any).body);
expect(restored).toEqual(original);
});

it("round-trips a buffer with all 256 byte values", async () => {
const original = new Uint8Array(256);
for (let i = 0; i < 256; i++) {
original[i] = i;
}

await handler.set("all-bytes", {
kind: "APP_ROUTE",
body: original.buffer as ArrayBuffer,
status: 200,
headers: {},
});

const result = await handler.get("all-bytes");
expect(result).not.toBeNull();
const restored = new Uint8Array((result!.value as any).body);
expect(restored).toEqual(original);
});
});

// -------------------------------------------------------------------------
// ctx.waitUntil registration
// -------------------------------------------------------------------------
Expand Down
Loading
Loading