Skip to content

Commit

Permalink
fix: marked all manifests that are loaded as frozen
Browse files Browse the repository at this point in the history
  • Loading branch information
wyattjoh committed Apr 11, 2024
1 parent c976ca6 commit f923f3f
Show file tree
Hide file tree
Showing 8 changed files with 199 additions and 51 deletions.
4 changes: 2 additions & 2 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ import { PrefetchRSCPathnameNormalizer } from './future/normalizers/request/pref
import { NextDataPathnameNormalizer } from './future/normalizers/request/next-data'
import { getIsServerAction } from './lib/server-action-request-meta'
import { isInterceptionRouteAppPath } from './future/helpers/interception-routes'
import { toRoute } from './lib/to-route'

export type FindComponentsResult = {
components: LoadComponentsReturnType
Expand Down Expand Up @@ -1823,8 +1824,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
) {
isSSG = true
} else if (!this.renderOpts.dev) {
isSSG ||=
!!prerenderManifest.routes[pathname === '/index' ? '/' : pathname]
isSSG ||= !!prerenderManifest.routes[toRoute(pathname)]
}

// Toggle whether or not this is a Data request
Expand Down
34 changes: 34 additions & 0 deletions packages/next/src/server/lib/freeze.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Recursively freezes an object and all of its properties. This prevents the
* object from being modified at runtime. When the JS runtime is running in
* strict mode, any attempts to modify a frozen object will throw an error.
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze
* @param obj The object to freeze.
*/
export function freeze(obj: object): void {
// `null` is an object, if we get this, we should just return it.
if (obj === null) return

// An array is an object, but we also want to freeze each element in the array
// as well.
if (Array.isArray(obj)) {
for (const item of obj) {
if (!item || typeof item !== 'object') continue
freeze(item)
}

Object.freeze(obj)
return
}

for (const name of Object.keys(obj)) {
const value = obj[name as keyof typeof obj]

if (!value || typeof value !== 'object') continue
freeze(value)
}

Object.freeze(obj)
return
}
132 changes: 89 additions & 43 deletions packages/next/src/server/lib/incremental-cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import type {
IncrementalCache as IncrementalCacheType,
IncrementalCacheKindHint,
} from '../../response-cache'
import type { Revalidate } from '../revalidate'

import FetchCache from './fetch-cache'
import FileSystemCache from './file-system-cache'
import path from '../../../shared/lib/isomorphic/path'
import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path'

import {
Expand All @@ -18,10 +18,7 @@ import {
NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER,
PRERENDER_REVALIDATE_HEADER,
} from '../../../lib/constants'

function toRoute(pathname: string): string {
return pathname.replace(/\/$/, '').replace(/\/index$/, '') || '/'
}
import { toRoute } from '../to-route'

export interface CacheHandlerContext {
fs?: CacheFs
Expand Down Expand Up @@ -64,21 +61,82 @@ export class CacheHandler {
public resetRequestCache(): void {}
}

/**
* A shared cache of revalidate timings for routes. This cache is used so we
* don't have to modify the prerender manifest when we want to update the
* revalidate timings for a route.
*/
class SharedRevalidateTimings {
/**
* The in-memory cache of revalidate timings for routes. This cache is
* populated when the cache is updated with new timings.
*/
private static timings = new Map<string, Revalidate>()

constructor(
/**
* The prerender manifest that contains the initial revalidate timings for
* routes.
*/
private readonly prerenderManifest: PrerenderManifest
) {}

/**
* Try to get the revalidate timings for a route. This will first try to get
* the timings from the in-memory cache. If the timings are not present in the
* in-memory cache, then the timings will be sourced from the prerender
* manifest.
*
* @param route the route to get the revalidate timings for
* @returns the revalidate timings for the route, or undefined if the timings
* are not present in the in-memory cache or the prerender manifest
*/
public get(route: string): Revalidate | undefined {
// This is a copy on write cache that is updated when the cache is updated.
// If the cache is never written to, then the timings will be sourced from
// the prerender manifest.
let revalidate = SharedRevalidateTimings.timings.get(route)
if (typeof revalidate !== 'undefined') return revalidate

revalidate = this.prerenderManifest.routes[route]?.initialRevalidateSeconds
if (typeof revalidate !== 'undefined') return revalidate

return undefined
}

/**
* Set the revalidate timings for a route.
*
* @param route the route to set the revalidate timings for
* @param revalidate the revalidate timings for the route
*/
public set(route: string, revalidate: Revalidate) {
SharedRevalidateTimings.timings.set(route, revalidate)
}
}

export class IncrementalCache implements IncrementalCacheType {
dev?: boolean
disableForTestmode?: boolean
cacheHandler?: CacheHandler
hasCustomCacheHandler: boolean
prerenderManifest: PrerenderManifest
requestHeaders: Record<string, undefined | string | string[]>
requestProtocol?: 'http' | 'https'
allowedRevalidateHeaderKeys?: string[]
minimalMode?: boolean
fetchCacheKeyPrefix?: string
revalidatedTags?: string[]
isOnDemandRevalidate?: boolean
private locks = new Map<string, Promise<void>>()
private unlocks = new Map<string, () => Promise<void>>()
readonly dev?: boolean
readonly disableForTestmode?: boolean
readonly cacheHandler?: CacheHandler
readonly hasCustomCacheHandler: boolean
readonly prerenderManifest: PrerenderManifest
readonly requestHeaders: Record<string, undefined | string | string[]>
readonly requestProtocol?: 'http' | 'https'
readonly allowedRevalidateHeaderKeys?: string[]
readonly minimalMode?: boolean
readonly fetchCacheKeyPrefix?: string
readonly revalidatedTags?: string[]
readonly isOnDemandRevalidate?: boolean

private readonly locks = new Map<string, Promise<void>>()
private readonly unlocks = new Map<string, () => Promise<void>>()

/**
* The revalidate timings for routes. This will source the timings from the
* prerender manifest until the in-memory cache is updated with new timings.
*/
private readonly revalidateTimings: SharedRevalidateTimings

constructor({
fs,
Expand Down Expand Up @@ -152,6 +210,7 @@ export class IncrementalCache implements IncrementalCacheType {
this.requestProtocol = requestProtocol
this.allowedRevalidateHeaderKeys = allowedRevalidateHeaderKeys
this.prerenderManifest = getPrerenderManifest()
this.revalidateTimings = new SharedRevalidateTimings(this.prerenderManifest)
this.fetchCacheKeyPrefix = fetchCacheKeyPrefix
let revalidatedTags: string[] = []

Expand Down Expand Up @@ -193,18 +252,16 @@ export class IncrementalCache implements IncrementalCacheType {
pathname: string,
fromTime: number,
dev?: boolean
): number | false {
): Revalidate {
// in development we don't have a prerender-manifest
// and default to always revalidating to allow easier debugging
if (dev) return new Date().getTime() - 1000

// if an entry isn't present in routes we fallback to a default
// of revalidating after 1 second
const { initialRevalidateSeconds } = this.prerenderManifest.routes[
toRoute(pathname)
] || {
initialRevalidateSeconds: 1,
}
// of revalidating after 1 second.
const initialRevalidateSeconds =
this.revalidateTimings.get(toRoute(pathname)) ?? 1

const revalidateAfter =
typeof initialRevalidateSeconds === 'number'
? initialRevalidateSeconds * 1000 + fromTime
Expand Down Expand Up @@ -485,11 +542,10 @@ export class IncrementalCache implements IncrementalCacheType {
}
}

const curRevalidate =
this.prerenderManifest.routes[toRoute(cacheKey)]?.initialRevalidateSeconds
const curRevalidate = this.revalidateTimings.get(toRoute(cacheKey))

let isStale: boolean | -1 | undefined
let revalidateAfter: false | number
let revalidateAfter: Revalidate

if (cacheData?.lastModified === -1) {
isStale = -1
Expand Down Expand Up @@ -584,22 +640,12 @@ export class IncrementalCache implements IncrementalCacheType {
pathname = this._getPathname(pathname, ctx.fetchCache)

try {
// we use the prerender manifest memory instance
// to store revalidate timings for calculating
// revalidateAfter values so we update this on set
// Set the value for the revalidate seconds so if it changes we can
// update the cache with the new value.
if (typeof ctx.revalidate !== 'undefined' && !ctx.fetchCache) {
this.prerenderManifest.routes[pathname] = {
experimentalPPR: undefined,
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: ctx.revalidate,
// Pages routes do not have a prefetch data route.
prefetchDataRoute: undefined,
}
this.revalidateTimings.set(pathname, ctx.revalidate)
}

await this.cacheHandler?.set(pathname, data, ctx)
} catch (error) {
console.warn('Failed to update prerender cache for', pathname, error)
Expand Down
33 changes: 33 additions & 0 deletions packages/next/src/server/lib/to-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { toRoute } from './to-route'

describe('toRoute Function', () => {
it('should remove trailing slash', () => {
const result = toRoute('/example/')
expect(result).toBe('/example')
})

it('should remove trailing `/index`', () => {
const result = toRoute('/example/index')
expect(result).toBe('/example')
})

it('should return `/` when input is `/index`', () => {
const result = toRoute('/index')
expect(result).toBe('/')
})

it('should return `/` when input is `/index/`', () => {
const result = toRoute('/index/')
expect(result).toBe('/')
})

it('should return `/` when input is only a slash', () => {
const result = toRoute('/')
expect(result).toBe('/')
})

it('should return `/` when input is empty', () => {
const result = toRoute('')
expect(result).toBe('/')
})
})
26 changes: 26 additions & 0 deletions packages/next/src/server/lib/to-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* This transforms a URL pathname into a route. It removes any trailing slashes
* and the `/index` suffix.
*
* @param {string} pathname - The URL path that needs to be optimized.
* @returns {string} - The route
*
* @example
* // returns '/example'
* toRoute('/example/index/');
*
* @example
* // returns '/example'
* toRoute('/example/');
*
* @example
* // returns '/'
* toRoute('/index/');
*
* @example
* // returns '/'
* toRoute('/');
*/
export function toRoute(pathname: string): string {
return pathname.replace(/(?:\/index)?\/?$/, '') || '/'
}
13 changes: 11 additions & 2 deletions packages/next/src/server/load-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from 'fs'
import { runInNewContext } from 'vm'
import { freeze } from './lib/freeze'

const cache = new Map<string, unknown>()

Expand All @@ -8,13 +9,17 @@ export function loadManifest(
shouldCache: boolean = true
): unknown {
const cached = shouldCache && cache.get(path)

if (cached) {
return cached
}

const manifest = JSON.parse(readFileSync(path, 'utf8'))

// Freeze the manifest so it cannot be modified if we're caching it.
if (shouldCache) {
freeze(manifest)
}

if (shouldCache) {
cache.set(path, manifest)
}
Expand All @@ -27,7 +32,6 @@ export function evalManifest(
shouldCache: boolean = true
): unknown {
const cached = shouldCache && cache.get(path)

if (cached) {
return cached
}
Expand All @@ -40,6 +44,11 @@ export function evalManifest(
const contextObject = {}
runInNewContext(content, contextObject)

// Freeze the context object so it cannot be modified if we're caching it.
if (shouldCache) {
freeze(contextObject)
}

if (shouldCache) {
cache.set(path, contextObject)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/server/next-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1768,11 +1768,11 @@ export default class NextNodeServer extends BaseServer {
return this._cachedPreviewManifest
}

const manifest = loadManifest(
this._cachedPreviewManifest = loadManifest(
join(this.distDir, PRERENDER_MANIFEST)
) as PrerenderManifest

return (this._cachedPreviewManifest = manifest)
return this._cachedPreviewManifest
}

protected getRoutesManifest(): NormalizedRouteManifest | undefined {
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/server/response-cache/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ interface IncrementalCachedPageValue {
}

export type IncrementalCacheEntry = {
curRevalidate?: number | false
curRevalidate?: Revalidate
// milliseconds to revalidate after
revalidateAfter: number | false
revalidateAfter: Revalidate
// -1 here dictates a blocking revalidate should be used
isStale?: boolean | -1
value: IncrementalCacheValue | null
Expand Down

0 comments on commit f923f3f

Please sign in to comment.