Skip to content
Merged
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
19 changes: 19 additions & 0 deletions web/lib/prerender-cache-key.ts
Original file line number Diff line number Diff line change
@@ -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/<ROUTE_KIND>/<sha256 of the source route>/$<page path>
//
// (see next/dist/server/lib/route-cache-key.js). The OpenNext static-assets
// incremental cache still stores build-time prerenders by plain page path
// (<build id>/<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;
}
40 changes: 40 additions & 0 deletions web/lib/test/prerender-cache-key.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
17 changes: 16 additions & 1 deletion web/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
});
Loading