rsnext/packages/next/server/response-cache.ts
JJ Kasper 923fdcd2c5
Update to not trigger revalidation during prefetch (#37201)
Continuation of https://github.com/vercel/next.js/pull/34498 this updates to send a `purpose: prefetch` header (related [w3c discussion](https://github.com/w3c/resource-hints/issues/74)) when prefetching data routes and then on the server we skip revalidating when this header is set. 

When a client-transition is actually made we send a background HEAD (non-blocking) request to the data route without the `purpose: prefetch` header which signals a revalidation should occur if the data is stale.  

This helps alleviate the number of revalidations occurring currently caused by prefetches as a path can be prefetched but not visited during a session so may not need to be revalidated yet. 

Fixes: https://github.com/vercel/next.js/issues/17758
x-ref: https://github.com/vercel/next.js/discussions/20521
x-ref: [slack thread](https://vercel.slack.com/archives/C031QM96T0A/p1645129445819139?thread_ts=1645124478.067049&cid=C031QM96T0A)
2022-05-29 23:05:23 +00:00

252 lines
7 KiB
TypeScript

import RenderResult from './render-result'
export interface CachedRedirectValue {
kind: 'REDIRECT'
props: Object
}
interface CachedPageValue {
kind: 'PAGE'
// this needs to be a RenderResult so since renderResponse
// expects that type instead of a string
html: RenderResult
pageData: Object
}
export interface CachedImageValue {
kind: 'IMAGE'
etag: string
buffer: Buffer
extension: string
isMiss?: boolean
isStale?: boolean
}
interface IncrementalCachedPageValue {
kind: 'PAGE'
// this needs to be a string since the cache expects to store
// the string value
html: string
pageData: Object
}
export type IncrementalCacheEntry = {
curRevalidate?: number | false
// milliseconds to revalidate after
revalidateAfter: number | false
isStale?: boolean
value: IncrementalCacheValue | null
}
export type IncrementalCacheValue =
| CachedRedirectValue
| IncrementalCachedPageValue
| CachedImageValue
export type ResponseCacheValue =
| CachedRedirectValue
| CachedPageValue
| CachedImageValue
export type ResponseCacheEntry = {
revalidate?: number | false
value: ResponseCacheValue | null
isStale?: boolean
isMiss?: boolean
}
type ResponseGenerator = (
hasResolved: boolean,
hadCache: boolean
) => Promise<ResponseCacheEntry | null>
type IncrementalCacheItem = {
revalidateAfter?: number | false
curRevalidate?: number | false
revalidate?: number | false
value: IncrementalCacheValue | null
isStale?: boolean
isMiss?: boolean
} | null
interface IncrementalCache {
get: (key: string) => Promise<IncrementalCacheItem>
set: (
key: string,
data: IncrementalCacheValue | null,
revalidate?: number | false
) => Promise<void>
}
export default class ResponseCache {
incrementalCache: IncrementalCache
pendingResponses: Map<string, Promise<ResponseCacheEntry | null>>
previousCacheItem?: {
key: string
entry: ResponseCacheEntry | null
expiresAt: number
}
minimalMode?: boolean
constructor(incrementalCache: IncrementalCache, minimalMode: boolean) {
this.incrementalCache = incrementalCache
this.pendingResponses = new Map()
this.minimalMode = minimalMode
}
public get(
key: string | null,
responseGenerator: ResponseGenerator,
context: {
isManualRevalidate?: boolean
isPrefetch?: boolean
}
): Promise<ResponseCacheEntry | null> {
// ensure manual revalidate doesn't block normal requests
const pendingResponseKey = key
? `${key}-${context.isManualRevalidate ? '1' : '0'}`
: null
const pendingResponse = pendingResponseKey
? this.pendingResponses.get(pendingResponseKey)
: null
if (pendingResponse) {
return pendingResponse
}
let resolver: (cacheEntry: ResponseCacheEntry | null) => void = () => {}
let rejecter: (error: Error) => void = () => {}
const promise: Promise<ResponseCacheEntry | null> = new Promise(
(resolve, reject) => {
resolver = resolve
rejecter = reject
}
)
if (pendingResponseKey) {
this.pendingResponses.set(pendingResponseKey, promise)
}
let resolved = false
const resolve = (cacheEntry: ResponseCacheEntry | null) => {
if (pendingResponseKey) {
// Ensure all reads from the cache get the latest value.
this.pendingResponses.set(
pendingResponseKey,
Promise.resolve(cacheEntry)
)
}
if (!resolved) {
resolved = true
resolver(cacheEntry)
}
}
// we keep the previous cache entry around to leverage
// when the incremental cache is disabled in minimal mode
if (
pendingResponseKey &&
this.minimalMode &&
this.previousCacheItem?.key === pendingResponseKey &&
this.previousCacheItem.expiresAt > Date.now()
) {
resolve(this.previousCacheItem.entry)
this.pendingResponses.delete(pendingResponseKey)
return promise
}
// We wait to do any async work until after we've added our promise to
// `pendingResponses` to ensure that any any other calls will reuse the
// same promise until we've fully finished our work.
;(async () => {
let cachedResponse: IncrementalCacheItem = null
try {
cachedResponse =
key && !this.minimalMode ? await this.incrementalCache.get(key) : null
if (cachedResponse && !context.isManualRevalidate) {
resolve({
isStale: cachedResponse.isStale,
revalidate: cachedResponse.curRevalidate,
value:
cachedResponse.value?.kind === 'PAGE'
? {
kind: 'PAGE',
html: RenderResult.fromStatic(cachedResponse.value.html),
pageData: cachedResponse.value.pageData,
}
: cachedResponse.value,
})
if (!cachedResponse.isStale || context.isPrefetch) {
// The cached value is still valid, so we don't need
// to update it yet.
return
}
}
const cacheEntry = await responseGenerator(resolved, !!cachedResponse)
const resolveValue =
cacheEntry === null
? null
: {
...cacheEntry,
isMiss: !cachedResponse,
}
// for manual revalidate wait to resolve until cache is set
if (!context.isManualRevalidate) {
resolve(resolveValue)
}
if (key && cacheEntry && typeof cacheEntry.revalidate !== 'undefined') {
if (this.minimalMode) {
this.previousCacheItem = {
key: pendingResponseKey || key,
entry: cacheEntry,
expiresAt: Date.now() + 1000,
}
} else {
await this.incrementalCache.set(
key,
cacheEntry.value?.kind === 'PAGE'
? {
kind: 'PAGE',
html: cacheEntry.value.html.toUnchunkedString(),
pageData: cacheEntry.value.pageData,
}
: cacheEntry.value,
cacheEntry.revalidate
)
}
} else {
this.previousCacheItem = undefined
}
if (context.isManualRevalidate) {
resolve(resolveValue)
}
} catch (err) {
// 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(
key,
cachedResponse.value,
Math.min(Math.max(cachedResponse.revalidate || 3, 3), 30)
)
}
// while revalidating in the background we can't reject as
// we already resolved the cache entry so log the error here
if (resolved) {
console.error(err)
} else {
rejecter(err as Error)
}
} finally {
if (pendingResponseKey) {
this.pendingResponses.delete(pendingResponseKey)
}
}
})()
return promise
}
}