From 5bf3e62aa29b4960c837c262c414dd8601bb8f45 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 2 Oct 2026 08:28:08 +0200 Subject: [PATCH] fix(web): read the prerender cache under Next 16.3.8's route-scoped keys /flows and every /docs/ page have returned 500 in production since the Next.js 16.3.8 upgrade (#126). Next 16.3.8 looks prerendered pages up under a route-scoped key, /route-cache///$. The OpenNext static-assets incremental cache stores them as /.cache and passes the key through unchanged, so every lookup misses and the Worker renders each page at request time. Pages that cannot render in the Workers runtime then fail: docs MDX with "EvalError: Code generation from strings disallowed", /flows with "WebAssembly.instantiate(): Wasm code generation disallowed by embedder". Wrap the cache so a route-scoped key is translated back to its page path before the lookup. The test builds keys with Next's own getRouteCacheKey, so another change to the key shape fails CI instead of production. Co-Authored-By: Claude Opus 5.5 (1M context) --- web/lib/prerender-cache-key.ts | 19 +++++++++++ web/lib/test/prerender-cache-key.test.ts | 40 ++++++++++++++++++++++++ web/open-next.config.ts | 17 +++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 web/lib/prerender-cache-key.ts create mode 100644 web/lib/test/prerender-cache-key.test.ts 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, });