Update app dir cache handling (#46081)

Updates handling for app dir caching for edge runtime and adds additional tests. 

x-ref: NEXT-511

Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com>
This commit is contained in:
Luba Kravchenko 2023-02-21 23:10:25 -08:00 committed by GitHub
parent 34e08e8d70
commit 9d2824e995
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 244 additions and 69 deletions

View file

@ -895,6 +895,19 @@ export default abstract class Server<ServerOptions extends Options = Options> {
): Promise<void> {
this.handleCompression(req, res)
// set incremental cache to request meta so it can
// be passed down for edge functions and the fetch disk
// cache can be leveraged locally
if (
!(globalThis as any).__incrementalCache &&
!getRequestMeta(req, '_nextIncrementalCache')
) {
const incrementalCache = this.getIncrementalCache({
requestHeaders: Object.assign({}, req.headers),
})
addRequestMeta(req, '_nextIncrementalCache', incrementalCache)
}
try {
const matched = await this.router.execute(req, res, parsedUrl)
if (matched) {
@ -1346,14 +1359,15 @@ export default abstract class Server<ServerOptions extends Options = Options> {
ssgCacheKey =
ssgCacheKey === '/index' && pathname === '/' ? '/' : ssgCacheKey
}
const incrementalCache = this.getIncrementalCache({
requestHeaders: Object.assign({}, req.headers),
})
if (
this.nextConfig.experimental.fetchCache &&
(!isEdgeRuntime(opts.runtime) ||
(this.serverOptions as any).webServerConfig)
) {
// use existing incrementalCache instance if available
const incrementalCache =
(globalThis as any).__incrementalCache ||
this.getIncrementalCache({
requestHeaders: Object.assign({}, req.headers),
})
if (this.nextConfig.experimental.fetchCache) {
delete req.headers[FETCH_CACHE_HEADER]
}
let isRevalidate = false

View file

@ -62,42 +62,49 @@ export default class FetchCache implements CacheHandler {
try {
const start = Date.now()
const res = await fetch(
`${this.cacheEndpoint}/v1/suspense-cache/getItems`,
`${this.cacheEndpoint}/v1/suspense-cache/${key}`,
{
method: 'POST',
body: JSON.stringify([key]),
method: 'GET',
headers: this.headers,
}
)
if (res.status === 404) {
if (this.debug) {
console.log(
`no fetch cache entry for ${key}, duration: ${
Date.now() - start
}ms`
)
}
return null
}
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) {
throw new Error(`invalid item returned ${JSON.stringify({ item })}`)
}
const cached = JSON.parse(item.value)
const cached = await res.json()
if (!cached || cached.kind !== 'FETCH') {
this.debug && console.log({ cached })
throw new Error(`invalid cache value`)
}
const cacheState = res.headers.get('x-vercel-cache-state')
const age = res.headers.get('age')
data = {
lastModified: Date.now() - item.age * 1000,
age: age === null ? undefined : parseInt(age, 10),
cacheState: cacheState || undefined,
value: cached,
}
if (this.debug) {
console.log(
`got fetch cache entry for ${key}, duration: ${
Date.now() - start
}ms, size: ${item.value.length}`
}ms, size: ${Object.keys(cached).length}`
)
}
@ -106,7 +113,9 @@ export default class FetchCache implements CacheHandler {
}
} catch (err) {
// unable to get data from fetch-cache
console.error(`Failed to get from fetch-cache`, err)
if (this.debug) {
console.error(`Failed to get from fetch-cache`, err)
}
}
}
return data || null
@ -126,15 +135,16 @@ export default class FetchCache implements CacheHandler {
try {
const start = Date.now()
const body = JSON.stringify([
{
id: key,
value: JSON.stringify(data),
},
])
if (data !== null && 'revalidate' in data) {
this.headers['x-vercel-revalidate'] = data.revalidate.toString()
}
if (data !== null && 'data' in data) {
this.headers['x-vercel-cache-control'] =
data.data.headers['cache-control']
}
const body = JSON.stringify(data)
const res = await fetch(
`${this.cacheEndpoint}/v1/suspense-cache/setItems`,
`${this.cacheEndpoint}/v1/suspense-cache/${key}`,
{
method: 'POST',
headers: this.headers,
@ -156,7 +166,9 @@ export default class FetchCache implements CacheHandler {
}
} catch (err) {
// unable to set to fetch-cache
console.error(`Failed to update fetch cache`, err)
if (this.debug) {
console.error(`Failed to update fetch cache`, err)
}
}
return
}

View file

@ -1,16 +1,25 @@
import LRUCache from 'next/dist/compiled/lru-cache'
import path from '../../../shared/lib/isomorphic/path'
import { CacheFs } from '../../../shared/lib/utils'
import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './'
type FileSystemCacheContext = Omit<
CacheHandlerContext,
'fs' | 'serverDistDir'
> & {
fs: CacheFs
serverDistDir: string
}
let memoryCache: LRUCache<string, CacheHandlerValue> | undefined
export default class FileSystemCache implements CacheHandler {
private fs: CacheHandlerContext['fs']
private flushToDisk?: CacheHandlerContext['flushToDisk']
private serverDistDir: CacheHandlerContext['serverDistDir']
private fs: FileSystemCacheContext['fs']
private flushToDisk?: FileSystemCacheContext['flushToDisk']
private serverDistDir: FileSystemCacheContext['serverDistDir']
private appDir: boolean
constructor(ctx: CacheHandlerContext) {
constructor(ctx: FileSystemCacheContext) {
this.fs = ctx.fs
this.flushToDisk = ctx.flushToDisk
this.serverDistDir = ctx.serverDistDir

View file

@ -14,10 +14,10 @@ function toRoute(pathname: string): string {
}
export interface CacheHandlerContext {
fs: CacheFs
fs?: CacheFs
dev?: boolean
flushToDisk?: boolean
serverDistDir: string
serverDistDir?: string
maxMemoryCacheSize?: number
_appDir: boolean
_requestHeaders: IncrementalCache['requestHeaders']
@ -25,6 +25,8 @@ export interface CacheHandlerContext {
export interface CacheHandlerValue {
lastModified?: number
age?: number
cacheState?: string
value: IncrementalCacheValue | null
}
@ -48,7 +50,7 @@ export class CacheHandler {
export class IncrementalCache {
dev?: boolean
cacheHandler: CacheHandler
cacheHandler?: CacheHandler
prerenderManifest: PrerenderManifest
requestHeaders: Record<string, undefined | string | string[]>
minimalMode?: boolean
@ -66,19 +68,23 @@ export class IncrementalCache {
getPrerenderManifest,
incrementalCacheHandlerPath,
}: {
fs: CacheFs
fs?: CacheFs
dev: boolean
appDir?: boolean
fetchCache?: boolean
minimalMode?: boolean
serverDistDir: string
serverDistDir?: string
flushToDisk?: boolean
requestHeaders: IncrementalCache['requestHeaders']
maxMemoryCacheSize?: number
incrementalCacheHandlerPath?: string
getPrerenderManifest: () => PrerenderManifest
}) {
let cacheHandlerMod: any = FileSystemCache
let cacheHandlerMod: any
if (fs && serverDistDir) {
cacheHandlerMod = FileSystemCache
}
if (process.env.NEXT_RUNTIME !== 'edge' && incrementalCacheHandlerPath) {
cacheHandlerMod = require(incrementalCacheHandlerPath)
@ -97,15 +103,18 @@ export class IncrementalCache {
this.minimalMode = minimalMode
this.requestHeaders = requestHeaders
this.prerenderManifest = getPrerenderManifest()
this.cacheHandler = new (cacheHandlerMod as typeof CacheHandler)({
dev,
fs,
flushToDisk: flushToDisk && !dev,
serverDistDir,
maxMemoryCacheSize,
_appDir: !!appDir,
_requestHeaders: requestHeaders,
})
if (cacheHandlerMod) {
this.cacheHandler = new (cacheHandlerMod as typeof CacheHandler)({
dev,
fs,
flushToDisk: flushToDisk && !dev,
serverDistDir,
maxMemoryCacheSize,
_appDir: !!appDir,
_requestHeaders: requestHeaders,
})
}
}
private calculateRevalidate(
@ -184,23 +193,25 @@ export class IncrementalCache {
pathname = this._getPathname(pathname, fetchCache)
let entry: IncrementalCacheEntry | null = null
const cacheData = await this.cacheHandler.get(pathname, fetchCache)
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
const age =
cacheData.age ||
Math.round((Date.now() - (cacheData.lastModified || 0)) / 1000)
const isStale = cacheData.cacheState
? cacheData.cacheState !== 'fresh'
: age > revalidate
const data = cacheData.value.data
return {
isStale: age > revalidate,
isStale: isStale,
value: {
kind: 'FETCH',
data,
age,
revalidate,
isStale: age > revalidate,
revalidate: revalidate,
},
revalidateAfter:
(cacheData.lastModified || Date.now()) + revalidate * 1000,
@ -272,7 +283,7 @@ export class IncrementalCache {
initialRevalidateSeconds: revalidateSeconds,
}
}
await this.cacheHandler.set(pathname, data, fetchCache)
await this.cacheHandler?.set(pathname, data, fetchCache)
} catch (error) {
console.warn('Failed to update prerender cache for', pathname, error)
}

View file

@ -16,8 +16,10 @@ export function patchFetch({
const { DynamicServerError } = serverHooks
const originFetch = globalThis.fetch
globalThis.fetch = async (input, init) => {
const originFetch = fetch
// @ts-ignore
// eslint-disable-next-line no-native-reassign
fetch = async (input: RequestInfo | URL, init: RequestInit | undefined) => {
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
// If the staticGenerationStore is not available, we can't do any special
@ -75,8 +77,6 @@ export function patchFetch({
cacheKey,
{
kind: 'FETCH',
isStale: false,
age: 0,
data: {
headers: Object.fromEntries(clonedRes.headers.entries()),
body: base64Body,

View file

@ -2143,6 +2143,7 @@ export default class NextNodeServer extends BaseServer {
},
useCache: !this.renderOpts.dev,
onWarning: params.onWarning,
incrementalCache: getRequestMeta(params.req, '_nextIncrementalCache'),
})
params.res.statusCode = result.response.status

View file

@ -26,6 +26,7 @@ export interface RequestMeta {
_protocol?: string
_nextDataNormalizing?: boolean
_nextMatch?: RouteMatch
_nextIncrementalCache?: any
}
export function getRequestMeta(

View file

@ -14,9 +14,11 @@ export interface ResponseCacheBase {
export interface CachedFetchValue {
kind: 'FETCH'
data: any
isStale: boolean
age: number
data: {
headers: { [k: string]: string }
body: string
status?: number
}
revalidate: number
}

View file

@ -26,6 +26,7 @@ import {
normalizeVercelUrl,
} from '../build/webpack/loaders/next-serverless-loader/utils'
import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex'
import { IncrementalCache } from './lib/incremental-cache'
interface WebServerOptions extends Options {
webServerConfig: {
page: string
@ -52,8 +53,39 @@ export default class NextWebServer extends BaseServer<WebServerOptions> {
// 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 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({
dev,
requestHeaders,
appDir: this.hasAppDir,
minimalMode: this.minimalMode,
fetchCache: this.nextConfig.experimental.fetchCache,
maxMemoryCacheSize: this.nextConfig.experimental.isrMemoryCacheSize,
flushToDisk: false,
incrementalCacheHandlerPath:
this.nextConfig.experimental?.incrementalCacheHandlerPath,
getPrerenderManifest: () => {
if (dev) {
return {
version: -1 as any, // letting us know this doesn't conform to spec
routes: {},
dynamicRoutes: {},
notFoundRoutes: [],
preview: null as any, // `preview` is special case read in next-dev-server
}
} else {
return this.getPrerenderManifest()
}
},
})
}
protected getResponseCache() {
return new WebResponseCache(this.minimalMode)

View file

@ -22,6 +22,7 @@ type RunnerFn = (params: {
useCache: boolean
edgeFunctionEntry: Pick<EdgeFunctionDefinition, 'wasm' | 'assets'>
distDir: string
incrementalCache?: any
}) => Promise<FetchEventResult>
/**
@ -52,6 +53,7 @@ export const getRuntimeContext = async (params: {
edgeFunctionEntry: any
distDir: string
paths: string[]
incrementalCache?: any
}): Promise<EdgeRuntime<any>> => {
const { runtime, evaluateInContext } = await getModuleContext({
moduleName: params.name,
@ -61,6 +63,7 @@ export const getRuntimeContext = async (params: {
edgeFunctionEntry: params.edgeFunctionEntry,
distDir: params.distDir,
})
runtime.context.globalThis.__incrementalCache = params.incrementalCache
for (const paramPath of params.paths) {
evaluateInContext(paramPath)

View file

@ -85,6 +85,8 @@ createNextDescribe(
'static-to-dynamic-error/[id].html',
'static-to-dynamic-error/[id].rsc',
'static-to-dynamic-error/[id]/page.js',
'variable-revalidate-edge/no-store/page.js',
'variable-revalidate-edge/revalidate-3/page.js',
'variable-revalidate/no-store/page.js',
'variable-revalidate/revalidate-3.html',
'variable-revalidate/revalidate-3.rsc',
@ -397,6 +399,34 @@ createNextDescribe(
}
})
it('should honor fetch cache correctly (edge)', async () => {
await fetchViaHTTP(next.url, '/variable-revalidate-edge/revalidate-3')
const res = await fetchViaHTTP(
next.url,
'/variable-revalidate-edge/revalidate-3'
)
expect(res.status).toBe(200)
const html = await res.text()
const $ = cheerio.load(html)
const layoutData = $('#layout-data').text()
const pageData = $('#page-data').text()
for (let i = 0; i < 3; i++) {
const res2 = await fetchViaHTTP(
next.url,
'/variable-revalidate-edge/revalidate-3'
)
expect(res2.status).toBe(200)
const html2 = await res2.text()
const $2 = cheerio.load(html2)
expect($2('#layout-data').text()).toBe(layoutData)
expect($2('#page-data').text()).toBe(pageData)
}
})
it('Should not throw Dynamic Server Usage error when using generateStaticParams with previewData', async () => {
const browserOnIndexPage = await next.browser('/ssg-preview')

View file

@ -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 (
<>
<p id="layout-data">revalidate 10: {data}</p>
{children}
</>
)
}

View file

@ -0,0 +1,21 @@
import { cache, use } from 'react'
export const runtime = 'experimental-edge'
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 (
<>
<p id="page">/variable-revalidate/no-cache</p>
<p id="page-data">no-store: {data}</p>
<p id="now">{Date.now()}</p>
</>
)
}

View file

@ -0,0 +1,21 @@
import { cache, use } from 'react'
export const runtime = 'experimental-edge'
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 (
<>
<p id="page">/variable-revalidate/revalidate-3</p>
<p id="page-data">revalidate 3: {data}</p>
<p id="now">{Date.now()}</p>
</>
)
}