From 68993c75e1125297cfe0008e0001610b0a873ede Mon Sep 17 00:00:00 2001 From: Jose Montes de Oca Date: Fri, 18 Sep 2026 15:11:31 -0400 Subject: [PATCH 1/3] fix: stop made-up og image paths from being cached on disk The og handler looks a page up with the last slug segment dropped, so a request like /og/setup/anything.png rendered Setup's image and stored a new copy under the requested name. Next never evicts those files, so every made-up name grew the cache for good. Unknown pages failed to store and logged a cache-write warning on every request. Every image is generated at build time, so set dynamicParams = false: anything else now 404s before the handler runs and nothing is written. The standalone smoke run now snapshots the cache directory before the server starts and fails if any request added a path. It probes a made-up og file name and an unknown og page, and fails on the cache-write warning. --- app/og/[...slug]/route.tsx | 6 ++++ scripts/smoke-standalone.ts | 60 ++++++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/app/og/[...slug]/route.tsx b/app/og/[...slug]/route.tsx index 7aa746d..2b307ca 100644 --- a/app/og/[...slug]/route.tsx +++ b/app/og/[...slug]/route.tsx @@ -6,6 +6,12 @@ import { appName } from "@/lib/shared"; export const revalidate = false; +// Every image is generated at build time, one per docs page. Without this, +// any other path the pattern matches (say /og/setup/anything.png) is rendered +// on demand and stored on disk under the requested name, and Next never +// evicts those files. +export const dynamicParams = false; + export async function GET( _req: Request, { params }: RouteContext<"/og/[...slug]">, diff --git a/scripts/smoke-standalone.ts b/scripts/smoke-standalone.ts index 3589cea..921edfc 100644 --- a/scripts/smoke-standalone.ts +++ b/scripts/smoke-standalone.ts @@ -43,6 +43,11 @@ const SHUTDOWN_TIMEOUT_MS = 5_000; // .html/.rsc/.meta/.segments under the app cache directory. const UNKNOWN_PAGE = "smoke-nonexistent-page"; +// A made-up file name on a real page. The og and llms.mdx handlers look the +// page up with the last segment dropped, so rendering this on demand serves +// Setup's image or markdown and persists a copy under this name. +const UNKNOWN_FILE = "smoke-nonexistent"; + // The percent-encoded probe. Without the proxy guard it misses the static // /robots.txt route, matches the catch-all, and decodes to the robots // route's cache key. @@ -51,6 +56,11 @@ const ROBOTS_PROBE = "/robots%2Etxt"; // What the robots route logs when it reads a page entry from its cache slot. const ROBOTS_INVARIANT = "app-route received invalid cache entry"; +// What Next logs when it cannot store a route handler's response. A handler +// that runs for an unknown page returns an empty 404, which the in-memory +// cache refuses, so every such request prints this. +const CACHE_WRITE_WARNING = "Failed to update prerender cache"; + function parseCli(): { mode: Mode; port: number } { const { values } = parseArgs({ options: { @@ -176,6 +186,11 @@ function listCacheFiles(key: string): string[] { .sort(); } +/** Every path under APP_CACHE_DIR, nested ones included, relative and sorted. */ +function snapshotCacheTree(): string[] { + return readdirSync(APP_CACHE_DIR, { encoding: "utf-8", recursive: true }).sort(); +} + // --------------------------------------------------------------------------- // Standalone assembly and server lifecycle. // --------------------------------------------------------------------------- @@ -291,6 +306,21 @@ async function waitForReady(baseUrl: string, server: Server): Promise { interface BuildArtifacts { robotsBody: string; robotsMeta: string; + /** Every path under APP_CACHE_DIR before the server starts. */ + cacheTree: string[]; +} + +/** No line of server output contains `needle`. */ +function checkServerOutputOmits(server: Server, needle: string): void { + const lines = server + .output() + .split("\n") + .filter((line) => line.includes(needle)); + check( + `server output never reports "${needle}"`, + lines.length === 0, + lines.join("\n"), + ); } /** Nothing may have been written into the robots route's cache slot. */ @@ -364,6 +394,15 @@ async function runChecks( `found ${JSON.stringify(unknownFiles)} in ${APP_CACHE_DIR}`, ); + // 3b. The og handler serves only the images the build generated. Anything + // else 404s before the handler runs, so nothing is rendered or stored. + // The tree diff and the warning check at the end cover the disk side. + await expectStatus(baseUrl, "/og/setup/image.png", 200, { + contentType: "image/png", + }); + await expectStatus(baseUrl, `/og/setup/${UNKNOWN_FILE}.png`, 404); + await expectStatus(baseUrl, `/og/${UNKNOWN_PAGE}/image.png`, 404); + // 4. Warm replay: the same probe now that /robots.txt sits in the // in-memory cache, then the robots route again. Past the proxy, this // ordering would read the robots entry back as a page and fail. @@ -371,14 +410,18 @@ async function runChecks( checkRobotsSlot("after the warm probe", build); await checkRobots(baseUrl, "after the warm probe", mode, build); - const invariantLines = server - .output() - .split("\n") - .filter((line) => line.includes(ROBOTS_INVARIANT)); + checkServerOutputOmits(server, ROBOTS_INVARIANT); + checkServerOutputOmits(server, CACHE_WRITE_WARNING); + + // Every request above either hits an entry the build prerendered or is + // turned away, so the cache directory must still hold exactly what the + // build shipped. Next never evicts what a request adds here. + const shipped = new Set(build.cacheTree); + const added = snapshotCacheTree().filter((path) => !shipped.has(path)); check( - `server output never reports "${ROBOTS_INVARIANT}"`, - invariantLines.length === 0, - invariantLines.join("\n"), + "cache dir: no request added a path", + added.length === 0, + `new paths under ${APP_CACHE_DIR}:\n${added.join("\n")}`, ); } @@ -390,6 +433,9 @@ async function main(): Promise { const build: BuildArtifacts = { robotsBody: readFileSync(resolve(APP_CACHE_DIR, "robots.txt.body"), "utf-8"), robotsMeta: readFileSync(resolve(APP_CACHE_DIR, "robots.txt.meta"), "utf-8"), + // Taken before the server starts, so files an earlier run left behind + // cannot fail this one. + cacheTree: snapshotCacheTree(), }; if (await isPortAnswering(baseUrl)) { From 6ba738fffc7ff79920c9121e98ccbcb522bb9779 Mon Sep 17 00:00:00 2001 From: Jose Montes de Oca Date: Fri, 18 Sep 2026 15:32:06 -0400 Subject: [PATCH 2/3] fix: stop made-up llms.mdx paths from being cached on disk The markdown handler has the same shape as the og one: it looks a page up with the last slug segment dropped, so /llms.mdx/setup/anything.md served Setup's markdown and stored a copy under the requested name. Its optional catch-all also matched bare /llms.mdx and /llms.mdx/setup, which served the index page's markdown and stored that too. Every markdown file is generated at build time, so set dynamicParams = false. The canonical files and the root markdown rewrite are unaffected; every other path now 404s without writing. The standalone smoke run probes a made-up file name, bare /llms.mdx and an unknown page, and the existing cache-tree check covers the disk side. --- app/llms.mdx/[[...slug]]/route.ts | 6 ++++++ scripts/smoke-standalone.ts | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/app/llms.mdx/[[...slug]]/route.ts b/app/llms.mdx/[[...slug]]/route.ts index c5c8d9a..98f7b53 100644 --- a/app/llms.mdx/[[...slug]]/route.ts +++ b/app/llms.mdx/[[...slug]]/route.ts @@ -3,6 +3,12 @@ import { notFound } from "next/navigation"; export const revalidate = false; +// Every markdown file is generated at build time, one per docs page. Without +// this, any other path the pattern matches (say /llms.mdx/setup/other.md) is +// rendered on demand and stored on disk under the requested name, and Next +// never evicts those files. +export const dynamicParams = false; + export async function GET( _req: Request, { params }: RouteContext<"/llms.mdx/[[...slug]]">, diff --git a/scripts/smoke-standalone.ts b/scripts/smoke-standalone.ts index 921edfc..71efb47 100644 --- a/scripts/smoke-standalone.ts +++ b/scripts/smoke-standalone.ts @@ -403,6 +403,14 @@ async function runChecks( await expectStatus(baseUrl, `/og/setup/${UNKNOWN_FILE}.png`, 404); await expectStatus(baseUrl, `/og/${UNKNOWN_PAGE}/image.png`, 404); + // 3c. The llms.mdx handler serves only the markdown the build generated. + // Its optional catch-all also matches bare /llms.mdx, which the handler + // would answer with the index page's markdown. Step 2 already requests + // a canonical file and the root markdown rewrite. + await expectStatus(baseUrl, `/llms.mdx/setup/${UNKNOWN_FILE}.md`, 404); + await expectStatus(baseUrl, "/llms.mdx", 404); + await expectStatus(baseUrl, `/llms.mdx/${UNKNOWN_PAGE}/content.md`, 404); + // 4. Warm replay: the same probe now that /robots.txt sits in the // in-memory cache, then the robots route again. Past the proxy, this // ordering would read the robots entry back as a page and fail. From a5a5c1c6f5c449b5d4a12e3e1b7d1e699d070514 Mon Sep 17 00:00:00 2001 From: Jose Montes de Oca Date: Fri, 18 Sep 2026 15:37:07 -0400 Subject: [PATCH 3/3] test: fail the smoke run if a dynamic route renders on demand The request probes only cover routes someone remembered to probe. A new dynamic route without dynamicParams = false would render any params it matches on demand and store each result on disk, and nothing would notice until the cache grew. Before the server starts, the smoke run now reads the standalone build's prerender manifest and fails on any dynamic route whose fallback is not false, naming each one. That is the field the server consults at request time, so the check holds for every route, probed or not. The header comment now maps the route-handler guard to the checks that cover it. --- scripts/smoke-standalone.ts | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-standalone.ts b/scripts/smoke-standalone.ts index 71efb47..7838253 100644 --- a/scripts/smoke-standalone.ts +++ b/scripts/smoke-standalone.ts @@ -7,8 +7,11 @@ * GET /robots%2Etxt fell through to the [[...slug]] catch-all, which decoded * the slug and wrote its 404 render into the robots route's cache slot. Every * later GET /robots.txt then threw "app-route received invalid cache entry". + * It is also where the cache directory used to grow without bound: whatever a + * dynamic route rendered on demand was stored under .next/server/app, and + * Next never deletes those files. * - * Two guards are in place, and the checks map onto them: + * Three guards are in place, and the checks map onto them: * - proxy.ts answers any percent-encoded path with 404 before routing. That * is what makes both probes of /robots%2Etxt return 404 and keeps them out * of the robots route's cache slot (the robots.txt.* and .meta checks). @@ -18,6 +21,13 @@ * catch-all, but on its own such a probe still answers 500, because Next * either retries the request until it gives up or reads the robots entry * back from the cache. + * - `dynamicParams = false` on the og and llms.mdx handlers makes made-up + * file names and unknown pages under /og and /llms.mdx 404 before the + * handler runs, so nothing is rendered or stored for them (the og and + * llms.mdx probes, the cache-write warning check, and the check that no + * request added a path to the cache directory). The prerender-manifest + * check fails the run if any dynamic route would render params the build + * did not generate, so a new route cannot reopen this. * * Run after `pnpm exec next build`: * pnpm smoke:standalone --mode public (DOCS_PREVIEW_MODE=false build) @@ -34,6 +44,7 @@ const REPO_ROOT = process.cwd(); const STANDALONE_DIR = resolve(REPO_ROOT, ".next/standalone"); const SERVER_ENTRY = resolve(STANDALONE_DIR, "server.js"); const APP_CACHE_DIR = resolve(STANDALONE_DIR, ".next/server/app"); +const PRERENDER_MANIFEST = resolve(STANDALONE_DIR, ".next/prerender-manifest.json"); const CANONICAL_SITEMAP = "https://docs.prose.md/sitemap.xml"; const BOOT_TIMEOUT_MS = 30_000; const REQUEST_TIMEOUT_MS = 15_000; @@ -310,6 +321,27 @@ interface BuildArtifacts { cacheTree: string[]; } +/** + * Every dynamic route must 404 on params the build did not generate. Any + * other fallback renders them on demand and caches the result on disk. This + * reads the same field the server consults at request time, so it holds for + * routes no probe below requests. A route that really needs on-demand + * rendering has to be exempted here on purpose, with the reason. + */ +function checkPrerenderManifest(): void { + const manifest = JSON.parse(readFileSync(PRERENDER_MANIFEST, "utf-8")) as { + dynamicRoutes: Record; + }; + const onDemand = Object.entries(manifest.dynamicRoutes) + .filter(([, route]) => route.fallback !== false) + .map(([name, route]) => `${name} (fallback: ${JSON.stringify(route.fallback)})`); + check( + "prerender manifest: every dynamic route 404s on params the build did not generate", + onDemand.length === 0, + `these routes render unknown params on demand and store the result on disk; export \`dynamicParams = false\` from each:\n${onDemand.join("\n")}`, + ); +} + /** No line of server output contains `needle`. */ function checkServerOutputOmits(server: Server, needle: string): void { const lines = server @@ -452,6 +484,7 @@ async function main(): Promise { } console.log(`Smoke-testing the ${mode} standalone build at ${baseUrl}\n`); + checkPrerenderManifest(); const server = startServer(port); const onSignal = (signal: NodeJS.Signals) => {