diff --git a/web/lib/prerender-cache-key.ts b/web/lib/prerender-cache-key.ts new file mode 100644 index 00000000..30d0e0e4 --- /dev/null +++ b/web/lib/prerender-cache-key.ts @@ -0,0 +1,19 @@ +// Next.js 16.3.8 scopes response-cache keys to the route that owns them: a +// prerendered page is looked up as +// +// /route-cache///$ +// +// (see next/dist/server/lib/route-cache-key.js). The OpenNext static-assets +// incremental cache still stores build-time prerenders by plain page path +// (/.cache), so the scoped key never matches a file and +// every page falls through to a runtime render — which 500s for pages that +// cannot render in the Workers runtime. +// +// This maps a scoped key back to the plain page path the cache is stored under. +// Keys in any other shape (older Next versions, fetch cache) pass through. +const ROUTE_CACHE_KEY = /^\/?route-cache\/[^/]+\/[0-9a-f]{64}\/\$(.*)$/; + +export function toPrerenderCacheKey(key: string): string { + const match = ROUTE_CACHE_KEY.exec(key); + return match ? match[1] : key; +} diff --git a/web/lib/test/prerender-cache-key.test.ts b/web/lib/test/prerender-cache-key.test.ts new file mode 100644 index 00000000..55702a1a --- /dev/null +++ b/web/lib/test/prerender-cache-key.test.ts @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto'; + +import { describe, expect, it } from 'vitest'; +// The real key builder, so this fails if a Next upgrade changes the key shape +// again instead of silently missing the prerender cache in production. +import { getRouteCacheKey } from 'next/dist/server/lib/route-cache-key'; +import type { RouteKind } from 'next/dist/server/route-kind'; + +import { toPrerenderCacheKey } from '../prerender-cache-key'; + +// RouteKind is a const enum, which isolatedModules cannot import as a value. +const appPage = (page: string) => ({ kind: 'APP_PAGE' as RouteKind, sourceRoute: page }); + +describe('toPrerenderCacheKey', () => { + it('maps a static page key to its page path', () => { + expect(toPrerenderCacheKey(getRouteCacheKey('/flows', appPage('/flows/page')))).toBe('/flows'); + }); + + it('maps a generateStaticParams page key to its concrete path', () => { + const key = getRouteCacheKey('/docs/introduction', appPage('/docs/[slug]/page')); + expect(toPrerenderCacheKey(key)).toBe('/docs/introduction'); + }); + + it('maps the root page to /index, matching the stored index.cache', () => { + expect(toPrerenderCacheKey(getRouteCacheKey('/', appPage('/page')))).toBe('/index'); + }); + + it('accepts the documented key shape literally', () => { + const hash = createHash('sha256').update('/flows/page').digest('hex'); + expect(toPrerenderCacheKey(`/route-cache/APP_PAGE/${hash}/$/flows`)).toBe('/flows'); + }); + + it('passes through keys that are not route-scoped', () => { + expect(toPrerenderCacheKey('/flows')).toBe('/flows'); + expect(toPrerenderCacheKey('docs/introduction')).toBe('docs/introduction'); + expect(toPrerenderCacheKey('44fe800491717aa4131a86c6f5e0985967ffe4edf6c95db62d6512d94188fd5f')).toBe( + '44fe800491717aa4131a86c6f5e0985967ffe4edf6c95db62d6512d94188fd5f', + ); + }); +}); diff --git a/web/open-next.config.ts b/web/open-next.config.ts index f52cef0b..f2634fa7 100644 --- a/web/open-next.config.ts +++ b/web/open-next.config.ts @@ -1,5 +1,8 @@ import { defineCloudflareConfig } from "@opennextjs/cloudflare"; import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache"; +import type { IncrementalCache } from "@opennextjs/aws/types/overrides.js"; + +import { toPrerenderCacheKey } from "./lib/prerender-cache-key"; // Marketing/docs site: static + SSG pages (docs MDX is read from disk at BUILD // time) plus a few dynamic routes and the PostHog proxy middleware. @@ -10,6 +13,18 @@ import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incre // the filesystem, and the Cloudflare Workers runtime stubs Node `fs` // (unenv) — so any runtime re-render of a docs page would fail. Serving the // prerendered output from assets avoids that entirely. No R2/KV needed. +// +// Next 16.3.8 asks for prerendered pages under a route-scoped key the +// static-assets cache does not store them under, so translate the key back to +// the page path before the lookup. Without this every page misses the cache. +const incrementalCache: IncrementalCache = { + name: staticAssetsIncrementalCache.name, + get: (key, cacheType) => staticAssetsIncrementalCache.get(toPrerenderCacheKey(key), cacheType), + set: (key, value, cacheType) => staticAssetsIncrementalCache.set(toPrerenderCacheKey(key), value, cacheType), + // Read-only cache: delete takes no key and only logs. + delete: () => staticAssetsIncrementalCache.delete(), +}; + export default defineCloudflareConfig({ - incrementalCache: staticAssetsIncrementalCache, + incrementalCache, });