Skip to content

Commit

Permalink
Update cache handling for app (#43659)
Browse files Browse the repository at this point in the history
This updates the app directory caching. 

x-ref: [slack thread
](https://vercel.slack.com/archives/C042LHPJ1NX/p1669231119199339)

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see
[`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [ ]
[e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see
[`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm build && pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
  • Loading branch information
ijjk committed Dec 3, 2022
1 parent 3a7c35a commit 0c756be
Show file tree
Hide file tree
Showing 23 changed files with 655 additions and 96 deletions.
1 change: 1 addition & 0 deletions 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

Expand Down
Expand Up @@ -7,6 +7,9 @@ export interface StaticGenerationStore {
fetchRevalidate?: number
isStaticGeneration?: boolean
forceStatic?: boolean
incrementalCache?: import('../../server/lib/incremental-cache').IncrementalCache
pendingRevalidates?: Promise<any>[]
isRevalidate?: boolean
}

export let staticGenerationAsyncStorage:
Expand Down
29 changes: 28 additions & 1 deletion packages/next/export/worker.ts
Expand Up @@ -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'
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')

Expand Down
131 changes: 122 additions & 9 deletions packages/next/server/app-render.tsx
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -292,7 +398,7 @@ function patchFetch(ComponentMod: any) {
if (hasNextConfig) delete init.next
}
}
return originFetch(input, init)
return doOriginalFetch()
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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)

Expand All @@ -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'
Expand All @@ -1760,6 +1871,8 @@ export async function renderToHTMLOrFlight(
isStaticGeneration,
inUse: true,
pathname,
incrementalCache: renderOpts.incrementalCache,
isRevalidate: renderOpts.isRevalidate,
}

const tryGetPreviewData =
Expand Down
33 changes: 30 additions & 3 deletions packages/next/server/base-server.ts
Expand Up @@ -78,6 +78,7 @@ import {
RSC,
RSC_VARY_HEADER,
FLIGHT_PARAMETERS,
FETCH_CACHE_HEADER,
} from '../client/components/app-router-headers'

export type FindComponentsResult = {
Expand Down Expand Up @@ -310,6 +311,10 @@ export default abstract class Server<ServerOptions extends Options = Options> {
res: BaseNextResponse
): void

protected abstract getIncrementalCache(options: {
requestHeaders: Record<string, undefined | string | string[]>
}): import('./lib/incremental-cache').IncrementalCache

protected abstract getResponseCache(options: {
dev: boolean
}): ResponseCacheBase
Expand Down Expand Up @@ -1283,11 +1288,22 @@ 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 &&
(opts.runtime !== 'experimental-edge' ||
(this.serverOptions as any).webServerConfig)
) {
delete req.headers[FETCH_CACHE_HEADER]
}
let isRevalidate = false

const doRender: () => Promise<ResponseCacheEntry | null> = async () => {
let pageData: any
let body: RenderResult | null
let sprRevalidate: number | false
let isrRevalidate: number | false
let isNotFound: boolean | undefined
let isRedirect: boolean | undefined

Expand All @@ -1312,6 +1328,12 @@ export default abstract class Server<ServerOptions extends Options = Options> {
const renderOpts: RenderOpts = {
...components,
...opts,
...(isAppPath && this.nextConfig.experimental.fetchCache
? {
incrementalCache,
isRevalidate: this.minimalMode || isRevalidate,
}
: {}),
isDataReq,
resolvedUrl,
locale,
Expand Down Expand Up @@ -1346,7 +1368,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
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

Expand All @@ -1361,7 +1383,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
}
value = { kind: 'PAGE', html: body, pageData }
}
return { revalidate: sprRevalidate, value }
return { revalidate: isrRevalidate, value }
}

const cacheEntry = await this.responseCache.get(
Expand All @@ -1371,6 +1393,10 @@ export default abstract class Server<ServerOptions extends Options = Options> {
const isDynamicPathname = isDynamicRoute(pathname)
const didRespond = hasResolved || res.sent

if (hadCache) {
isRevalidate = true
}

if (!staticPaths) {
;({ staticPaths, fallbackMode } = hasStaticPaths
? await this.getStaticPaths({ pathname })
Expand Down Expand Up @@ -1486,6 +1512,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
}
},
{
incrementalCache,
isManualRevalidate,
isPrefetch: req.headers.purpose === 'prefetch',
}
Expand Down
3 changes: 3 additions & 0 deletions packages/next/server/config-schema.ts
Expand Up @@ -277,6 +277,9 @@ const configSchema = {
fallbackNodePolyfills: {
type: 'boolean',
},
fetchCache: {
type: 'boolean',
},
forceSwcTransforms: {
type: 'boolean',
},
Expand Down
2 changes: 2 additions & 0 deletions packages/next/server/config-shared.ts
Expand Up @@ -79,6 +79,7 @@ export interface NextJsWebpackConfig {
}

export interface ExperimentalConfig {
fetchCache?: boolean
allowMiddlewareResponseBody?: boolean
skipMiddlewareUrlNormalize?: boolean
skipTrailingSlashRedirect?: boolean
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 0c756be

Please sign in to comment.