From bd1f6c1a3263ab848586a0882560899104744b13 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 19:26:23 -0700 Subject: [PATCH 1/5] perf: cache startup filesystem scans, optimize base64 and path normalization - Cache hasMdxFiles() result per root directory to avoid redundant recursive filesystem walks when config() fires per Vite environment - Cache resolvePostcssStringPlugins() result per project root to skip repeated existsSync checks across 17 PostCSS config file candidates - Replace byte-by-byte base64 encode/decode in KV cache handler with Buffer APIs for significantly faster serialization - Pass pre-normalized URL to nodeToWebRequest() in App Router prod server to eliminate redundant path normalization in the RSC handler --- .../vinext/src/cloudflare/kv-cache-handler.ts | 28 +- packages/vinext/src/index.ts | 40 ++- packages/vinext/src/server/prod-server.ts | 24 +- tests/kv-cache-handler.test.ts | 63 +++++ tests/node-to-web-request.test.ts | 90 ++++++ tests/startup-cache.test.ts | 262 ++++++++++++++++++ 6 files changed, 484 insertions(+), 23 deletions(-) create mode 100644 tests/node-to-web-request.test.ts create mode 100644 tests/startup-cache.test.ts diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index f0f58b6c..0ff9f32c 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -28,6 +28,8 @@ * } */ +import { Buffer } from "node:buffer"; + import type { CacheHandler, CacheHandlerValue, IncrementalCacheValue } from "../shims/cache.js"; import { getRequestExecutionContext } from "../shims/request-context.js"; @@ -79,6 +81,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}$/; + /** * Validate a cache tag. Returns null if invalid. * Note: `:` is rejected because TAG_PREFIX and ENTRY_PREFIX use `:` as a @@ -379,26 +384,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)) { + 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); } /** * 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 { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 215100e0..88b42918 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -284,9 +284,14 @@ const POSTCSS_CONFIG_FILES = [ * Returns the resolved PostCSS config object to inject into Vite's * `css.postcss`, or `undefined` if no resolution is needed. */ +/** Module-level cache for resolvePostcssStringPlugins — avoids re-scanning per Vite environment. */ +const _postcssCache = new Map(); + async function resolvePostcssStringPlugins( projectRoot: string, ): Promise<{ plugins: any[] } | undefined> { + if (_postcssCache.has(projectRoot)) return _postcssCache.get(projectRoot); + // Find the PostCSS config file let configPath: string | null = null; for (const name of POSTCSS_CONFIG_FILES) { @@ -296,7 +301,10 @@ async function resolvePostcssStringPlugins( break; } } - if (!configPath) return undefined; + if (!configPath) { + _postcssCache.set(projectRoot, undefined); + return undefined; + } // Load the config file let config: any; @@ -307,6 +315,7 @@ async function resolvePostcssStringPlugins( configPath.endsWith(".yml") ) { // JSON/YAML configs use object form — postcss-load-config handles these fine + _postcssCache.set(projectRoot, undefined); return undefined; } // For .postcssrc without extension, check if it's JSON @@ -314,6 +323,7 @@ async function resolvePostcssStringPlugins( const content = fs.readFileSync(configPath, "utf-8").trim(); if (content.startsWith("{")) { // JSON format — postcss-load-config handles object form + _postcssCache.set(projectRoot, undefined); return undefined; } } @@ -321,16 +331,23 @@ async function resolvePostcssStringPlugins( config = mod.default ?? mod; } catch { // If we can't load the config, let Vite/postcss-load-config handle it + _postcssCache.set(projectRoot, undefined); return undefined; } // 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)) { + _postcssCache.set(projectRoot, undefined); + 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) { + _postcssCache.set(projectRoot, undefined); + return undefined; + } // Resolve string plugin names to actual plugin functions const req = createRequire(path.join(projectRoot, "package.json")); @@ -356,7 +373,9 @@ async function resolvePostcssStringPlugins( }), ); - return { plugins: resolved }; + const result = { plugins: resolved }; + _postcssCache.set(projectRoot, result); + return result; } // Virtual module IDs for Pages Router production build @@ -3551,14 +3570,22 @@ function findFileWithExts( return null; } +/** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ +const _mdxScanCache = new Map(); + /** * Check if the project has .mdx files in app/ or pages/ directories. */ function hasMdxFiles(root: string, appDir: string | null, pagesDir: string | null): boolean { + if (_mdxScanCache.has(root)) return _mdxScanCache.get(root)!; 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(root, true); + return true; + } } + _mdxScanCache.set(root, false); return false; } @@ -3596,6 +3623,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 }; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index b13dee14..c86ef1c6 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -344,15 +344,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)) { @@ -549,9 +555,9 @@ async function startAppRouterServer(options: AppRouterServerOptions) { } 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)); @@ -578,7 +584,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) { @@ -611,8 +617,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("?")) : ""; + const normalizedUrl = pathname + qs; + // 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 diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index 5b4461fb..9cbc2b4d 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -358,6 +358,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 // ------------------------------------------------------------------------- diff --git a/tests/node-to-web-request.test.ts b/tests/node-to-web-request.test.ts new file mode 100644 index 00000000..6281a367 --- /dev/null +++ b/tests/node-to-web-request.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import type { IncomingMessage } from "node:http"; + +/** + * Tests for the nodeToWebRequest helper in prod-server.ts. + * + * Verifies the urlOverride parameter, which allows the App Router prod server + * to pass an already-normalized URL to avoid redundant normalization by the + * RSC handler downstream. + */ + +/** Minimal mock that satisfies nodeToWebRequest's usage of IncomingMessage (GET only). */ +function mockReq(overrides: Partial = {}): IncomingMessage { + return { + headers: { host: "localhost:3000" }, + url: "/", + method: "GET", + ...overrides, + } as unknown as IncomingMessage; +} + +describe("nodeToWebRequest", () => { + it("uses req.url when no urlOverride is provided", async () => { + const mod = await import("../packages/vinext/src/server/prod-server.js"); + const req = mockReq({ url: "/test/page?q=1" }); + + const webReq = mod.nodeToWebRequest(req); + + const parsed = new URL(webReq.url); + // Without override, the raw req.url is used as the path+query source + expect(parsed.pathname).toBe("/test/page"); + expect(parsed.searchParams.get("q")).toBe("1"); + }); + + it("uses urlOverride when provided instead of req.url", async () => { + const mod = await import("../packages/vinext/src/server/prod-server.js"); + const req = mockReq({ url: "/raw/unnormalized//path?q=1" }); + + // After normalization, the prod server would pass the clean URL + const webReq = mod.nodeToWebRequest(req, "/normalized/path?q=1"); + + const parsed = new URL(webReq.url); + expect(parsed.pathname).toBe("/normalized/path"); + expect(parsed.searchParams.get("q")).toBe("1"); + }); + + it("urlOverride replaces the entire path+query from req.url", async () => { + const mod = await import("../packages/vinext/src/server/prod-server.js"); + const req = mockReq({ url: "/original/path?old=param" }); + + const webReq = mod.nodeToWebRequest(req, "/overridden/path?new=param"); + + const parsed = new URL(webReq.url); + expect(parsed.pathname).toBe("/overridden/path"); + expect(parsed.searchParams.get("new")).toBe("param"); + // The old query param should NOT be present + expect(parsed.searchParams.has("old")).toBe(false); + }); + + it("preserves headers and host when urlOverride is used", async () => { + const mod = await import("../packages/vinext/src/server/prod-server.js"); + // GET request (no body needed, avoids Readable.toWeb mock issues) + const req = mockReq({ + url: "/raw/url", + method: "GET", + headers: { + host: "example.com", + "x-custom": "value", + }, + }); + + const webReq = mod.nodeToWebRequest(req, "/normalized/url"); + + expect(webReq.method).toBe("GET"); + expect(webReq.headers.get("x-custom")).toBe("value"); + const parsed = new URL(webReq.url); + expect(parsed.hostname).toBe("example.com"); + expect(parsed.pathname).toBe("/normalized/url"); + }); + + it("uses req.url fallback '/' when req.url is undefined and no override", async () => { + const mod = await import("../packages/vinext/src/server/prod-server.js"); + const req = mockReq({ url: undefined }); + + const webReq = mod.nodeToWebRequest(req); + + const parsed = new URL(webReq.url); + expect(parsed.pathname).toBe("/"); + }); +}); diff --git a/tests/startup-cache.test.ts b/tests/startup-cache.test.ts new file mode 100644 index 00000000..659de4ca --- /dev/null +++ b/tests/startup-cache.test.ts @@ -0,0 +1,262 @@ +/** + * Tests for startup-time caching of expensive filesystem operations. + * + * Task 1a: hasMdxFiles() should cache its result per root directory. + * Task 1b: resolvePostcssStringPlugins() should cache its result per project root. + */ +import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// --------------------------------------------------------------------------- +// Task 1a: hasMdxFiles caching +// --------------------------------------------------------------------------- + +describe("hasMdxFiles caching", () => { + let hasMdxFiles: (typeof import("../packages/vinext/src/index.js"))["_hasMdxFiles"]; + let mdxScanCache: (typeof import("../packages/vinext/src/index.js"))["_mdxScanCache"]; + + beforeAll(async () => { + const mod = await import("../packages/vinext/src/index.js"); + hasMdxFiles = mod._hasMdxFiles; + mdxScanCache = mod._mdxScanCache; + }); + + beforeEach(() => { + // Clear the cache between tests to ensure isolation + mdxScanCache.clear(); + }); + + it("returns true when .mdx files exist in appDir", async () => { + const fsp = await import("node:fs/promises"); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-")); + const appDir = path.join(tmpDir, "app"); + await fsp.mkdir(appDir, { recursive: true }); + await fsp.writeFile(path.join(appDir, "page.mdx"), "# Hello"); + + try { + const result = hasMdxFiles(tmpDir, appDir, null); + expect(result).toBe(true); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("returns false when no .mdx files exist", async () => { + const fsp = await import("node:fs/promises"); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-")); + const appDir = path.join(tmpDir, "app"); + await fsp.mkdir(appDir, { recursive: true }); + await fsp.writeFile(path.join(appDir, "page.tsx"), "export default () => null"); + + try { + const result = hasMdxFiles(tmpDir, appDir, null); + expect(result).toBe(false); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("does not rescan the filesystem on second call with same root", async () => { + const fsp = await import("node:fs/promises"); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-cache-")); + const appDir = path.join(tmpDir, "app"); + await fsp.mkdir(appDir, { recursive: true }); + await fsp.writeFile(path.join(appDir, "page.mdx"), "# Hello"); + + try { + // First call — populates cache + const result1 = hasMdxFiles(tmpDir, appDir, null); + expect(result1).toBe(true); + + // Spy on readdirSync after first call + const readdirSpy = vi.spyOn(fs, "readdirSync"); + + // Second call — should use cache, no filesystem reads + const result2 = hasMdxFiles(tmpDir, appDir, null); + expect(result2).toBe(true); + expect(readdirSpy).not.toHaveBeenCalled(); + + readdirSpy.mockRestore(); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("caches false results too (no redundant scans for missing mdx)", async () => { + const fsp = await import("node:fs/promises"); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-nomdx-")); + const appDir = path.join(tmpDir, "app"); + await fsp.mkdir(appDir, { recursive: true }); + await fsp.writeFile(path.join(appDir, "page.tsx"), "export default () => null"); + + try { + // First call + const result1 = hasMdxFiles(tmpDir, appDir, null); + expect(result1).toBe(false); + + const readdirSpy = vi.spyOn(fs, "readdirSync"); + + // Second call — should hit cache + const result2 = hasMdxFiles(tmpDir, appDir, null); + expect(result2).toBe(false); + expect(readdirSpy).not.toHaveBeenCalled(); + + readdirSpy.mockRestore(); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("caches independently per root directory", async () => { + const fsp = await import("node:fs/promises"); + const tmpDir1 = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-root1-")); + const tmpDir2 = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-root2-")); + const appDir1 = path.join(tmpDir1, "app"); + const appDir2 = path.join(tmpDir2, "app"); + await fsp.mkdir(appDir1, { recursive: true }); + await fsp.mkdir(appDir2, { recursive: true }); + await fsp.writeFile(path.join(appDir1, "page.mdx"), "# Hello"); + await fsp.writeFile(path.join(appDir2, "page.tsx"), "export default () => null"); + + try { + expect(hasMdxFiles(tmpDir1, appDir1, null)).toBe(true); + expect(hasMdxFiles(tmpDir2, appDir2, null)).toBe(false); + } finally { + await fsp.rm(tmpDir1, { recursive: true, force: true }); + await fsp.rm(tmpDir2, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Task 1b: resolvePostcssStringPlugins caching +// --------------------------------------------------------------------------- + +describe("resolvePostcssStringPlugins caching", () => { + let resolvePostcssStringPlugins: (typeof import("../packages/vinext/src/index.js"))["_resolvePostcssStringPlugins"]; + let postcssCache: (typeof import("../packages/vinext/src/index.js"))["_postcssCache"]; + + beforeAll(async () => { + const mod = await import("../packages/vinext/src/index.js"); + resolvePostcssStringPlugins = mod._resolvePostcssStringPlugins; + postcssCache = mod._postcssCache; + }); + + beforeEach(() => { + postcssCache.clear(); + }); + + async function createTmpProject(configFileName: string, configContent: string): Promise { + const fsp = await import("node:fs/promises"); + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-cache-")); + + // Create a mock PostCSS plugin + const pluginDir = path.join(dir, "node_modules", "mock-postcss-plugin"); + await fsp.mkdir(pluginDir, { recursive: true }); + await fsp.writeFile( + path.join(pluginDir, "index.js"), + `module.exports = function mockPlugin(opts) { + return { postcssPlugin: "mock-postcss-plugin", Once(root) {} }; +}; +module.exports.postcss = true;`, + ); + await fsp.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ name: "mock-postcss-plugin", version: "1.0.0", main: "index.js" }), + ); + + await fsp.writeFile(path.join(dir, configFileName), configContent); + return dir; + } + + async function cleanupDir(dir: string) { + const fsp = await import("node:fs/promises"); + await fsp.rm(dir, { recursive: true, force: true }).catch(() => {}); + } + + it("does not re-scan filesystem on second call with same projectRoot", async () => { + const dir = await createTmpProject( + "postcss.config.cjs", + `module.exports = { plugins: ["mock-postcss-plugin"] };`, + ); + + try { + // First call — populates cache + const result1 = await resolvePostcssStringPlugins(dir); + expect(result1).toBeDefined(); + expect(result1!.plugins).toHaveLength(1); + + // Spy on existsSync after first call + const existsSpy = vi.spyOn(fs, "existsSync"); + + // Second call — should use cache + const result2 = await resolvePostcssStringPlugins(dir); + expect(result2).toBeDefined(); + expect(result2!.plugins).toHaveLength(1); + expect(existsSpy).not.toHaveBeenCalled(); + + existsSpy.mockRestore(); + } finally { + await cleanupDir(dir); + } + }); + + it("caches undefined results (no config file)", async () => { + const fsp = await import("node:fs/promises"); + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-noconf-")); + + try { + // First call — no config, returns undefined + const result1 = await resolvePostcssStringPlugins(dir); + expect(result1).toBeUndefined(); + + const existsSpy = vi.spyOn(fs, "existsSync"); + + // Second call — should use cache + const result2 = await resolvePostcssStringPlugins(dir); + expect(result2).toBeUndefined(); + expect(existsSpy).not.toHaveBeenCalled(); + + existsSpy.mockRestore(); + } finally { + await cleanupDir(dir); + } + }); + + it("returns the exact same object reference from cache", async () => { + const dir = await createTmpProject( + "postcss.config.cjs", + `module.exports = { plugins: ["mock-postcss-plugin"] };`, + ); + + try { + const result1 = await resolvePostcssStringPlugins(dir); + const result2 = await resolvePostcssStringPlugins(dir); + // Same reference — not re-computed + expect(result1).toBe(result2); + } finally { + await cleanupDir(dir); + } + }); + + it("caches independently per project root", async () => { + const dir1 = await createTmpProject( + "postcss.config.cjs", + `module.exports = { plugins: ["mock-postcss-plugin"] };`, + ); + const fsp = await import("node:fs/promises"); + const dir2 = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-noconf2-")); + + try { + const result1 = await resolvePostcssStringPlugins(dir1); + const result2 = await resolvePostcssStringPlugins(dir2); + expect(result1).toBeDefined(); + expect(result2).toBeUndefined(); + } finally { + await cleanupDir(dir1); + await cleanupDir(dir2); + } + }); +}); From c0b75a5e9917a095f426d3b479a139cfc6949973 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 21:04:33 -0700 Subject: [PATCH 2/5] fix: validate base64 structural length and improve test spy cleanup Add length % 4 check to base64ToArrayBuffer to reject structurally invalid base64 strings that pass the character-set regex but produce empty/truncated buffers via Buffer.from(). Replace manual mockRestore() calls with afterEach(vi.restoreAllMocks) to prevent spy leaks on assertion failure. --- .../vinext/src/cloudflare/kv-cache-handler.ts | 2 +- tests/startup-cache.test.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index 0ff9f32c..7b325981 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -393,7 +393,7 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string { * since Buffer.from(str, "base64") silently ignores invalid characters. */ function base64ToArrayBuffer(base64: string): ArrayBuffer { - if (!BASE64_RE.test(base64)) { + if (!BASE64_RE.test(base64) || base64.length % 4 !== 0) { throw new Error("Invalid base64 string"); } const buf = Buffer.from(base64, "base64"); diff --git a/tests/startup-cache.test.ts b/tests/startup-cache.test.ts index 659de4ca..1d6e854b 100644 --- a/tests/startup-cache.test.ts +++ b/tests/startup-cache.test.ts @@ -4,7 +4,7 @@ * Task 1a: hasMdxFiles() should cache its result per root directory. * Task 1b: resolvePostcssStringPlugins() should cache its result per project root. */ -import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; +import { afterEach, describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import fs from "node:fs"; import path from "node:path"; import os from "node:os"; @@ -28,6 +28,10 @@ describe("hasMdxFiles caching", () => { mdxScanCache.clear(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("returns true when .mdx files exist in appDir", async () => { const fsp = await import("node:fs/promises"); const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-")); @@ -77,8 +81,6 @@ describe("hasMdxFiles caching", () => { const result2 = hasMdxFiles(tmpDir, appDir, null); expect(result2).toBe(true); expect(readdirSpy).not.toHaveBeenCalled(); - - readdirSpy.mockRestore(); } finally { await fsp.rm(tmpDir, { recursive: true, force: true }); } @@ -102,8 +104,6 @@ describe("hasMdxFiles caching", () => { const result2 = hasMdxFiles(tmpDir, appDir, null); expect(result2).toBe(false); expect(readdirSpy).not.toHaveBeenCalled(); - - readdirSpy.mockRestore(); } finally { await fsp.rm(tmpDir, { recursive: true, force: true }); } @@ -148,6 +148,10 @@ describe("resolvePostcssStringPlugins caching", () => { postcssCache.clear(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + async function createTmpProject(configFileName: string, configContent: string): Promise { const fsp = await import("node:fs/promises"); const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-cache-")); @@ -196,8 +200,6 @@ module.exports.postcss = true;`, expect(result2).toBeDefined(); expect(result2!.plugins).toHaveLength(1); expect(existsSpy).not.toHaveBeenCalled(); - - existsSpy.mockRestore(); } finally { await cleanupDir(dir); } @@ -218,8 +220,6 @@ module.exports.postcss = true;`, const result2 = await resolvePostcssStringPlugins(dir); expect(result2).toBeUndefined(); expect(existsSpy).not.toHaveBeenCalled(); - - existsSpy.mockRestore(); } finally { await cleanupDir(dir); } From 7385c3bbdb852515959fa49e3268ac2d3a1787a5 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 13:42:28 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20bonk=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20composite=20mdx=20cache=20key,=20postcss=20comme?= =?UTF-8?q?nt=20order,=20hoist=20test=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vinext/src/index.ts | 13 +++++++------ tests/node-to-web-request.test.ts | 32 ++++++++++++++++--------------- tests/startup-cache.test.ts | 2 +- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 88b42918..c86da651 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -272,6 +272,9 @@ const POSTCSS_CONFIG_FILES = [ ".postcssrc.yml", ]; +/** Module-level cache for resolvePostcssStringPlugins — avoids re-scanning per Vite environment. */ +const _postcssCache = new Map(); + /** * Resolve PostCSS string plugin names in a project's PostCSS config. * @@ -284,9 +287,6 @@ const POSTCSS_CONFIG_FILES = [ * Returns the resolved PostCSS config object to inject into Vite's * `css.postcss`, or `undefined` if no resolution is needed. */ -/** Module-level cache for resolvePostcssStringPlugins — avoids re-scanning per Vite environment. */ -const _postcssCache = new Map(); - async function resolvePostcssStringPlugins( projectRoot: string, ): Promise<{ plugins: any[] } | undefined> { @@ -3577,15 +3577,16 @@ const _mdxScanCache = new Map(); * Check if the project has .mdx files in app/ or pages/ directories. */ function hasMdxFiles(root: string, appDir: string | null, pagesDir: string | null): boolean { - if (_mdxScanCache.has(root)) return _mdxScanCache.get(root)!; + 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)) { - _mdxScanCache.set(root, true); + _mdxScanCache.set(cacheKey, true); return true; } } - _mdxScanCache.set(root, false); + _mdxScanCache.set(cacheKey, false); return false; } diff --git a/tests/node-to-web-request.test.ts b/tests/node-to-web-request.test.ts index 6281a367..ce360089 100644 --- a/tests/node-to-web-request.test.ts +++ b/tests/node-to-web-request.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeAll } from "vitest"; import type { IncomingMessage } from "node:http"; /** @@ -20,11 +20,17 @@ function mockReq(overrides: Partial = {}): IncomingMessage { } describe("nodeToWebRequest", () => { - it("uses req.url when no urlOverride is provided", async () => { + let nodeToWebRequest: (typeof import("../packages/vinext/src/server/prod-server.js"))["nodeToWebRequest"]; + + beforeAll(async () => { const mod = await import("../packages/vinext/src/server/prod-server.js"); + nodeToWebRequest = mod.nodeToWebRequest; + }); + + it("uses req.url when no urlOverride is provided", () => { const req = mockReq({ url: "/test/page?q=1" }); - const webReq = mod.nodeToWebRequest(req); + const webReq = nodeToWebRequest(req); const parsed = new URL(webReq.url); // Without override, the raw req.url is used as the path+query source @@ -32,23 +38,21 @@ describe("nodeToWebRequest", () => { expect(parsed.searchParams.get("q")).toBe("1"); }); - it("uses urlOverride when provided instead of req.url", async () => { - const mod = await import("../packages/vinext/src/server/prod-server.js"); + it("uses urlOverride when provided instead of req.url", () => { const req = mockReq({ url: "/raw/unnormalized//path?q=1" }); // After normalization, the prod server would pass the clean URL - const webReq = mod.nodeToWebRequest(req, "/normalized/path?q=1"); + const webReq = nodeToWebRequest(req, "/normalized/path?q=1"); const parsed = new URL(webReq.url); expect(parsed.pathname).toBe("/normalized/path"); expect(parsed.searchParams.get("q")).toBe("1"); }); - it("urlOverride replaces the entire path+query from req.url", async () => { - const mod = await import("../packages/vinext/src/server/prod-server.js"); + it("urlOverride replaces the entire path+query from req.url", () => { const req = mockReq({ url: "/original/path?old=param" }); - const webReq = mod.nodeToWebRequest(req, "/overridden/path?new=param"); + const webReq = nodeToWebRequest(req, "/overridden/path?new=param"); const parsed = new URL(webReq.url); expect(parsed.pathname).toBe("/overridden/path"); @@ -57,8 +61,7 @@ describe("nodeToWebRequest", () => { expect(parsed.searchParams.has("old")).toBe(false); }); - it("preserves headers and host when urlOverride is used", async () => { - const mod = await import("../packages/vinext/src/server/prod-server.js"); + it("preserves headers and host when urlOverride is used", () => { // GET request (no body needed, avoids Readable.toWeb mock issues) const req = mockReq({ url: "/raw/url", @@ -69,7 +72,7 @@ describe("nodeToWebRequest", () => { }, }); - const webReq = mod.nodeToWebRequest(req, "/normalized/url"); + const webReq = nodeToWebRequest(req, "/normalized/url"); expect(webReq.method).toBe("GET"); expect(webReq.headers.get("x-custom")).toBe("value"); @@ -78,11 +81,10 @@ describe("nodeToWebRequest", () => { expect(parsed.pathname).toBe("/normalized/url"); }); - it("uses req.url fallback '/' when req.url is undefined and no override", async () => { - const mod = await import("../packages/vinext/src/server/prod-server.js"); + it("uses req.url fallback '/' when req.url is undefined and no override", () => { const req = mockReq({ url: undefined }); - const webReq = mod.nodeToWebRequest(req); + const webReq = nodeToWebRequest(req); const parsed = new URL(webReq.url); expect(parsed.pathname).toBe("/"); diff --git a/tests/startup-cache.test.ts b/tests/startup-cache.test.ts index 1d6e854b..0e9ccb40 100644 --- a/tests/startup-cache.test.ts +++ b/tests/startup-cache.test.ts @@ -1,7 +1,7 @@ /** * Tests for startup-time caching of expensive filesystem operations. * - * Task 1a: hasMdxFiles() should cache its result per root directory. + * Task 1a: hasMdxFiles() should cache its result per (root, appDir, pagesDir) combination. * Task 1b: resolvePostcssStringPlugins() should cache its result per project root. */ import { afterEach, describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; From 6a8a891ceca505818300abbf138da8177f64f49d Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 13:59:56 +0000 Subject: [PATCH 4/5] fix: cache Promise in resolvePostcssStringPlugins to prevent concurrent scan race; add POST urlOverride test --- packages/vinext/src/index.ts | 31 ++++++++++++++++++------------- tests/node-to-web-request.test.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 88ef41ae..8b77dd03 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -272,8 +272,12 @@ const POSTCSS_CONFIG_FILES = [ ".postcssrc.yml", ]; -/** Module-level cache for resolvePostcssStringPlugins — avoids re-scanning per Vite environment. */ -const _postcssCache = new Map(); +/** + * 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>(); /** * Resolve PostCSS string plugin names in a project's PostCSS config. @@ -287,10 +291,19 @@ const _postcssCache = new Map(); * 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> { - if (_postcssCache.has(projectRoot)) return _postcssCache.get(projectRoot); // Find the PostCSS config file let configPath: string | null = null; @@ -302,7 +315,6 @@ async function resolvePostcssStringPlugins( } } if (!configPath) { - _postcssCache.set(projectRoot, undefined); return undefined; } @@ -315,7 +327,6 @@ async function resolvePostcssStringPlugins( configPath.endsWith(".yml") ) { // JSON/YAML configs use object form — postcss-load-config handles these fine - _postcssCache.set(projectRoot, undefined); return undefined; } // For .postcssrc without extension, check if it's JSON @@ -323,7 +334,6 @@ async function resolvePostcssStringPlugins( const content = fs.readFileSync(configPath, "utf-8").trim(); if (content.startsWith("{")) { // JSON format — postcss-load-config handles object form - _postcssCache.set(projectRoot, undefined); return undefined; } } @@ -331,21 +341,18 @@ async function resolvePostcssStringPlugins( config = mod.default ?? mod; } catch { // If we can't load the config, let Vite/postcss-load-config handle it - _postcssCache.set(projectRoot, undefined); return undefined; } // Only process array-form plugins that contain string entries // (either bare strings or tuple form ["plugin-name", { options }]) if (!config || !Array.isArray(config.plugins)) { - _postcssCache.set(projectRoot, undefined); return undefined; } const hasStringPlugins = config.plugins.some( (p: any) => typeof p === "string" || (Array.isArray(p) && typeof p[0] === "string"), ); if (!hasStringPlugins) { - _postcssCache.set(projectRoot, undefined); return undefined; } @@ -373,9 +380,7 @@ async function resolvePostcssStringPlugins( }), ); - const result = { plugins: resolved }; - _postcssCache.set(projectRoot, result); - return result; + return { plugins: resolved }; } // Virtual module IDs for Pages Router production build diff --git a/tests/node-to-web-request.test.ts b/tests/node-to-web-request.test.ts index ce360089..e77760a5 100644 --- a/tests/node-to-web-request.test.ts +++ b/tests/node-to-web-request.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll } from "vitest"; import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; /** * Tests for the nodeToWebRequest helper in prod-server.ts. @@ -9,7 +10,7 @@ import type { IncomingMessage } from "node:http"; * RSC handler downstream. */ -/** Minimal mock that satisfies nodeToWebRequest's usage of IncomingMessage (GET only). */ +/** Minimal mock that satisfies nodeToWebRequest's usage of IncomingMessage. */ function mockReq(overrides: Partial = {}): IncomingMessage { return { headers: { host: "localhost:3000" }, @@ -62,7 +63,6 @@ describe("nodeToWebRequest", () => { }); it("preserves headers and host when urlOverride is used", () => { - // GET request (no body needed, avoids Readable.toWeb mock issues) const req = mockReq({ url: "/raw/url", method: "GET", @@ -89,4 +89,26 @@ describe("nodeToWebRequest", () => { const parsed = new URL(webReq.url); expect(parsed.pathname).toBe("/"); }); + + it("urlOverride works for POST requests without affecting the body stream", async () => { + const bodyContent = JSON.stringify({ hello: "world" }); + // Build a real Readable and graft the IncomingMessage properties onto it + // so that Readable.toWeb() (used internally for POST bodies) works correctly. + const readable = Readable.from([Buffer.from(bodyContent)]) as unknown as IncomingMessage; + readable.headers = { host: "localhost:3000", "content-type": "application/json" }; + readable.url = "/raw/unnormalized//api/submit"; + readable.method = "POST"; + + const webReq = nodeToWebRequest(readable, "/api/submit"); + + // URL is overridden correctly + const parsed = new URL(webReq.url); + expect(parsed.pathname).toBe("/api/submit"); + expect(webReq.method).toBe("POST"); + + // Body stream is present and readable + expect(webReq.body).not.toBeNull(); + const text = await webReq.text(); + expect(text).toBe(bodyContent); + }); }); From 8f2ec4ae8419f145faddbdb72e48635fb3709947 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 14:21:06 +0000 Subject: [PATCH 5/5] fmt --- packages/vinext/src/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 8b77dd03..8283b7b9 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -291,9 +291,7 @@ const _postcssCache = new Map>() * Returns the resolved PostCSS config object to inject into Vite's * `css.postcss`, or `undefined` if no resolution is needed. */ -function resolvePostcssStringPlugins( - projectRoot: string, -): Promise<{ plugins: any[] } | undefined> { +function resolvePostcssStringPlugins(projectRoot: string): Promise<{ plugins: any[] } | undefined> { if (_postcssCache.has(projectRoot)) return _postcssCache.get(projectRoot)!; const promise = _resolvePostcssStringPluginsUncached(projectRoot); @@ -304,7 +302,6 @@ function resolvePostcssStringPlugins( 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) {