diff --git a/packages/next/client/components/app-router-headers.ts b/packages/next/client/components/app-router-headers.ts index de00963e625e7fe..12d2a3dccd025a8 100644 --- a/packages/next/client/components/app-router-headers.ts +++ b/packages/next/client/components/app-router-headers.ts @@ -1,6 +1,7 @@ export const RSC = 'RSC' as const export const NEXT_ROUTER_STATE_TREE = 'Next-Router-State-Tree' as const export const NEXT_ROUTER_PREFETCH = 'Next-Router-Prefetch' as const +export const FETCH_CACHE_HEADER = 'x-vercel-sc-headers' as const export const RSC_VARY_HEADER = `${RSC}, ${NEXT_ROUTER_STATE_TREE}, ${NEXT_ROUTER_PREFETCH}` as const diff --git a/packages/next/client/components/static-generation-async-storage.ts b/packages/next/client/components/static-generation-async-storage.ts index 7a43c8ad70bba05..570f6c4dcf24710 100644 --- a/packages/next/client/components/static-generation-async-storage.ts +++ b/packages/next/client/components/static-generation-async-storage.ts @@ -7,6 +7,9 @@ export interface StaticGenerationStore { fetchRevalidate?: number isStaticGeneration?: boolean forceStatic?: boolean + incrementalCache?: import('../../server/lib/incremental-cache').IncrementalCache + pendingRevalidates?: Promise[] + isRevalidate?: boolean } export let staticGenerationAsyncStorage: diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index 9ee4a4d7f62cf98..c2e0b095fffb4ea 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -12,7 +12,7 @@ import '../server/node-polyfill-fetch' import { loadRequireHook } from '../build/webpack/require-hook' import { extname, join, dirname, sep } from 'path' -import { promises } from 'fs' +import fs, { promises } from 'fs' import AmpHtmlValidator from 'next/dist/compiled/amphtml-validator' import { loadComponents } from '../server/load-components' import { isDynamicRoute } from '../shared/lib/router/utils/is-dynamic' @@ -32,6 +32,7 @@ import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' import { REDIRECT_ERROR_CODE } from '../client/components/redirect' import { DYNAMIC_ERROR_CODE } from '../client/components/hooks-server-context' import { NOT_FOUND_ERROR_CODE } from '../client/components/not-found' +import { IncrementalCache } from '../server/lib/incremental-cache' loadRequireHook() @@ -98,6 +99,7 @@ interface RenderOpts { domainLocales?: DomainLocale[] trailingSlash?: boolean supportsDynamicHTML?: boolean + incrementalCache?: import('../server/lib/incremental-cache').IncrementalCache } // expose AsyncLocalStorage on globalThis for react usage @@ -310,6 +312,31 @@ export default async function exportPage({ // and bail when dynamic dependencies are detected // only fully static paths are fully generated here if (isAppDir) { + curRenderOpts.incrementalCache = new IncrementalCache({ + dev: false, + requestHeaders: {}, + flushToDisk: true, + maxMemoryCacheSize: 50 * 1024 * 1024, + getPrerenderManifest: () => ({ + version: 3, + routes: {}, + dynamicRoutes: {}, + preview: { + previewModeEncryptionKey: '', + previewModeId: '', + previewModeSigningKey: '', + }, + notFoundRoutes: [], + }), + fs: { + readFile: (f) => fs.promises.readFile(f, 'utf8'), + readFileSync: (f) => fs.readFileSync(f, 'utf8'), + writeFile: (f, d) => fs.promises.writeFile(f, d, 'utf8'), + mkdir: (dir) => fs.promises.mkdir(dir, { recursive: true }), + stat: (f) => fs.promises.stat(f), + }, + serverDistDir: join(distDir, 'server'), + }) const { renderToHTMLOrFlight } = require('../server/app-render') as typeof import('../server/app-render') diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index cfa39d4708cf0b1..d1f41e47d05fed5 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -80,6 +80,7 @@ function preloadComponent(Component: any, props: any) { return Component } +const CACHE_ONE_YEAR = 31536000 const INTERNAL_HEADERS_INSTANCE = Symbol('internal for headers readonly') function readonlyHeadersError() { @@ -176,6 +177,8 @@ export type RenderOptsPartial = { assetPrefix?: string fontLoaderManifest?: FontLoaderManifest isBot?: boolean + incrementalCache?: import('./lib/incremental-cache').IncrementalCache + isRevalidate?: boolean } export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial @@ -245,12 +248,115 @@ function patchFetch(ComponentMod: any) { const originFetch = globalThis.fetch globalThis.fetch = async (input, init) => { const staticGenerationStore = - 'getStore' in staticGenerationAsyncStorage + ('getStore' in staticGenerationAsyncStorage ? staticGenerationAsyncStorage.getStore() - : staticGenerationAsyncStorage + : staticGenerationAsyncStorage) || {} + + const { + isStaticGeneration, + fetchRevalidate, + pathname, + incrementalCache, + isRevalidate, + } = (staticGenerationStore || {}) as StaticGenerationStore + + let revalidate: number | undefined | boolean + + if (typeof init?.next?.revalidate === 'number') { + revalidate = init.next.revalidate + } + if (init?.next?.revalidate === false) { + revalidate = CACHE_ONE_YEAR + } + + if ( + !staticGenerationStore.fetchRevalidate || + (typeof revalidate === 'number' && + revalidate < staticGenerationStore.fetchRevalidate) + ) { + staticGenerationStore.fetchRevalidate = revalidate + } + + let cacheKey: string | undefined + + const doOriginalFetch = async () => { + return originFetch(input, init).then(async (res) => { + if ( + incrementalCache && + cacheKey && + typeof revalidate === 'number' && + revalidate > 0 + ) { + const clonedRes = res.clone() + + let base64Body = '' + + if (process.env.NEXT_RUNTIME === 'edge') { + let string = '' + new Uint8Array(await clonedRes.arrayBuffer()).forEach((byte) => { + string += String.fromCharCode(byte) + }) + base64Body = btoa(string) + } else { + base64Body = Buffer.from(await clonedRes.arrayBuffer()).toString( + 'base64' + ) + } + + await incrementalCache.set( + cacheKey, + { + kind: 'FETCH', + isStale: false, + age: 0, + data: { + headers: Object.fromEntries(clonedRes.headers.entries()), + body: base64Body, + }, + revalidate, + }, + revalidate, + true + ) + } + return res + }) + } - const { isStaticGeneration, fetchRevalidate, pathname } = - staticGenerationStore || {} + if (incrementalCache && typeof revalidate === 'number' && revalidate > 0) { + cacheKey = await incrementalCache?.fetchCacheKey(input.toString(), init) + const entry = await incrementalCache.get(cacheKey, true) + + if (entry?.value && entry.value.kind === 'FETCH') { + // when stale and is revalidating we wait for fresh data + // so the revalidated entry has the updated data + if (!isRevalidate || !entry.isStale) { + if (entry.isStale) { + if (!staticGenerationStore.pendingRevalidates) { + staticGenerationStore.pendingRevalidates = [] + } + staticGenerationStore.pendingRevalidates.push( + doOriginalFetch().catch(console.error) + ) + } + + const resData = entry.value.data + let decodedBody = '' + + // TODO: handle non-text response bodies + if (process.env.NEXT_RUNTIME === 'edge') { + decodedBody = atob(resData.body) + } else { + decodedBody = Buffer.from(resData.body, 'base64').toString() + } + + return new Response(decodedBody, { + headers: resData.headers, + status: resData.status, + }) + } + } + } if (staticGenerationStore && isStaticGeneration) { if (init && typeof init === 'object') { @@ -292,7 +398,7 @@ function patchFetch(ComponentMod: any) { if (hasNextConfig) delete init.next } } - return originFetch(input, init) + return doOriginalFetch() } } @@ -771,9 +877,7 @@ export async function renderToHTMLOrFlight( supportsDynamicHTML, } = renderOpts - if (process.env.NODE_ENV === 'production') { - patchFetch(ComponentMod) - } + patchFetch(ComponentMod) const generateStaticHTML = supportsDynamicHTML !== true const staticGenerationAsyncStorage = ComponentMod.staticGenerationAsyncStorage @@ -1111,7 +1215,7 @@ export async function renderToHTMLOrFlight( // otherwise if (layoutOrPageMod.dynamic === 'force-static') { staticGenerationStore.forceStatic = true - } else { + } else if (layoutOrPageMod.dynamic !== 'error') { staticGenerationStore.forceStatic = false } } @@ -1726,6 +1830,10 @@ export async function renderToHTMLOrFlight( } const renderResult = new RenderResult(await bodyResult()) + if (staticGenerationStore.pendingRevalidates) { + await Promise.all(staticGenerationStore.pendingRevalidates) + } + if (isStaticGeneration) { const htmlResult = await streamToBufferedResult(renderResult) @@ -1744,6 +1852,9 @@ export async function renderToHTMLOrFlight( await generateFlight() ) + if (staticGenerationStore?.forceStatic === false) { + staticGenerationStore.fetchRevalidate = 0 + } ;(renderOpts as any).pageData = filteredFlightData ;(renderOpts as any).revalidate = typeof staticGenerationStore?.fetchRevalidate === 'undefined' @@ -1760,6 +1871,8 @@ export async function renderToHTMLOrFlight( isStaticGeneration, inUse: true, pathname, + incrementalCache: renderOpts.incrementalCache, + isRevalidate: renderOpts.isRevalidate, } const tryGetPreviewData = diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index b91508e5ad1baa6..3cf525acb8e5541 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -78,6 +78,7 @@ import { RSC, RSC_VARY_HEADER, FLIGHT_PARAMETERS, + FETCH_CACHE_HEADER, } from '../client/components/app-router-headers' export type FindComponentsResult = { @@ -310,6 +311,10 @@ export default abstract class Server { res: BaseNextResponse ): void + protected abstract getIncrementalCache(options: { + requestHeaders: Record + }): import('./lib/incremental-cache').IncrementalCache + protected abstract getResponseCache(options: { dev: boolean }): ResponseCacheBase @@ -1283,11 +1288,22 @@ export default abstract class Server { ssgCacheKey = ssgCacheKey === '/index' && pathname === '/' ? '/' : ssgCacheKey } + const incrementalCache = this.getIncrementalCache({ + requestHeaders: Object.assign({}, req.headers), + }) + if ( + this.nextConfig.experimental.fetchCache && + (opts.runtime !== 'experimental-edge' || + (this.serverOptions as any).webServerConfig) + ) { + delete req.headers[FETCH_CACHE_HEADER] + } + let isRevalidate = false const doRender: () => Promise = async () => { let pageData: any let body: RenderResult | null - let sprRevalidate: number | false + let isrRevalidate: number | false let isNotFound: boolean | undefined let isRedirect: boolean | undefined @@ -1312,6 +1328,12 @@ export default abstract class Server { const renderOpts: RenderOpts = { ...components, ...opts, + ...(isAppPath && this.nextConfig.experimental.fetchCache + ? { + incrementalCache, + isRevalidate: this.minimalMode || isRevalidate, + } + : {}), isDataReq, resolvedUrl, locale, @@ -1346,7 +1368,7 @@ export default abstract class Server { body = renderResult // TODO: change this to a different passing mechanism pageData = (renderOpts as any).pageData - sprRevalidate = (renderOpts as any).revalidate + isrRevalidate = (renderOpts as any).revalidate isNotFound = (renderOpts as any).isNotFound isRedirect = (renderOpts as any).isRedirect @@ -1361,7 +1383,7 @@ export default abstract class Server { } value = { kind: 'PAGE', html: body, pageData } } - return { revalidate: sprRevalidate, value } + return { revalidate: isrRevalidate, value } } const cacheEntry = await this.responseCache.get( @@ -1371,6 +1393,10 @@ export default abstract class Server { const isDynamicPathname = isDynamicRoute(pathname) const didRespond = hasResolved || res.sent + if (hadCache) { + isRevalidate = true + } + if (!staticPaths) { ;({ staticPaths, fallbackMode } = hasStaticPaths ? await this.getStaticPaths({ pathname }) @@ -1486,6 +1512,7 @@ export default abstract class Server { } }, { + incrementalCache, isManualRevalidate, isPrefetch: req.headers.purpose === 'prefetch', } diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts index 2567e5298d61585..ba1128cf18b3a67 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -277,6 +277,9 @@ const configSchema = { fallbackNodePolyfills: { type: 'boolean', }, + fetchCache: { + type: 'boolean', + }, forceSwcTransforms: { type: 'boolean', }, diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index 8659f99f4f0208e..0a56331d28ceb4f 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -79,6 +79,7 @@ export interface NextJsWebpackConfig { } export interface ExperimentalConfig { + fetchCache?: boolean allowMiddlewareResponseBody?: boolean skipMiddlewareUrlNormalize?: boolean skipTrailingSlashRedirect?: boolean @@ -565,6 +566,7 @@ export const defaultConfig: NextConfig = { swcMinify: true, output: !!process.env.NEXT_PRIVATE_STANDALONE ? 'standalone' : undefined, experimental: { + fetchCache: false, middlewarePrefetch: 'flexible', optimisticClientCache: true, runtime: undefined, diff --git a/packages/next/server/lib/incremental-cache/fetch-cache.ts b/packages/next/server/lib/incremental-cache/fetch-cache.ts new file mode 100644 index 000000000000000..613f50c22ca6c5d --- /dev/null +++ b/packages/next/server/lib/incremental-cache/fetch-cache.ts @@ -0,0 +1,162 @@ +import LRUCache from 'next/dist/compiled/lru-cache' +import { FETCH_CACHE_HEADER } from '../../../client/components/app-router-headers' +import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './' + +let memoryCache: LRUCache | undefined + +export default class FetchCache implements CacheHandler { + private headers: Record + private cacheEndpoint: string + private debug: boolean + + constructor(ctx: CacheHandlerContext) { + if (ctx.maxMemoryCacheSize && !memoryCache) { + memoryCache = new LRUCache({ + max: ctx.maxMemoryCacheSize, + length({ value }) { + if (!value) { + return 25 + } else if (value.kind === 'REDIRECT') { + return JSON.stringify(value.props).length + } else if (value.kind === 'IMAGE') { + throw new Error('invariant image should not be incremental-cache') + } else if (value.kind === 'FETCH') { + return JSON.stringify(value.data || '').length + } + // rough estimate of size of cache value + return ( + value.html.length + (JSON.stringify(value.pageData)?.length || 0) + ) + }, + }) + } + this.debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE + this.headers = {} + this.headers['Content-Type'] = 'application/json' + + if (FETCH_CACHE_HEADER in ctx._requestHeaders) { + const newHeaders = JSON.parse( + ctx._requestHeaders[FETCH_CACHE_HEADER] as string + ) + for (const k in newHeaders) { + this.headers[k] = newHeaders[k] + } + } + this.cacheEndpoint = `https://${ctx._requestHeaders['x-vercel-sc-host']}${ + ctx._requestHeaders['x-vercel-sc-basepath'] || '' + }` + if (this.debug) { + console.log('using cache endpoint', this.cacheEndpoint) + } + } + + public async get(key: string, fetchCache?: boolean) { + if (!fetchCache) return null + + let data = memoryCache?.get(key) + + // get data from fetch cache + if (!data) { + try { + const start = Date.now() + const res = await fetch( + `${this.cacheEndpoint}/v1/suspense-cache/getItems`, + { + method: 'POST', + body: JSON.stringify([key]), + headers: this.headers, + } + ) + + if (!res.ok) { + console.error(await res.text()) + throw new Error(`invalid response from cache ${res.status}`) + } + + const items = await res.json() + const item = items[key] + + if (!item || !item.value) { + console.log({ item }) + throw new Error(`invalid item returned`) + } + + const cached = JSON.parse(item.value) + + if (!cached || cached.kind !== 'FETCH') { + this.debug && console.log({ cached }) + throw new Error(`invalid cache value`) + } + + data = { + lastModified: Date.now() - item.age * 1000, + value: cached, + } + if (this.debug) { + console.log( + 'got fetch cache entry duration:', + Date.now() - start, + data + ) + } + + if (data) { + memoryCache?.set(key, data) + } + } catch (err) { + // unable to get data from fetch-cache + console.error(`Failed to get from fetch-cache`, err) + } + } + return data || null + } + + public async set( + key: string, + data: CacheHandlerValue['value'], + fetchCache?: boolean + ) { + if (!fetchCache) return + + memoryCache?.set(key, { + value: data, + lastModified: Date.now(), + }) + + try { + const start = Date.now() + const body = JSON.stringify([ + { + id: key, + value: JSON.stringify(data), + }, + ]) + + const res = await fetch( + `${this.cacheEndpoint}/v1/suspense-cache/setItems`, + { + method: 'POST', + headers: this.headers, + body: body, + } + ) + + if (!res.ok) { + this.debug && console.log(await res.text()) + throw new Error(`invalid response ${res.status}`) + } + + if (this.debug) { + console.log( + 'successfully set to fetch-cache duration:', + Date.now() - start, + body + ) + } + } catch (err) { + // unable to set to fetch-cache + console.error(`Failed to update fetch cache`, err) + } + return + } +} diff --git a/packages/next/server/lib/incremental-cache/file-system-cache.ts b/packages/next/server/lib/incremental-cache/file-system-cache.ts index 85f43051ba26068..fcba747e46e70fe 100644 --- a/packages/next/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/server/lib/incremental-cache/file-system-cache.ts @@ -2,11 +2,12 @@ import LRUCache from 'next/dist/compiled/lru-cache' import path from '../../../shared/lib/isomorphic/path' import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './' +let memoryCache: LRUCache | undefined + export default class FileSystemCache implements CacheHandler { private fs: CacheHandlerContext['fs'] private flushToDisk?: CacheHandlerContext['flushToDisk'] private serverDistDir: CacheHandlerContext['serverDistDir'] - private memoryCache?: LRUCache private appDir: boolean constructor(ctx: CacheHandlerContext) { @@ -15,8 +16,8 @@ export default class FileSystemCache implements CacheHandler { this.serverDistDir = ctx.serverDistDir this.appDir = !!ctx._appDir - if (ctx.maxMemoryCacheSize) { - this.memoryCache = new LRUCache({ + if (ctx.maxMemoryCacheSize && !memoryCache) { + memoryCache = new LRUCache({ max: ctx.maxMemoryCacheSize, length({ value }) { if (!value) { @@ -25,6 +26,8 @@ export default class FileSystemCache implements CacheHandler { return JSON.stringify(value.props).length } else if (value.kind === 'IMAGE') { throw new Error('invariant image should not be incremental-cache') + } else if (value.kind === 'FETCH') { + return JSON.stringify(value.data || '').length } // rough estimate of size of cache value return ( @@ -35,40 +38,55 @@ export default class FileSystemCache implements CacheHandler { } } - public async get(key: string) { - let data = this.memoryCache?.get(key) + public async get(key: string, fetchCache?: boolean) { + let data = memoryCache?.get(key) // let's check the disk for seed data if (!data) { try { - const { filePath: htmlPath, isAppPath } = await this.getFsPath( - `${key}.html` - ) - const html = await this.fs.readFile(htmlPath) - const pageData = isAppPath - ? await this.fs.readFile( - ( - await this.getFsPath(`${key}.rsc`, true) - ).filePath - ) - : JSON.parse( - await this.fs.readFile( - await ( - await this.getFsPath(`${key}.json`, false) + const { filePath, isAppPath } = await this.getFsPath({ + pathname: fetchCache ? key : `${key}.html`, + fetchCache, + }) + const fileData = await this.fs.readFile(filePath) + const { mtime } = await this.fs.stat(filePath) + + if (fetchCache) { + const lastModified = mtime.getTime() + data = { + lastModified, + value: JSON.parse(fileData), + } + } else { + const pageData = isAppPath + ? await this.fs.readFile( + ( + await this.getFsPath({ pathname: `${key}.rsc`, appDir: true }) ).filePath ) - ) - const { mtime } = await this.fs.stat(htmlPath) + : JSON.parse( + await this.fs.readFile( + await ( + await this.getFsPath({ + pathname: `${key}.json`, + appDir: false, + }) + ).filePath + ) + ) + data = { + lastModified: mtime.getTime(), + value: { + kind: 'PAGE', + html: fileData, + pageData, + }, + } + } - data = { - lastModified: mtime.getTime(), - value: { - kind: 'PAGE', - html, - pageData, - }, + if (data) { + memoryCache?.set(key, data) } - this.memoryCache?.set(key, data) } catch (_) { // unable to get data from disk } @@ -77,41 +95,66 @@ export default class FileSystemCache implements CacheHandler { } public async set(key: string, data: CacheHandlerValue['value']) { - if (!this.flushToDisk) return - - this.memoryCache?.set(key, { + memoryCache?.set(key, { value: data, lastModified: Date.now(), }) + if (!this.flushToDisk) return if (data?.kind === 'PAGE') { const isAppPath = typeof data.pageData === 'string' - const { filePath: htmlPath } = await this.getFsPath( - `${key}.html`, - isAppPath - ) + const { filePath: htmlPath } = await this.getFsPath({ + pathname: `${key}.html`, + appDir: isAppPath, + }) await this.fs.mkdir(path.dirname(htmlPath)) await this.fs.writeFile(htmlPath, data.html) await this.fs.writeFile( ( - await this.getFsPath( - `${key}.${isAppPath ? 'rsc' : 'json'}`, - isAppPath - ) + await this.getFsPath({ + pathname: `${key}.${isAppPath ? 'rsc' : 'json'}`, + appDir: isAppPath, + }) ).filePath, isAppPath ? data.pageData : JSON.stringify(data.pageData) ) + } else if (data?.kind === 'FETCH') { + const { filePath } = await this.getFsPath({ + pathname: key, + fetchCache: true, + }) + await this.fs.mkdir(path.dirname(filePath)) + await this.fs.writeFile(filePath, JSON.stringify(data)) } } - private async getFsPath( - pathname: string, + private async getFsPath({ + pathname, + appDir, + fetchCache, + }: { + pathname: string appDir?: boolean - ): Promise<{ + fetchCache?: boolean + }): Promise<{ filePath: string isAppPath: boolean }> { + if (fetchCache) { + // we store in .next/cache/fetch-cache so it can be persisted + // across deploys + return { + filePath: path.join( + this.serverDistDir, + '..', + 'cache', + 'fetch-cache', + pathname + ), + isAppPath: false, + } + } let isAppPath = false let filePath = path.join(this.serverDistDir, 'pages', pathname) diff --git a/packages/next/server/lib/incremental-cache/index.ts b/packages/next/server/lib/incremental-cache/index.ts index 81a325ff59509f8..3805d418f101ab7 100644 --- a/packages/next/server/lib/incremental-cache/index.ts +++ b/packages/next/server/lib/incremental-cache/index.ts @@ -3,6 +3,7 @@ import FileSystemCache from './file-system-cache' import { PrerenderManifest } from '../../../build' import path from '../../../shared/lib/isomorphic/path' import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' +import FetchCache from './fetch-cache' import { IncrementalCacheValue, IncrementalCacheEntry, @@ -18,7 +19,8 @@ export interface CacheHandlerContext { flushToDisk?: boolean serverDistDir: string maxMemoryCacheSize?: number - _appDir?: boolean + _appDir: boolean + _requestHeaders: IncrementalCache['requestHeaders'] } export interface CacheHandlerValue { @@ -30,13 +32,17 @@ export class CacheHandler { // eslint-disable-next-line constructor(_ctx: CacheHandlerContext) {} - public async get(_key: string): Promise { + public async get( + _key: string, + _fetchCache?: boolean + ): Promise { return {} as any } public async set( _key: string, - _data: IncrementalCacheValue | null + _data: IncrementalCacheValue | null, + _fetchCache?: boolean ): Promise {} } @@ -44,13 +50,18 @@ export class IncrementalCache { dev?: boolean cacheHandler: CacheHandler prerenderManifest: PrerenderManifest + requestHeaders: Record + minimalMode?: boolean constructor({ fs, dev, appDir, flushToDisk, + fetchCache, + minimalMode, serverDistDir, + requestHeaders, maxMemoryCacheSize, getPrerenderManifest, incrementalCacheHandlerPath, @@ -58,8 +69,11 @@ export class IncrementalCache { fs: CacheFs dev: boolean appDir?: boolean + fetchCache?: boolean + minimalMode?: boolean serverDistDir: string flushToDisk?: boolean + requestHeaders: IncrementalCache['requestHeaders'] maxMemoryCacheSize?: number incrementalCacheHandlerPath?: string getPrerenderManifest: () => PrerenderManifest @@ -71,11 +85,17 @@ export class IncrementalCache { cacheHandlerMod = cacheHandlerMod.default || cacheHandlerMod } + if (minimalMode && fetchCache) { + cacheHandlerMod = FetchCache + } + if (process.env.__NEXT_TEST_MAX_ISR_CACHE) { // Allow cache size to be overridden for testing purposes maxMemoryCacheSize = parseInt(process.env.__NEXT_TEST_MAX_ISR_CACHE, 10) } this.dev = dev + this.minimalMode = minimalMode + this.requestHeaders = requestHeaders this.prerenderManifest = getPrerenderManifest() this.cacheHandler = new (cacheHandlerMod as typeof CacheHandler)({ dev, @@ -83,7 +103,8 @@ export class IncrementalCache { flushToDisk, serverDistDir, maxMemoryCacheSize, - _appDir: appDir, + _appDir: !!appDir, + _requestHeaders: requestHeaders, }) } @@ -110,19 +131,75 @@ export class IncrementalCache { return revalidateAfter } - _getPathname(pathname: string) { - return normalizePagePath(pathname) + _getPathname(pathname: string, fetchCache?: boolean) { + return fetchCache ? pathname : normalizePagePath(pathname) + } + + // x-ref: https://github.com/facebook/react/blob/2655c9354d8e1c54ba888444220f63e836925caa/packages/react/src/ReactFetch.js#L23 + async fetchCacheKey(url: string, init: RequestInit = {}): Promise { + const cacheString = JSON.stringify([ + url, + init.method, + init.headers, + init.mode, + init.redirect, + init.credentials, + init.referrer, + init.referrerPolicy, + init.integrity, + init.next, + init.cache, + ]) + let cacheKey: string + + if (process.env.NEXT_RUNTIME === 'edge') { + function bufferToHex(buffer: ArrayBuffer): string { + return Array.prototype.map + .call(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, '0')) + .join('') + } + const buffer = new TextEncoder().encode(cacheString) + cacheKey = bufferToHex(await crypto.subtle.digest('SHA-256', buffer)) + } else { + const crypto = require('crypto') as typeof import('crypto') + cacheKey = crypto.createHash('sha256').update(cacheString).digest('hex') + } + return cacheKey } // get data from cache if available - async get(pathname: string): Promise { + async get( + pathname: string, + fetchCache?: boolean + ): Promise { // we don't leverage the prerender cache in dev mode // so that getStaticProps is always called for easier debugging if (this.dev) return null - pathname = this._getPathname(pathname) + pathname = this._getPathname(pathname, fetchCache) let entry: IncrementalCacheEntry | null = null - const cacheData = await this.cacheHandler.get(pathname) + const cacheData = await this.cacheHandler.get(pathname, fetchCache) + + if (cacheData?.value?.kind === 'FETCH') { + const data = cacheData.value.data + const age = Math.round( + (Date.now() - (cacheData.lastModified || 0)) / 1000 + ) + const revalidate = cacheData.value.revalidate + + return { + isStale: age > revalidate, + value: { + kind: 'FETCH', + data, + age, + revalidate, + isStale: age > revalidate, + }, + revalidateAfter: + (cacheData.lastModified || Date.now()) + revalidate * 1000, + } + } const curRevalidate = this.prerenderManifest.routes[toRoute(pathname)]?.initialRevalidateSeconds @@ -159,7 +236,7 @@ export class IncrementalCache { curRevalidate, revalidateAfter, } - this.set(pathname, entry.value, curRevalidate) + this.set(pathname, entry.value, curRevalidate, fetchCache) } return entry } @@ -168,16 +245,17 @@ export class IncrementalCache { async set( pathname: string, data: IncrementalCacheValue | null, - revalidateSeconds?: number | false + revalidateSeconds?: number | false, + fetchCache?: boolean ) { if (this.dev) return - pathname = this._getPathname(pathname) + pathname = this._getPathname(pathname, fetchCache) try { // we use the prerender manifest memory instance // to store revalidate timings for calculating // revalidateAfter values so we update this on set - if (typeof revalidateSeconds !== 'undefined') { + if (typeof revalidateSeconds !== 'undefined' && !fetchCache) { this.prerenderManifest.routes[pathname] = { dataRoute: path.posix.join( '/_next/data', @@ -187,7 +265,7 @@ export class IncrementalCache { initialRevalidateSeconds: revalidateSeconds, } } - await this.cacheHandler.set(pathname, data) + await this.cacheHandler.set(pathname, data, fetchCache) } catch (error) { console.warn('Failed to update prerender cache for', pathname, error) } diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index db89c1cfe9dc236..058f23239ac2ebe 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -228,15 +228,7 @@ export default class NextNodeServer extends BaseServer { } if (!this.minimalMode) { - const { ImageOptimizerCache } = - require('./image-optimizer') as typeof import('./image-optimizer') - this.imageResponseCache = new ResponseCache( - new ImageOptimizerCache({ - distDir: this.distDir, - nextConfig: this.nextConfig, - }), - this.minimalMode - ) + this.imageResponseCache = new ResponseCache(this.minimalMode) } if (!options.dev) { @@ -278,12 +270,23 @@ export default class NextNodeServer extends BaseServer { loadEnvConfig(this.dir, dev, Log, forceReload) } - protected getResponseCache({ dev }: { dev: boolean }) { - const incrementalCache = new IncrementalCache({ + protected getIncrementalCache({ + requestHeaders, + }: { + requestHeaders: IncrementalCache['requestHeaders'] + }) { + const dev = !!this.renderOpts.dev + // incremental-cache is request specific with a shared + // although can have shared caches in module scope + // per-cache handler + return new IncrementalCache({ fs: this.getCacheFilesystem(), dev, - serverDistDir: this.serverDistDir, + requestHeaders, appDir: this.hasAppDir, + minimalMode: this.minimalMode, + serverDistDir: this.serverDistDir, + fetchCache: this.nextConfig.experimental.fetchCache, maxMemoryCacheSize: this.nextConfig.experimental.isrMemoryCacheSize, flushToDisk: !this.minimalMode && this.nextConfig.experimental.isrFlushToDisk, @@ -303,8 +306,10 @@ export default class NextNodeServer extends BaseServer { } }, }) + } - return new ResponseCache(incrementalCache, this.minimalMode) + protected getResponseCache() { + return new ResponseCache(this.minimalMode) } protected getPublicDir(): string { @@ -383,7 +388,15 @@ export default class NextNodeServer extends BaseServer { finished: true, } } - const { getHash, ImageOptimizerCache, sendResponse, ImageError } = + const { ImageOptimizerCache } = + require('./image-optimizer') as typeof import('./image-optimizer') + + const imageOptimizerCache = new ImageOptimizerCache({ + distDir: this.distDir, + nextConfig: this.nextConfig, + }) + + const { getHash, sendResponse, ImageError } = require('./image-optimizer') as typeof import('./image-optimizer') if (!this.imageResponseCache) { @@ -391,7 +404,6 @@ export default class NextNodeServer extends BaseServer { 'invariant image optimizer cache was not initialized' ) } - const imagesConfig = this.nextConfig.images if (imagesConfig.loader !== 'default') { @@ -434,7 +446,9 @@ export default class NextNodeServer extends BaseServer { revalidate: maxAge, } }, - {} + { + incrementalCache: imageOptimizerCache, + } ) if (cacheEntry?.value?.kind !== 'IMAGE') { diff --git a/packages/next/server/response-cache/index.ts b/packages/next/server/response-cache/index.ts index d6a918955ee1986..6f22b1adc8ba449 100644 --- a/packages/next/server/response-cache/index.ts +++ b/packages/next/server/response-cache/index.ts @@ -10,7 +10,6 @@ import RenderResult from '../render-result' export * from './types' export default class ResponseCache { - incrementalCache: IncrementalCache pendingResponses: Map> previousCacheItem?: { key: string @@ -19,8 +18,7 @@ export default class ResponseCache { } minimalMode?: boolean - constructor(incrementalCache: IncrementalCache, minimalMode: boolean) { - this.incrementalCache = incrementalCache + constructor(minimalMode: boolean) { this.pendingResponses = new Map() this.minimalMode = minimalMode } @@ -31,8 +29,10 @@ export default class ResponseCache { context: { isManualRevalidate?: boolean isPrefetch?: boolean + incrementalCache: IncrementalCache } ): Promise { + const { incrementalCache } = context // ensure manual revalidate doesn't block normal requests const pendingResponseKey = key ? `${key}-${context.isManualRevalidate ? '1' : '0'}` @@ -41,6 +41,7 @@ export default class ResponseCache { const pendingResponse = pendingResponseKey ? this.pendingResponses.get(pendingResponseKey) : null + if (pendingResponse) { return pendingResponse } @@ -92,9 +93,15 @@ export default class ResponseCache { let cachedResponse: IncrementalCacheItem = null try { cachedResponse = - key && !this.minimalMode ? await this.incrementalCache.get(key) : null + key && !this.minimalMode ? await incrementalCache.get(key) : null if (cachedResponse && !context.isManualRevalidate) { + if (cachedResponse.value?.kind === 'FETCH') { + throw new Error( + `invariant: unexpected cachedResponse of kind fetch in response cache` + ) + } + resolve({ isStale: cachedResponse.isStale, revalidate: cachedResponse.curRevalidate, @@ -136,7 +143,7 @@ export default class ResponseCache { expiresAt: Date.now() + 1000, } } else { - await this.incrementalCache.set( + await incrementalCache.set( key, cacheEntry.value?.kind === 'PAGE' ? { @@ -159,7 +166,7 @@ export default class ResponseCache { // when a getStaticProps path is erroring we automatically re-set the // existing cache under a new expiration to prevent non-stop retrying if (cachedResponse && key) { - await this.incrementalCache.set( + await incrementalCache.set( key, cachedResponse.value, Math.min(Math.max(cachedResponse.revalidate || 3, 3), 30) diff --git a/packages/next/server/response-cache/types.ts b/packages/next/server/response-cache/types.ts index 63c2d37c4f6598d..a6459121b62cfb3 100644 --- a/packages/next/server/response-cache/types.ts +++ b/packages/next/server/response-cache/types.ts @@ -7,10 +7,19 @@ export interface ResponseCacheBase { context: { isManualRevalidate?: boolean isPrefetch?: boolean + incrementalCache: IncrementalCache } ): Promise } +export interface CachedFetchValue { + kind: 'FETCH' + data: any + isStale: boolean + age: number + revalidate: number +} + export interface CachedRedirectValue { kind: 'REDIRECT' props: Object @@ -53,6 +62,7 @@ export type IncrementalCacheValue = | CachedRedirectValue | IncrementalCachedPageValue | CachedImageValue + | CachedFetchValue export type ResponseCacheValue = | CachedRedirectValue diff --git a/packages/next/server/response-cache/web.ts b/packages/next/server/response-cache/web.ts index 619b0bd81f18a9d..0fea0028a5e0f14 100644 --- a/packages/next/server/response-cache/web.ts +++ b/packages/next/server/response-cache/web.ts @@ -24,6 +24,7 @@ export default class WebResponseCache { context: { isManualRevalidate?: boolean isPrefetch?: boolean + incrementalCache: any } ): Promise { // ensure manual revalidate doesn't block normal requests diff --git a/packages/next/server/web-server.ts b/packages/next/server/web-server.ts index 5cfdaca5c6331ab..e236f6b881f75bd 100644 --- a/packages/next/server/web-server.ts +++ b/packages/next/server/web-server.ts @@ -54,6 +54,9 @@ export default class NextWebServer extends BaseServer { // For the web server layer, compression is automatically handled by the // upstream proxy (edge runtime or node server) and we can simply skip here. } + protected getIncrementalCache() { + return {} as any + } protected getResponseCache() { return new WebResponseCache(this.minimalMode) } diff --git a/packages/next/server/web/adapter.ts b/packages/next/server/web/adapter.ts index 062dca353ae0b1a..9595dd62084619c 100644 --- a/packages/next/server/web/adapter.ts +++ b/packages/next/server/web/adapter.ts @@ -11,6 +11,7 @@ import { NextURL } from './next-url' import { stripInternalSearchParams } from '../internal-utils' import { normalizeRscPath } from '../../shared/lib/router/utils/app-paths' import { + FETCH_CACHE_HEADER, NEXT_ROUTER_PREFETCH, NEXT_ROUTER_STATE_TREE, RSC, @@ -45,6 +46,7 @@ const FLIGHT_PARAMETERS = [ [RSC], [NEXT_ROUTER_STATE_TREE], [NEXT_ROUTER_PREFETCH], + [FETCH_CACHE_HEADER], ] as const export async function adapter(params: { diff --git a/packages/next/types/global.d.ts b/packages/next/types/global.d.ts index e07479561b08e4a..2332e513e672911 100644 --- a/packages/next/types/global.d.ts +++ b/packages/next/types/global.d.ts @@ -39,7 +39,7 @@ interface Window { } interface NextFetchRequestConfig { - revalidate?: number + revalidate?: number | false } interface RequestInit { diff --git a/test/e2e/app-dir/app-static.test.ts b/test/e2e/app-dir/app-static.test.ts index c3f3f3a66eaf73b..66a08b6c3a8071f 100644 --- a/test/e2e/app-dir/app-static.test.ts +++ b/test/e2e/app-dir/app-static.test.ts @@ -76,6 +76,10 @@ describe('app-dir static/dynamic handling', () => { 'ssr-auto/cache-no-store/page.js', 'ssr-auto/fetch-revalidate-zero/page.js', 'ssr-forced/page.js', + 'variable-revalidate/no-store/page.js', + 'variable-revalidate/revalidate-3.html', + 'variable-revalidate/revalidate-3.rsc', + 'variable-revalidate/revalidate-3/page.js', ]) }) @@ -167,6 +171,11 @@ describe('app-dir static/dynamic handling', () => { initialRevalidateSeconds: false, srcRoute: '/ssg-preview/[[...route]]', }, + '/variable-revalidate/revalidate-3': { + dataRoute: '/variable-revalidate/revalidate-3.rsc', + initialRevalidateSeconds: 3, + srcRoute: '/variable-revalidate/revalidate-3', + }, }) expect(manifest.dynamicRoutes).toEqual({ '/blog/[author]/[slug]': { diff --git a/test/e2e/app-dir/app-static/app/ssr-auto/fetch-revalidate-zero/page.js b/test/e2e/app-dir/app-static/app/ssr-auto/fetch-revalidate-zero/page.js index 34706f5284a4a6f..823d8131ba65335 100644 --- a/test/e2e/app-dir/app-static/app/ssr-auto/fetch-revalidate-zero/page.js +++ b/test/e2e/app-dir/app-static/app/ssr-auto/fetch-revalidate-zero/page.js @@ -19,6 +19,3 @@ export default function Page() { ) } - -// TODO-APP: remove revalidate config once next.revalidate is supported -export const revalidate = 0 diff --git a/test/e2e/app-dir/app-static/app/variable-revalidate/layout.js b/test/e2e/app-dir/app-static/app/variable-revalidate/layout.js new file mode 100644 index 000000000000000..3f9ed053c5ad29f --- /dev/null +++ b/test/e2e/app-dir/app-static/app/variable-revalidate/layout.js @@ -0,0 +1,18 @@ +import { cache, use } from 'react' + +export default function Layout({ children }) { + const getData = cache(() => + fetch('https://next-data-api-endpoint.vercel.app/api/random?layout', { + next: { revalidate: 10 }, + }).then((res) => res.text()) + ) + const dataPromise = getData() + const data = use(dataPromise) + + return ( + <> +

revalidate 10: {data}

+ {children} + + ) +} diff --git a/test/e2e/app-dir/app-static/app/variable-revalidate/no-store/page.js b/test/e2e/app-dir/app-static/app/variable-revalidate/no-store/page.js new file mode 100644 index 000000000000000..b0c99184ef876ec --- /dev/null +++ b/test/e2e/app-dir/app-static/app/variable-revalidate/no-store/page.js @@ -0,0 +1,19 @@ +import { cache, use } from 'react' + +export default function Page() { + const getData = cache(() => + fetch('https://next-data-api-endpoint.vercel.app/api/random?page', { + cache: 'no-store', + }).then((res) => res.text()) + ) + const dataPromise = getData() + const data = use(dataPromise) + + return ( + <> +

/variable-revalidate/no-cache

+

no-store: {data}

+

{Date.now()}

+ + ) +} diff --git a/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-3/page.js b/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-3/page.js new file mode 100644 index 000000000000000..da03c9760ef2d33 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-3/page.js @@ -0,0 +1,19 @@ +import { cache, use } from 'react' + +export default function Page() { + const getData = cache(() => + fetch('https://next-data-api-endpoint.vercel.app/api/random?page', { + next: { revalidate: 3 }, + }).then((res) => res.text()) + ) + const dataPromise = getData() + const data = use(dataPromise) + + return ( + <> +

/variable-revalidate/revalidate-3

+

revalidate 3: {data}

+

{Date.now()}

+ + ) +} diff --git a/test/e2e/app-dir/app-static/next.config.js b/test/e2e/app-dir/app-static/next.config.js index 771ea0e5638f921..e03d15af21fbdb5 100644 --- a/test/e2e/app-dir/app-static/next.config.js +++ b/test/e2e/app-dir/app-static/next.config.js @@ -1,6 +1,7 @@ module.exports = { experimental: { appDir: true, + fetchCache: true, }, // assetPrefix: '/assets', rewrites: async () => {