diff --git a/src/build/templates/handler-monorepo.tmpl.js b/src/build/templates/handler-monorepo.tmpl.js index 89f4411d5e..0eb146dd45 100644 --- a/src/build/templates/handler-monorepo.tmpl.js +++ b/src/build/templates/handler-monorepo.tmpl.js @@ -28,6 +28,7 @@ export default async function (req, context) { 'site.id': context.site.id, 'http.method': req.method, 'http.target': req.url, + isBackgroundRevalidation: requestContext.isBackgroundRevalidation, monorepo: true, cwd: '{{cwd}}', }) @@ -35,7 +36,7 @@ export default async function (req, context) { const { default: handler } = await import('{{nextServerHandler}}') cachedHandler = handler } - const response = await cachedHandler(req, context) + const response = await cachedHandler(req, context, span, requestContext) span.setAttributes({ 'http.status_code': response.status, }) diff --git a/src/build/templates/handler.tmpl.js b/src/build/templates/handler.tmpl.js index c86fe13131..360de892c2 100644 --- a/src/build/templates/handler.tmpl.js +++ b/src/build/templates/handler.tmpl.js @@ -25,10 +25,11 @@ export default async function handler(req, context) { 'site.id': context.site.id, 'http.method': req.method, 'http.target': req.url, + isBackgroundRevalidation: requestContext.isBackgroundRevalidation, monorepo: false, cwd: process.cwd(), }) - const response = await serverHandler(req, context) + const response = await serverHandler(req, context, span, requestContext) span.setAttributes({ 'http.status_code': response.status, }) diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index c00a4d5e14..a2f84ab125 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -52,6 +52,29 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { return await encodeBlobKey(key) } + private getTTL(blob: NetlifyCacheHandlerValue) { + if ( + blob.value?.kind === 'FETCH' || + blob.value?.kind === 'ROUTE' || + blob.value?.kind === 'APP_ROUTE' || + blob.value?.kind === 'PAGE' || + blob.value?.kind === 'PAGES' || + blob.value?.kind === 'APP_PAGE' + ) { + const { revalidate } = blob.value + + if (typeof revalidate === 'number') { + const revalidateAfter = revalidate * 1_000 + blob.lastModified + return (revalidateAfter - Date.now()) / 1_000 + } + if (revalidate === false) { + return 'PERMANENT' + } + } + + return 'NOT SET' + } + private captureResponseCacheLastModified( cacheValue: NetlifyCacheHandlerValue, key: string, @@ -219,10 +242,31 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { return null } + const ttl = this.getTTL(blob) + + if (getRequestContext()?.isBackgroundRevalidation && typeof ttl === 'number' && ttl < 0) { + // background revalidation request should allow data that is not yet stale, + // but opt to discard STALE data, so that Next.js generate fresh response + span.addEvent('Discarding stale entry due to SWR background revalidation request', { + key, + blobKey, + ttl, + }) + getLogger() + .withFields({ + ttl, + key, + }) + .debug( + `[NetlifyCacheHandler.get] Discarding stale entry due to SWR background revalidation request: ${key}`, + ) + return null + } + const staleByTags = await this.checkCacheEntryStaleByTags(blob, ctx.tags, ctx.softTags) if (staleByTags) { - span.addEvent('Stale', { staleByTags }) + span.addEvent('Stale', { staleByTags, key, blobKey, ttl }) return null } @@ -231,7 +275,11 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { switch (blob.value?.kind) { case 'FETCH': - span.addEvent('FETCH', { lastModified: blob.lastModified, revalidate: ctx.revalidate }) + span.addEvent('FETCH', { + lastModified: blob.lastModified, + revalidate: ctx.revalidate, + ttl, + }) return { lastModified: blob.lastModified, value: blob.value, @@ -242,6 +290,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, status: blob.value.status, + revalidate: blob.value.revalidate, + ttl, }) const valueWithoutRevalidate = this.captureRouteRevalidateAndRemoveFromObject(blob.value) @@ -256,10 +306,10 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { } case 'PAGE': case 'PAGES': { - span.addEvent(blob.value?.kind, { lastModified: blob.lastModified }) - const { revalidate, ...restOfPageValue } = blob.value + span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl }) + await this.injectEntryToPrerenderManifest(key, revalidate) return { @@ -268,10 +318,10 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { } } case 'APP_PAGE': { - span.addEvent(blob.value?.kind, { lastModified: blob.lastModified }) - const { revalidate, rscData, ...restOfPageValue } = blob.value + span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl }) + await this.injectEntryToPrerenderManifest(key, revalidate) return { diff --git a/src/run/handlers/request-context.cts b/src/run/handlers/request-context.cts index cc67739242..82f4f8567f 100644 --- a/src/run/handlers/request-context.cts +++ b/src/run/handlers/request-context.cts @@ -13,6 +13,10 @@ export interface FutureContext extends Context { } export type RequestContext = { + /** + * Determine if this request is for CDN SWR background revalidation + */ + isBackgroundRevalidation: boolean captureServerTiming: boolean responseCacheGetLastModified?: number responseCacheKey?: string @@ -41,7 +45,20 @@ type RequestContextAsyncLocalStorage = AsyncLocalStorage export function createRequestContext(request?: Request, context?: FutureContext): RequestContext { const backgroundWorkPromises: Promise[] = [] + const isDebugRequest = + request?.headers.has('x-nf-debug-logging') || request?.headers.has('x-next-debug-logging') + + const logger = systemLogger.withLogLevel(isDebugRequest ? LogLevel.Debug : LogLevel.Log) + + const isBackgroundRevalidation = + request?.headers.get('netlify-invocation-source') === 'background-revalidation' + + if (isBackgroundRevalidation) { + logger.debug('[NetlifyNextRuntime] Background revalidation request') + } + return { + isBackgroundRevalidation, captureServerTiming: request?.headers.has('x-next-debug-logging') ?? false, trackBackgroundWork: (promise) => { if (context?.waitUntil) { @@ -53,11 +70,7 @@ export function createRequestContext(request?: Request, context?: FutureContext) get backgroundWorkPromise() { return Promise.allSettled(backgroundWorkPromises) }, - logger: systemLogger.withLogLevel( - request?.headers.has('x-nf-debug-logging') || request?.headers.has('x-next-debug-logging') - ? LogLevel.Debug - : LogLevel.Log, - ), + logger, } } diff --git a/src/run/handlers/server.ts b/src/run/handlers/server.ts index 2c6853a655..cb63218830 100644 --- a/src/run/handlers/server.ts +++ b/src/run/handlers/server.ts @@ -1,6 +1,8 @@ import type { OutgoingHttpHeaders } from 'http' import { ComputeJsOutgoingMessage, toComputeResponse, toReqRes } from '@fastly/http-compute-js' +import type { Context } from '@netlify/functions' +import { Span } from '@opentelemetry/api' import type { NextConfigComplete } from 'next/dist/server/config-shared.js' import type { WorkerRequestHandler } from 'next/dist/server/lib/types.js' @@ -13,7 +15,7 @@ import { } from '../headers.js' import { nextResponseProxy } from '../revalidate.js' -import { createRequestContext, getLogger, getRequestContext } from './request-context.cjs' +import { getLogger, type RequestContext } from './request-context.cjs' import { getTracer } from './tracer.cjs' import { setupWaitUntil } from './wait-until.cjs' @@ -46,7 +48,12 @@ const disableFaultyTransferEncodingHandling = (res: ComputeJsOutgoingMessage) => } } -export default async (request: Request) => { +export default async ( + request: Request, + _context: Context, + topLevelSpan: Span, + requestContext: RequestContext, +) => { const tracer = getTracer() if (!nextHandler) { @@ -85,8 +92,6 @@ export default async (request: Request) => { disableFaultyTransferEncodingHandling(res as unknown as ComputeJsOutgoingMessage) - const requestContext = getRequestContext() ?? createRequestContext() - const resProxy = nextResponseProxy(res, requestContext) // We don't await this here, because it won't resolve until the response is finished. @@ -103,15 +108,31 @@ export default async (request: Request) => { const response = await toComputeResponse(resProxy) if (requestContext.responseCacheKey) { - span.setAttribute('responseCacheKey', requestContext.responseCacheKey) + topLevelSpan.setAttribute('responseCacheKey', requestContext.responseCacheKey) } - await adjustDateHeader({ headers: response.headers, request, span, tracer, requestContext }) + const nextCache = response.headers.get('x-nextjs-cache') + const isServedFromNextCache = nextCache === 'HIT' || nextCache === 'STALE' + + topLevelSpan.setAttributes({ + 'x-nextjs-cache': nextCache ?? undefined, + isServedFromNextCache, + }) + + if (isServedFromNextCache) { + await adjustDateHeader({ + headers: response.headers, + request, + span, + tracer, + requestContext, + }) + } setCacheControlHeaders(response, request, requestContext, nextConfig) setCacheTagsHeaders(response.headers, requestContext) setVaryHeaders(response.headers, request, nextConfig) - setCacheStatusHeader(response.headers) + setCacheStatusHeader(response.headers, nextCache) async function waitForBackgroundWork() { // it's important to keep the stream open until the next handler has finished diff --git a/src/run/headers.ts b/src/run/headers.ts index 7d0ab35222..91d4588528 100644 --- a/src/run/headers.ts +++ b/src/run/headers.ts @@ -137,17 +137,6 @@ export const adjustDateHeader = async ({ tracer: RuntimeTracer requestContext: RequestContext }) => { - const cacheState = headers.get('x-nextjs-cache') - const isServedFromCache = cacheState === 'HIT' || cacheState === 'STALE' - - span.setAttributes({ - 'x-nextjs-cache': cacheState ?? undefined, - isServedFromCache, - }) - - if (!isServedFromCache) { - return - } const key = new URL(request.url).pathname let lastModified: number | undefined @@ -317,8 +306,7 @@ const NEXT_CACHE_TO_CACHE_STATUS: Record = { * a Cache-Status header for Next cache so users inspect that together with CDN cache status * and not on its own. */ -export const setCacheStatusHeader = (headers: Headers) => { - const nextCache = headers.get('x-nextjs-cache') +export const setCacheStatusHeader = (headers: Headers, nextCache: string | null) => { if (typeof nextCache === 'string') { if (nextCache in NEXT_CACHE_TO_CACHE_STATUS) { const cacheStatus = NEXT_CACHE_TO_CACHE_STATUS[nextCache] diff --git a/src/shared/cache-types.cts b/src/shared/cache-types.cts index bdc48bb973..9cbfd9b13e 100644 --- a/src/shared/cache-types.cts +++ b/src/shared/cache-types.cts @@ -78,7 +78,9 @@ type CachedRouteValueToNetlify = T extends CachedRouteValue ? NetlifyCachedAppPageValue : T -type MapCachedRouteValueToNetlify = { [K in keyof T]: CachedRouteValueToNetlify } +type MapCachedRouteValueToNetlify = { [K in keyof T]: CachedRouteValueToNetlify } & { + lastModified: number +} /** * Used for storing in blobs and reading from blobs diff --git a/tests/e2e/page-router.test.ts b/tests/e2e/page-router.test.ts index 06f8abc29d..0cbe296623 100644 --- a/tests/e2e/page-router.test.ts +++ b/tests/e2e/page-router.test.ts @@ -390,6 +390,63 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => { expect(beforeFetch.localeCompare(date2)).toBeLessThan(0) }) + test('Background SWR invocations can store fresh responses in CDN cache', async ({ + page, + pageRouter, + }) => { + const slug = Date.now() + const pathname = `/revalidate-60/${slug}` + + const beforeFirstFetch = new Date().toISOString() + + const response1 = await page.goto(new URL(pathname, pageRouter.url).href) + expect(response1?.status()).toBe(200) + expect(response1?.headers()['cache-status']).toMatch( + /"Netlify (Edge|Durable)"; fwd=(uri-miss(; stored)?|miss)/m, + ) + expect(response1?.headers()['netlify-cdn-cache-control']).toMatch( + /s-maxage=60, stale-while-revalidate=[0-9]+, durable/, + ) + + // ensure response was NOT produced before invocation + const date1 = (await page.textContent('[data-testid="date-now"]')) ?? '' + expect(date1.localeCompare(beforeFirstFetch)).toBeGreaterThan(0) + + // allow page to get stale + await page.waitForTimeout(60_000) + + const response2 = await page.goto(new URL(pathname, pageRouter.url).href) + expect(response2?.status()).toBe(200) + expect(response2?.headers()['cache-status']).toMatch( + /"Netlify (Edge|Durable)"; hit; fwd=stale/m, + ) + expect(response2?.headers()['netlify-cdn-cache-control']).toMatch( + /s-maxage=60, stale-while-revalidate=[0-9]+, durable/, + ) + + const date2 = (await page.textContent('[data-testid="date-now"]')) ?? '' + expect(date2).toBe(date1) + + // wait a bit to ensure background work has a chance to finish + // (it should take at least 5 seconds to regenerate, so we should wait at least that much to get fresh response) + await page.waitForTimeout(10_000) + + // subsequent request should be served with fresh response from cdn cache, as previous request + // should result in background SWR invocation that serves fresh response that was stored in CDN cache + const response3 = await page.goto(new URL(pathname, pageRouter.url).href) + expect(response3?.status()).toBe(200) + expect(response3?.headers()['cache-status']).toMatch( + // hit, without being followed by ';fwd=stale' + /"Netlify (Edge|Durable)"; hit(?!; fwd=stale)/m, + ) + expect(response3?.headers()['netlify-cdn-cache-control']).toMatch( + /s-maxage=60, stale-while-revalidate=[0-9]+, durable/, + ) + + const date3 = (await page.textContent('[data-testid="date-now"]')) ?? '' + expect(date3.localeCompare(date2)).toBeGreaterThan(0) + }) + test('should serve 404 page when requesting non existing page (no matching route)', async ({ page, pageRouter, diff --git a/tests/fixtures/page-router/netlify/functions/purge-cdn.ts b/tests/fixtures/page-router/netlify/functions/purge-cdn.ts new file mode 100644 index 0000000000..6dd0891ae6 --- /dev/null +++ b/tests/fixtures/page-router/netlify/functions/purge-cdn.ts @@ -0,0 +1,41 @@ +import { purgeCache, Config } from '@netlify/functions' + +export default async function handler(request: Request) { + const url = new URL(request.url) + const pathToPurge = url.searchParams.get('path') + + if (!pathToPurge) { + return Response.json( + { + status: 'error', + error: 'missing "path" query parameter', + }, + { status: 400 }, + ) + } + try { + await purgeCache({ tags: [`_N_T_${encodeURI(pathToPurge)}`] }) + return Response.json( + { + status: 'ok', + }, + { + status: 200, + }, + ) + } catch (error) { + return Response.json( + { + status: 'error', + error: error.toString(), + }, + { + status: 500, + }, + ) + } +} + +export const config: Config = { + path: '/api/purge-cdn', +} diff --git a/tests/fixtures/page-router/pages/api/purge-cdn.js b/tests/fixtures/page-router/pages/api/purge-cdn.js deleted file mode 100644 index 3e5db4ff84..0000000000 --- a/tests/fixtures/page-router/pages/api/purge-cdn.js +++ /dev/null @@ -1,25 +0,0 @@ -import { purgeCache } from '@netlify/functions' - -/** - * @param {import('next').NextApiRequest} req - * @param {import('next').NextApiResponse} res - */ -export default async function handler(req, res) { - const pathToPurge = req.query.path - if (!pathToPurge) { - return res.status(400).send({ - status: 'error', - error: 'missing "path" query parameter', - }) - } - - try { - await purgeCache({ tags: [`_N_T_${pathToPurge}`] }) - return res.status(200).json({ message: 'ok' }) - } catch (err) { - return res.status(500).send({ - status: 'error', - error: error.toString(), - }) - } -} diff --git a/tests/fixtures/page-router/pages/revalidate-60/[slug].js b/tests/fixtures/page-router/pages/revalidate-60/[slug].js new file mode 100644 index 0000000000..3208f5fe3b --- /dev/null +++ b/tests/fixtures/page-router/pages/revalidate-60/[slug].js @@ -0,0 +1,35 @@ +const Show = ({ time, easyTimeToCompare, slug }) => ( +
+

+ This page uses getStaticProps() at + {time} +

+

+ Time string: {easyTimeToCompare} +

+

Slug {slug}

+
+) + +/** @type {import('next').getStaticPaths} */ +export const getStaticPaths = () => { + return { + paths: [], + fallback: 'blocking', + } +} + +/** @type {import('next').GetStaticProps} */ +export async function getStaticProps({ params }) { + const date = new Date() + return { + props: { + slug: params.slug, + time: date.toISOString(), + easyTimeToCompare: date.toTimeString(), + }, + revalidate: 60, + } +} + +export default Show