diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache.ts b/packages/next-on-pages/templates/_worker.js/utils/cache.ts index 40b8192f1..8084b8e14 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -8,6 +8,8 @@ const NEXT_CACHE_SOFT_TAGS_HEADER = 'x-next-cache-soft-tags'; const REQUEST_CONTEXT_KEY = Symbol.for('__cloudflare-request-context__'); +const CF_NEXT_SUSPENSE_CACHE_HEADER = 'cf-next-suspense-cache'; + /** * Handles an internal request to the suspense cache. * @@ -50,14 +52,17 @@ export async function handleSuspenseCacheRequest(request: Request) { const data = await cache.get(cacheKey, { softTags }); if (!data) return new Response(null, { status: 404 }); - return new Response(JSON.stringify(data.value), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'x-vercel-cache-state': 'fresh', - age: `${(Date.now() - (data.lastModified ?? Date.now())) / 1000}`, + return new Response( + JSON.stringify(formatCacheValueForResponse(data.value)), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'x-vercel-cache-state': 'fresh', + age: `${(Date.now() - (data.lastModified ?? Date.now())) / 1000}`, + }, }, - }); + ); } case 'POST': { // Retrieve request context. @@ -124,3 +129,21 @@ async function getInternalCacheAdaptor( function getTagsFromHeader(req: Request, key: string): string[] | undefined { return req.headers.get(key)?.split(',')?.filter(Boolean); } + +function formatCacheValueForResponse(value: IncrementalCacheValue | null) { + switch (value?.kind) { + case 'FETCH': + return { + ...value, + data: { + ...value.data, + headers: { + ...value.data.headers, + [CF_NEXT_SUSPENSE_CACHE_HEADER]: 'HIT', + }, + }, + }; + default: + return value; + } +} diff --git a/pages-e2e/features/appFetchCache/assets/app/api/cache/route.js b/pages-e2e/features/appFetchCache/assets/app/api/cache/route.js new file mode 100644 index 000000000..456a183ee --- /dev/null +++ b/pages-e2e/features/appFetchCache/assets/app/api/cache/route.js @@ -0,0 +1,13 @@ +export const runtime = 'edge'; + +export async function GET(request) { + const url = new URL('/api/hello', request.url); + const data = await fetch(url.href, { next: { tags: ['cache'] } }); + + return new Response( + JSON.stringify({ + body: await data.text(), + headers: Object.fromEntries([...data.headers.entries()]), + }), + ); +} diff --git a/pages-e2e/features/appFetchCache/fetch-cache.test.ts b/pages-e2e/features/appFetchCache/fetch-cache.test.ts new file mode 100644 index 000000000..fadf7250d --- /dev/null +++ b/pages-e2e/features/appFetchCache/fetch-cache.test.ts @@ -0,0 +1,23 @@ +import { beforeAll, describe, it } from 'vitest'; + +describe('Simple Pages API Routes', () => { + it('should return a cached fetch response from the suspense cache', async ({ + expect, + }) => { + const initialResp = await fetch(`${DEPLOYMENT_URL}/api/cache`); + const initialRespJson = await initialResp.json(); + + expect(initialRespJson.body).toEqual(expect.stringMatching('Hello world')); + expect(initialRespJson.headers).toEqual( + expect.not.objectContaining({ 'cf-next-suspense-cache': 'HIT' }), + ); + + const cachedResp = await fetch(`${DEPLOYMENT_URL}/api/cache`); + const cachedRespJson = await cachedResp.json(); + + expect(cachedRespJson.body).toEqual(expect.stringMatching('Hello world')); + expect(cachedRespJson.headers).toEqual( + expect.objectContaining({ 'cf-next-suspense-cache': 'HIT' }), + ); + }); +}); diff --git a/pages-e2e/features/appFetchCache/main.feature b/pages-e2e/features/appFetchCache/main.feature new file mode 100644 index 000000000..223c7e01e --- /dev/null +++ b/pages-e2e/features/appFetchCache/main.feature @@ -0,0 +1,3 @@ +{ + "setup": "node --loader tsm setup.ts" +} diff --git a/pages-e2e/features/appFetchCache/setup.ts b/pages-e2e/features/appFetchCache/setup.ts new file mode 100644 index 000000000..d38a9cf1c --- /dev/null +++ b/pages-e2e/features/appFetchCache/setup.ts @@ -0,0 +1,2 @@ +import { copyWorkspaceAssets } from '../_utils/copyWorkspaceAssets'; +await copyWorkspaceAssets(); diff --git a/pages-e2e/fixtures/app14.0.0/main.fixture b/pages-e2e/fixtures/app14.0.0/main.fixture index 4b0cc891d..c6b519a43 100644 --- a/pages-e2e/fixtures/app14.0.0/main.fixture +++ b/pages-e2e/fixtures/app14.0.0/main.fixture @@ -16,8 +16,8 @@ "compatibilityFlags": ["nodejs_compat"], "kvNamespaces": { "MY_KV": { - "production": {"id": "00000000000000000000000000000000"}, - "staging": {"id": "00000000000000000000000000000000"} + "production": { "id": "00000000000000000000000000000000" }, + "staging": { "id": "00000000000000000000000000000000" } } } } diff --git a/pages-e2e/fixtures/appLatest/main.fixture b/pages-e2e/fixtures/appLatest/main.fixture index f95b9f12d..a936532b2 100644 --- a/pages-e2e/fixtures/appLatest/main.fixture +++ b/pages-e2e/fixtures/appLatest/main.fixture @@ -10,7 +10,8 @@ "appConfigsRewritesRedirectsHeaders", "appWasm", "appServerActions", - "appGetRequestContext" + "appGetRequestContext", + "appFetchCache" ], "localSetup": "./setup.sh", "buildConfig": { @@ -21,8 +22,12 @@ "compatibilityFlags": ["nodejs_compat"], "kvNamespaces": { "MY_KV": { - "production": {"id": "00000000000000000000000000000000"}, - "staging": {"id": "00000000000000000000000000000000"} + "production": { "id": "00000000000000000000000000000000" }, + "staging": { "id": "00000000000000000000000000000000" } + }, + "__NEXT_ON_PAGES__KV_SUSPENSE_CACHE": { + "production": { "id": "00000000000000000000000000000000" }, + "staging": { "id": "00000000000000000000000000000000" } } } }