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:
parent
34e08e8d70
commit
9d2824e995
14 changed files with 244 additions and 69 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -26,6 +26,7 @@ export interface RequestMeta {
|
|||
_protocol?: string
|
||||
_nextDataNormalizing?: boolean
|
||||
_nextMatch?: RouteMatch
|
||||
_nextIncrementalCache?: any
|
||||
}
|
||||
|
||||
export function getRequestMeta(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
Loading…
Reference in a new issue