rsnext/packages/next/server/incremental-cache.ts
Shu Ding 99d4d6c5a4
Implement web server as the request handler for edge SSR (#33635)
(#31506 for context)

This PR implements the minimum viable web server on top of the Next.js base server, and integrates it into our middleware (edge) SSR runtime to handle all the requests.

This also addresses problems like missing dynamic routes support in our current handler.

Note that this is the initial implementation with the assumption that the web server is running under minimal mode. Also later we can refactor the `__server_context` environment to properly passing the context via the constructor or methods.
2022-01-26 06:22:11 +00:00

232 lines
6.3 KiB
TypeScript

import type { CacheFs } from '../shared/lib/utils'
import LRUCache from 'next/dist/compiled/lru-cache'
import path from 'path'
import { PrerenderManifest } from '../build'
import { normalizePagePath } from './normalize-page-path'
function toRoute(pathname: string): string {
return pathname.replace(/\/$/, '').replace(/\/index$/, '') || '/'
}
interface CachedRedirectValue {
kind: 'REDIRECT'
props: Object
}
interface CachedPageValue {
kind: 'PAGE'
html: string
pageData: Object
}
export type IncrementalCacheValue = CachedRedirectValue | CachedPageValue
type IncrementalCacheEntry = {
curRevalidate?: number | false
// milliseconds to revalidate after
revalidateAfter: number | false
isStale?: boolean
value: IncrementalCacheValue | null
}
export class IncrementalCache {
incrementalOptions: {
flushToDisk?: boolean
pagesDir?: string
distDir?: string
dev?: boolean
}
prerenderManifest: PrerenderManifest
cache?: LRUCache<string, IncrementalCacheEntry>
locales?: string[]
fs: CacheFs
constructor({
fs,
max,
dev,
distDir,
pagesDir,
flushToDisk,
locales,
getPrerenderManifest,
}: {
fs: CacheFs
dev: boolean
max?: number
distDir: string
pagesDir: string
flushToDisk?: boolean
locales?: string[]
getPrerenderManifest: () => PrerenderManifest
}) {
this.fs = fs
this.incrementalOptions = {
dev,
distDir,
pagesDir,
flushToDisk:
!dev && (typeof flushToDisk !== 'undefined' ? flushToDisk : true),
}
this.locales = locales
this.prerenderManifest = getPrerenderManifest()
if (process.env.__NEXT_TEST_MAX_ISR_CACHE) {
// Allow cache size to be overridden for testing purposes
max = parseInt(process.env.__NEXT_TEST_MAX_ISR_CACHE, 10)
}
if (max) {
this.cache = new LRUCache({
max,
length({ value }) {
if (!value || value.kind === 'REDIRECT') return 25
// rough estimate of size of cache value
return value.html.length + JSON.stringify(value.pageData).length
},
})
}
}
private getSeedPath(pathname: string, ext: string): string {
return path.join(this.incrementalOptions.pagesDir!, `${pathname}.${ext}`)
}
private calculateRevalidate(
pathname: string,
fromTime: number
): number | false {
pathname = toRoute(pathname)
// in development we don't have a prerender-manifest
// and default to always revalidating to allow easier debugging
if (this.incrementalOptions.dev) return new Date().getTime() - 1000
const { initialRevalidateSeconds } = this.prerenderManifest.routes[
pathname
] || {
initialRevalidateSeconds: 1,
}
const revalidateAfter =
typeof initialRevalidateSeconds === 'number'
? initialRevalidateSeconds * 1000 + fromTime
: initialRevalidateSeconds
return revalidateAfter
}
getFallback(page: string): Promise<string> {
page = normalizePagePath(page)
return this.fs.readFile(this.getSeedPath(page, 'html'))
}
// get data from cache if available
async get(pathname: string): Promise<IncrementalCacheEntry | null> {
if (this.incrementalOptions.dev) return null
pathname = normalizePagePath(pathname)
let data = this.cache && this.cache.get(pathname)
// let's check the disk for seed data
if (!data) {
if (this.prerenderManifest.notFoundRoutes.includes(pathname)) {
const now = Date.now()
const revalidateAfter = this.calculateRevalidate(pathname, now)
data = {
value: null,
revalidateAfter: revalidateAfter !== false ? now : false,
}
}
try {
const htmlPath = this.getSeedPath(pathname, 'html')
const jsonPath = this.getSeedPath(pathname, 'json')
const html = await this.fs.readFile(htmlPath)
const pageData = JSON.parse(await this.fs.readFile(jsonPath))
const { mtime } = await this.fs.stat(htmlPath)
data = {
revalidateAfter: this.calculateRevalidate(pathname, mtime.getTime()),
value: {
kind: 'PAGE',
html,
pageData,
},
}
if (this.cache) {
this.cache.set(pathname, data)
}
} catch (_) {
// unable to get data from disk
}
}
if (!data) {
return null
}
if (
data &&
data.revalidateAfter !== false &&
data.revalidateAfter < new Date().getTime()
) {
data.isStale = true
}
const manifestPath = toRoute(pathname)
const manifestEntry = this.prerenderManifest.routes[manifestPath]
if (data && manifestEntry) {
data.curRevalidate = manifestEntry.initialRevalidateSeconds
}
return data
}
// populate the incremental cache with new data
async set(
pathname: string,
data: IncrementalCacheValue | null,
revalidateSeconds?: number | false
) {
if (this.incrementalOptions.dev) return
if (typeof revalidateSeconds !== 'undefined') {
// TODO: Update this to not mutate the manifest from the
// build.
this.prerenderManifest.routes[pathname] = {
dataRoute: path.posix.join(
'/_next/data',
`${normalizePagePath(pathname)}.json`
),
srcRoute: null, // FIXME: provide actual source route, however, when dynamically appending it doesn't really matter
initialRevalidateSeconds: revalidateSeconds,
}
}
pathname = normalizePagePath(pathname)
if (this.cache) {
this.cache.set(pathname, {
revalidateAfter: this.calculateRevalidate(
pathname,
new Date().getTime()
),
value: data,
})
}
// TODO: This option needs to cease to exist unless it stops mutating the
// `next build` output's manifest.
if (this.incrementalOptions.flushToDisk && data?.kind === 'PAGE') {
try {
const seedHtmlPath = this.getSeedPath(pathname, 'html')
const seedJsonPath = this.getSeedPath(pathname, 'json')
await this.fs.mkdir(path.dirname(seedHtmlPath))
await this.fs.writeFile(seedHtmlPath, data.html)
await this.fs.writeFile(seedJsonPath, JSON.stringify(data.pageData))
} catch (error) {
// failed to flush to disk
console.warn('Failed to update prerender files for', pathname, error)
}
}
}
}