diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 144e8c8d4f976..c20ca553c0769 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -185,6 +185,7 @@ import { buildCustomRoute } from '../lib/build-custom-route' import { createProgress } from './progress' import { traceMemoryUsage } from '../lib/memory/trace' import { generateEncryptionKeyBase64 } from '../server/app-render/encryption-utils' +import type { DeepReadonly } from '../shared/lib/deep-readonly' interface ExperimentalBypassForInfo { experimentalBypassFor?: RouteHas[] @@ -337,7 +338,7 @@ async function readManifest(filePath: string): Promise { async function writePrerenderManifest( distDir: string, - manifest: Readonly + manifest: DeepReadonly ): Promise { await writeManifest(path.join(distDir, PRERENDER_MANIFEST), manifest) await writeEdgePartialPrerenderManifest(distDir, manifest) @@ -345,19 +346,20 @@ async function writePrerenderManifest( async function writeEdgePartialPrerenderManifest( distDir: string, - manifest: Readonly> + manifest: DeepReadonly> ): Promise { // We need to write a partial prerender manifest to make preview mode settings available in edge middleware. // Use env vars in JS bundle and inject the actual vars to edge manifest. - const edgePartialPrerenderManifest: Partial = { - ...manifest, - preview: { - previewModeId: 'process.env.__NEXT_PREVIEW_MODE_ID', - previewModeSigningKey: 'process.env.__NEXT_PREVIEW_MODE_SIGNING_KEY', - previewModeEncryptionKey: - 'process.env.__NEXT_PREVIEW_MODE_ENCRYPTION_KEY', - }, - } + const edgePartialPrerenderManifest: DeepReadonly> = + { + ...manifest, + preview: { + previewModeId: 'process.env.__NEXT_PREVIEW_MODE_ID', + previewModeSigningKey: 'process.env.__NEXT_PREVIEW_MODE_SIGNING_KEY', + previewModeEncryptionKey: + 'process.env.__NEXT_PREVIEW_MODE_ENCRYPTION_KEY', + }, + } await writeFileUtf8( path.join(distDir, PRERENDER_MANIFEST.replace(/\.json$/, '.js')), `self.__PRERENDER_MANIFEST=${JSON.stringify( @@ -367,7 +369,7 @@ async function writeEdgePartialPrerenderManifest( } async function writeClientSsgManifest( - prerenderManifest: PrerenderManifest, + prerenderManifest: DeepReadonly, { buildId, distDir, @@ -3318,7 +3320,7 @@ export default async function build( NextBuildContext.allowedRevalidateHeaderKeys = config.experimental.allowedRevalidateHeaderKeys - const prerenderManifest: Readonly = { + const prerenderManifest: DeepReadonly = { version: 4, routes: finalPrerenderRoutes, dynamicRoutes: finalDynamicRoutes, diff --git a/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts b/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts index 8f6f1d71415a1..80a6d4b1e554a 100644 --- a/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts +++ b/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts @@ -19,6 +19,7 @@ import type { SizeLimit } from '../../../../../types' import { internal_getCurrentFunctionWaitUntil } from '../../../../server/web/internal-edge-wait-until' import type { PAGE_TYPES } from '../../../../lib/page-types' import type { NextRequestHint } from '../../../../server/web/adapter' +import type { DeepReadonly } from '../../../../shared/lib/deep-readonly' export function getRender({ dev, @@ -53,7 +54,7 @@ export function getRender({ renderToHTML?: any Document: DocumentType buildManifest: BuildManifest - prerenderManifest: PrerenderManifest + prerenderManifest: DeepReadonly reactLoadableManifest: ReactLoadableManifest subresourceIntegrityManifest?: Record interceptionRouteRewrites?: ManifestRewriteRoute[] diff --git a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts index 02f41c7923710..1271fe61e571a 100644 --- a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts @@ -70,7 +70,7 @@ export interface ManifestNode { } export type ClientReferenceManifest = { - moduleLoading: { + readonly moduleLoading: { prefix: string crossOrigin: string | null } diff --git a/packages/next/src/client/components/request-async-storage.external.ts b/packages/next/src/client/components/request-async-storage.external.ts index bcaddf009fb42..dbd33809881c3 100644 --- a/packages/next/src/client/components/request-async-storage.external.ts +++ b/packages/next/src/client/components/request-async-storage.external.ts @@ -5,13 +5,16 @@ import type { ReadonlyHeaders } from '../../server/web/spec-extension/adapters/h import type { ReadonlyRequestCookies } from '../../server/web/spec-extension/adapters/request-cookies' import { createAsyncLocalStorage } from './async-local-storage' +import type { DeepReadonly } from '../../shared/lib/deep-readonly' export interface RequestStore { readonly headers: ReadonlyHeaders readonly cookies: ReadonlyRequestCookies readonly mutableCookies: ResponseCookies readonly draftMode: DraftModeProvider - readonly reactLoadableManifest: Record + readonly reactLoadableManifest: DeepReadonly< + Record + > readonly assetPrefix: string } diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index 7fc87fe264564..ffd2c408c5a56 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -56,6 +56,7 @@ import { formatManifest } from '../build/manifests/formatter/format-manifest' import { validateRevalidate } from '../server/lib/patch-fetch' import { TurborepoAccessTraceResult } from '../build/turborepo-access-trace' import { createProgress } from '../build/progress' +import type { DeepReadonly } from '../shared/lib/deep-readonly' export class ExportError extends Error { code = 'NEXT_EXPORT_ERROR' @@ -188,7 +189,7 @@ export async function exportAppImpl( !options.pages && (require(join(distDir, SERVER_DIRECTORY, PAGES_MANIFEST)) as PagesManifest) - let prerenderManifest: PrerenderManifest | undefined + let prerenderManifest: DeepReadonly | undefined try { prerenderManifest = require(join(distDir, PRERENDER_MANIFEST)) } catch {} diff --git a/packages/next/src/pages/_document.tsx b/packages/next/src/pages/_document.tsx index 891327bc15710..792c45f0c2d29 100644 --- a/packages/next/src/pages/_document.tsx +++ b/packages/next/src/pages/_document.tsx @@ -25,6 +25,7 @@ import { } from '../shared/lib/html-context.shared-runtime' import type { HtmlProps } from '../shared/lib/html-context.shared-runtime' import { encodeURIPath } from '../shared/lib/encode-uri-path' +import type { DeepReadonly } from '../shared/lib/deep-readonly' export type { DocumentContext, DocumentInitialProps, DocumentProps } @@ -360,7 +361,7 @@ function getAmpPath(ampPath: string, asPath: string): string { } function getNextFontLinkTags( - nextFontManifest: NextFontManifest | undefined, + nextFontManifest: DeepReadonly | undefined, dangerousAsPath: string, assetPrefix: string = '' ) { diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index e6598a5a189a8..80b86d3c6aa7c 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -107,6 +107,7 @@ import { wrapClientComponentLoader, } from '../client-component-renderer-logger' import { createServerModuleMap } from './action-utils' +import type { DeepReadonly } from '../../shared/lib/deep-readonly' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -137,7 +138,7 @@ export type AppRenderContext = AppRenderBaseContext & { requestId: string defaultRevalidate: Revalidate pagePath: string - clientReferenceManifest: ClientReferenceManifest + clientReferenceManifest: DeepReadonly assetPrefix: string flightDataRendererErrorHandler: ErrorHandler serverComponentsErrorHandler: ErrorHandler diff --git a/packages/next/src/server/app-render/encryption-utils.ts b/packages/next/src/server/app-render/encryption-utils.ts index e29d46484d9f5..76a23a743940a 100644 --- a/packages/next/src/server/app-render/encryption-utils.ts +++ b/packages/next/src/server/app-render/encryption-utils.ts @@ -1,5 +1,6 @@ import type { ActionManifest } from '../../build/webpack/plugins/flight-client-entry-plugin' import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin' +import type { DeepReadonly } from '../../shared/lib/deep-readonly' // Keep the key in memory as it should never change during the lifetime of the server in // both development and production. @@ -116,8 +117,8 @@ export function setReferenceManifestsSingleton({ serverActionsManifest, serverModuleMap, }: { - clientReferenceManifest: ClientReferenceManifest - serverActionsManifest: ActionManifest + clientReferenceManifest: DeepReadonly + serverActionsManifest: DeepReadonly serverModuleMap: { [id: string]: { id: string @@ -160,8 +161,8 @@ export function getClientReferenceManifestSingleton() { const serverActionsManifestSingleton = (globalThis as any)[ SERVER_ACTION_MANIFESTS_SINGLETON ] as { - clientReferenceManifest: ClientReferenceManifest - serverActionsManifest: ActionManifest + clientReferenceManifest: DeepReadonly + serverActionsManifest: DeepReadonly } if (!serverActionsManifestSingleton) { @@ -181,8 +182,8 @@ export async function getActionEncryptionKey() { const serverActionsManifestSingleton = (globalThis as any)[ SERVER_ACTION_MANIFESTS_SINGLETON ] as { - clientReferenceManifest: ClientReferenceManifest - serverActionsManifest: ActionManifest + clientReferenceManifest: DeepReadonly + serverActionsManifest: DeepReadonly } if (!serverActionsManifestSingleton) { diff --git a/packages/next/src/server/app-render/get-css-inlined-link-tags.tsx b/packages/next/src/server/app-render/get-css-inlined-link-tags.tsx index e8c5ea360eab5..b177883e371fa 100644 --- a/packages/next/src/server/app-render/get-css-inlined-link-tags.tsx +++ b/packages/next/src/server/app-render/get-css-inlined-link-tags.tsx @@ -1,10 +1,11 @@ import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin' +import type { DeepReadonly } from '../../shared/lib/deep-readonly' /** * Get external stylesheet link hrefs based on server CSS manifest. */ export function getLinkAndScriptTags( - clientReferenceManifest: ClientReferenceManifest, + clientReferenceManifest: DeepReadonly, filePath: string, injectedCSS: Set, injectedScripts: Set, diff --git a/packages/next/src/server/app-render/get-preloadable-fonts.tsx b/packages/next/src/server/app-render/get-preloadable-fonts.tsx index 61991ababccf8..99e94cdfad193 100644 --- a/packages/next/src/server/app-render/get-preloadable-fonts.tsx +++ b/packages/next/src/server/app-render/get-preloadable-fonts.tsx @@ -1,4 +1,5 @@ import type { NextFontManifest } from '../../build/webpack/plugins/next-font-manifest-plugin' +import type { DeepReadonly } from '../../shared/lib/deep-readonly' /** * Get hrefs for fonts to preload @@ -8,7 +9,7 @@ import type { NextFontManifest } from '../../build/webpack/plugins/next-font-man * Returns null if there are fonts but none to preload and at least some were previously preloaded */ export function getPreloadableFonts( - nextFontManifest: NextFontManifest | undefined, + nextFontManifest: DeepReadonly | undefined, filePath: string | undefined, injectedFontPreloadTags: Set ): string[] | null { diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 7ed9c21b9e6d8..8f81570e0c9df 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -7,6 +7,7 @@ import type { ParsedUrlQuery } from 'querystring' import type { AppPageModule } from '../future/route-modules/app-page/module' import type { SwrDelta } from '../lib/revalidate' import type { LoadingModuleData } from '../../shared/lib/app-router-context.shared-runtime' +import type { DeepReadonly } from '../../shared/lib/deep-readonly' import s from 'next/dist/compiled/superstruct' @@ -126,14 +127,14 @@ export interface RenderOptsPartial { buildId: string basePath: string trailingSlash: boolean - clientReferenceManifest?: ClientReferenceManifest + clientReferenceManifest?: DeepReadonly supportsDynamicHTML: boolean runtime?: ServerRuntime serverComponents?: boolean enableTainting?: boolean assetPrefix?: string crossOrigin?: '' | 'anonymous' | 'use-credentials' | undefined - nextFontManifest?: NextFontManifest + nextFontManifest?: DeepReadonly isBot?: boolean incrementalCache?: import('../lib/incremental-cache').IncrementalCache isRevalidate?: boolean diff --git a/packages/next/src/server/app-render/use-flight-response.tsx b/packages/next/src/server/app-render/use-flight-response.tsx index 1483de5df7efa..6069a71aff358 100644 --- a/packages/next/src/server/app-render/use-flight-response.tsx +++ b/packages/next/src/server/app-render/use-flight-response.tsx @@ -2,6 +2,7 @@ import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight import type { BinaryStreamOf } from './app-render' import { htmlEscapeJsonString } from '../htmlescape' +import type { DeepReadonly } from '../../shared/lib/deep-readonly' const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' @@ -18,7 +19,7 @@ const encoder = new TextEncoder() */ export function useFlightStream( flightStream: BinaryStreamOf, - clientReferenceManifest: ClientReferenceManifest, + clientReferenceManifest: DeepReadonly, nonce?: string ): Promise { const response = flightResponses.get(flightStream) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index a53fc5fcd1306..94366137b6361 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -135,6 +135,7 @@ import { NextDataPathnameNormalizer } from './future/normalizers/request/next-da import { getIsServerAction } from './lib/server-action-request-meta' import { isInterceptionRouteAppPath } from './future/helpers/interception-routes' import { toRoute } from './lib/to-route' +import type { DeepReadonly } from '../shared/lib/deep-readonly' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -285,9 +286,9 @@ export default abstract class Server { protected readonly renderOpts: BaseRenderOpts protected readonly serverOptions: Readonly protected readonly appPathRoutes?: Record - protected readonly clientReferenceManifest?: ClientReferenceManifest + protected readonly clientReferenceManifest?: DeepReadonly protected interceptionRoutePatterns: RegExp[] - protected nextFontManifest?: NextFontManifest + protected nextFontManifest?: DeepReadonly private readonly responseCache: ResponseCacheBase protected abstract getPublicDir(): string @@ -314,9 +315,11 @@ export default abstract class Server { shouldEnsure?: boolean url?: string }): Promise - protected abstract getFontManifest(): FontManifest | undefined - protected abstract getPrerenderManifest(): PrerenderManifest - protected abstract getNextFontManifest(): NextFontManifest | undefined + protected abstract getFontManifest(): DeepReadonly | undefined + protected abstract getPrerenderManifest(): DeepReadonly + protected abstract getNextFontManifest(): + | DeepReadonly + | undefined protected abstract attachRequestMeta( req: BaseNextRequest, parsedUrl: NextUrlWithParsedQuery diff --git a/packages/next/src/server/future/route-modules/app-route/module.ts b/packages/next/src/server/future/route-modules/app-route/module.ts index 2edb287eda3a7..dd362b38f21c3 100644 --- a/packages/next/src/server/future/route-modules/app-route/module.ts +++ b/packages/next/src/server/future/route-modules/app-route/module.ts @@ -4,6 +4,7 @@ import type { AppConfig } from '../../../../build/utils' import type { NextRequest } from '../../../web/spec-extension/request' import type { PrerenderManifest } from '../../../../build' import type { NextURL } from '../../../web/next-url' +import type { DeepReadonly } from '../../../../shared/lib/deep-readonly' import { RouteModule, @@ -63,7 +64,7 @@ export type AppRouteModule = */ export interface AppRouteRouteHandlerContext extends RouteModuleHandleContext { renderOpts: StaticGenerationContext['renderOpts'] - prerenderManifest: PrerenderManifest + prerenderManifest: DeepReadonly } /** diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index 83df85c456bcd..755da67fdf1d9 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -7,6 +7,7 @@ import type { IncrementalCacheKindHint, } from '../../response-cache' import type { Revalidate } from '../revalidate' +import type { DeepReadonly } from '../../../shared/lib/deep-readonly' import FetchCache from './fetch-cache' import FileSystemCache from './file-system-cache' @@ -67,7 +68,7 @@ export class IncrementalCache implements IncrementalCacheType { readonly disableForTestmode?: boolean readonly cacheHandler?: CacheHandler readonly hasCustomCacheHandler: boolean - readonly prerenderManifest: PrerenderManifest + readonly prerenderManifest: DeepReadonly readonly requestHeaders: Record readonly requestProtocol?: 'http' | 'https' readonly allowedRevalidateHeaderKeys?: string[] @@ -115,7 +116,7 @@ export class IncrementalCache implements IncrementalCacheType { allowedRevalidateHeaderKeys?: string[] requestHeaders: IncrementalCache['requestHeaders'] maxMemoryCacheSize?: number - getPrerenderManifest: () => PrerenderManifest + getPrerenderManifest: () => DeepReadonly fetchCacheKeyPrefix?: string CurCacheHandler?: typeof CacheHandler experimental: { ppr: boolean } diff --git a/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts b/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts index 849b12c0dfaa4..b3d3e008aa8f8 100644 --- a/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts +++ b/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts @@ -1,4 +1,5 @@ import type { PrerenderManifest } from '../../../build' +import type { DeepReadonly } from '../../../shared/lib/deep-readonly' import type { Revalidate } from '../revalidate' /** @@ -18,7 +19,9 @@ export class SharedRevalidateTimings { * The prerender manifest that contains the initial revalidate timings for * routes. */ - private readonly prerenderManifest: Pick + private readonly prerenderManifest: DeepReadonly< + Pick + > ) {} /** diff --git a/packages/next/src/server/load-components.ts b/packages/next/src/server/load-components.ts index 46b1e6d35b0aa..5e3978d0f4eef 100644 --- a/packages/next/src/server/load-components.ts +++ b/packages/next/src/server/load-components.ts @@ -30,6 +30,7 @@ import { evalManifest, loadManifest } from './load-manifest' import { wait } from '../lib/wait' import { setReferenceManifestsSingleton } from './app-render/encryption-utils' import { createServerModuleMap } from './app-render/action-utils' +import type { DeepReadonly } from '../shared/lib/deep-readonly' export type ManifestItem = { id: number | string @@ -52,10 +53,10 @@ export interface LoadableManifest { export type LoadComponentsReturnType = { Component: NextComponentType pageConfig: PageConfig - buildManifest: BuildManifest - subresourceIntegrityManifest?: Record - reactLoadableManifest: ReactLoadableManifest - clientReferenceManifest?: ClientReferenceManifest + buildManifest: DeepReadonly + subresourceIntegrityManifest?: DeepReadonly> + reactLoadableManifest: DeepReadonly + clientReferenceManifest?: DeepReadonly serverActionsManifest?: any Document: DocumentType App: AppType @@ -71,13 +72,13 @@ export type LoadComponentsReturnType = { /** * Load manifest file with retries, defaults to 3 attempts. */ -export async function loadManifestWithRetries( +export async function loadManifestWithRetries( manifestPath: string, attempts = 3 -): Promise { +) { while (true) { try { - return loadManifest(manifestPath) + return loadManifest(manifestPath) } catch (err) { attempts-- if (attempts <= 0) throw err @@ -90,13 +91,13 @@ export async function loadManifestWithRetries( /** * Load manifest file with retries, defaults to 3 attempts. */ -export async function evalManifestWithRetries( +export async function evalManifestWithRetries( manifestPath: string, attempts = 3 -): Promise { +) { while (true) { try { - return evalManifest(manifestPath) + return evalManifest(manifestPath) } catch (err) { attempts-- if (attempts <= 0) throw err @@ -109,12 +110,12 @@ export async function evalManifestWithRetries( async function loadClientReferenceManifest( manifestPath: string, entryName: string -): Promise { +) { try { - const context = (await evalManifestWithRetries(manifestPath)) as { + const context = await evalManifestWithRetries<{ __RSC_MANIFEST: { [key: string]: ClientReferenceManifest } - } - return context.__RSC_MANIFEST[entryName] as ClientReferenceManifest + }>(manifestPath) + return context.__RSC_MANIFEST[entryName] } catch (err) { return undefined } @@ -149,12 +150,10 @@ async function loadComponentsImpl({ clientReferenceManifest, serverActionsManifest, ] = await Promise.all([ - loadManifestWithRetries( - join(distDir, BUILD_MANIFEST) - ) as Promise, - loadManifestWithRetries( + loadManifestWithRetries(join(distDir, BUILD_MANIFEST)), + loadManifestWithRetries( join(distDir, REACT_LOADABLE_MANIFEST) - ) as Promise, + ), hasClientManifest ? loadClientReferenceManifest( join( @@ -167,9 +166,9 @@ async function loadComponentsImpl({ ) : undefined, isAppPath - ? (loadManifestWithRetries( + ? loadManifestWithRetries( join(distDir, 'server', SERVER_REFERENCE_MANIFEST + '.json') - ).catch(() => null) as Promise) + ).catch(() => null) : null, ]) diff --git a/packages/next/src/server/load-manifest.test.ts b/packages/next/src/server/load-manifest.test.ts new file mode 100644 index 0000000000000..32e77fa81c8aa --- /dev/null +++ b/packages/next/src/server/load-manifest.test.ts @@ -0,0 +1,82 @@ +import { loadManifest } from './load-manifest' +import { readFileSync } from 'fs' + +jest.mock('fs') + +describe('loadManifest', () => { + const cache = new Map() + + afterEach(() => { + jest.resetAllMocks() + cache.clear() + }) + + it('should load the manifest from the file system when not cached', () => { + const mockManifest = { key: 'value' } + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockManifest)) + + let result = loadManifest('path/to/manifest', false) + expect(result).toEqual(mockManifest) + expect(readFileSync).toHaveBeenCalledTimes(1) + expect(readFileSync).toHaveBeenCalledWith('path/to/manifest', 'utf8') + expect(cache.has('path/to/manifest')).toBe(false) + + result = loadManifest('path/to/manifest', false) + expect(result).toEqual(mockManifest) + expect(readFileSync).toHaveBeenCalledTimes(2) + expect(readFileSync).toHaveBeenCalledWith('path/to/manifest', 'utf8') + expect(cache.has('path/to/manifest')).toBe(false) + }) + + it('should return the cached manifest when available', () => { + const mockManifest = { key: 'value' } + cache.set('path/to/manifest', mockManifest) + + let result = loadManifest('path/to/manifest', true, cache) + expect(result).toBe(mockManifest) + expect(readFileSync).not.toHaveBeenCalled() + + result = loadManifest('path/to/manifest', true, cache) + expect(result).toBe(mockManifest) + expect(readFileSync).not.toHaveBeenCalled() + }) + + it('should cache the manifest when not already cached', () => { + const mockManifest = { key: 'value' } + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockManifest)) + + const result = loadManifest('path/to/manifest', true, cache) + + expect(result).toEqual(mockManifest) + expect(cache.get('path/to/manifest')).toEqual(mockManifest) + expect(readFileSync).toHaveBeenCalledWith('path/to/manifest', 'utf8') + }) + + it('should throw an error when the manifest file cannot be read', () => { + ;(readFileSync as jest.Mock).mockImplementation(() => { + throw new Error('File not found') + }) + + expect(() => loadManifest('path/to/manifest', false)).toThrow( + 'File not found' + ) + }) + + it('should freeze the manifest when caching', () => { + const mockManifest = { key: 'value', nested: { key: 'value' } } + ;(readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockManifest)) + + const result = loadManifest( + 'path/to/manifest', + true, + cache + ) as typeof mockManifest + expect(Object.isFrozen(result)).toBe(true) + expect(Object.isFrozen(result.nested)).toBe(true) + + const result2 = loadManifest('path/to/manifest', true, cache) + expect(Object.isFrozen(result2)).toBe(true) + + expect(result).toBe(result2) + }) +}) diff --git a/packages/next/src/server/load-manifest.ts b/packages/next/src/server/load-manifest.ts index 82b2ae1d0272d..59a724e84b0e3 100644 --- a/packages/next/src/server/load-manifest.ts +++ b/packages/next/src/server/load-manifest.ts @@ -1,19 +1,52 @@ +import type { DeepReadonly } from '../shared/lib/deep-readonly' + import { readFileSync } from 'fs' import { runInNewContext } from 'vm' +import { deepFreeze } from '../shared/lib/deep-freeze' -const cache = new Map() +const sharedCache = new Map() -export function loadManifest( +/** + * Load a manifest file from the file system. Optionally cache the manifest in + * memory to avoid reading the file multiple times using the provided cache or + * defaulting to a shared module cache. The manifest is frozen to prevent + * modifications if it is cached. + * + * @param path the path to the manifest file + * @param shouldCache whether to cache the manifest in memory + * @param cache the cache to use for storing the manifest + * @returns the manifest object + */ +export function loadManifest( + path: string, + shouldCache: false +): T +export function loadManifest( + path: string, + shouldCache?: boolean, + cache?: Map +): DeepReadonly +export function loadManifest( + path: string, + shouldCache?: true, + cache?: Map +): DeepReadonly +export function loadManifest( path: string, - shouldCache: boolean = true -): unknown { + shouldCache: boolean = true, + cache = sharedCache +): T { const cached = shouldCache && cache.get(path) - if (cached) { - return cached + return cached as T } - const manifest = JSON.parse(readFileSync(path, 'utf8')) + let manifest = JSON.parse(readFileSync(path, 'utf8')) + + // Freeze the manifest so it cannot be modified if we're caching it. + if (shouldCache) { + manifest = deepFreeze(manifest) + } if (shouldCache) { cache.set(path, manifest) @@ -22,14 +55,28 @@ export function loadManifest( return manifest } -export function evalManifest( +export function evalManifest( path: string, - shouldCache: boolean = true -): unknown { + shouldCache: false +): T +export function evalManifest( + path: string, + shouldCache?: boolean, + cache?: Map +): DeepReadonly +export function evalManifest( + path: string, + shouldCache?: true, + cache?: Map +): DeepReadonly +export function evalManifest( + path: string, + shouldCache: boolean = true, + cache = sharedCache +): T { const cached = shouldCache && cache.get(path) - if (cached) { - return cached + return cached as T } const content = readFileSync(path, 'utf8') @@ -37,16 +84,21 @@ export function evalManifest( throw new Error('Manifest file is empty') } - const contextObject = {} + let contextObject = {} runInNewContext(content, contextObject) + // Freeze the context object so it cannot be modified if we're caching it. + if (shouldCache) { + contextObject = deepFreeze(contextObject) + } + if (shouldCache) { cache.set(path, contextObject) } - return contextObject + return contextObject as T } -export function clearManifestCache(path: string): boolean { +export function clearManifestCache(path: string, cache = sharedCache): boolean { return cache.delete(path) } diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index d9a2292ad5f30..87e0cc7972648 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -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 { diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 9709e740d7d02..6ef1e948d103c 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -22,7 +22,7 @@ import { } from './api-utils' import { getCookieParser } from './api-utils/get-cookie-parser' import type { FontManifest, FontConfig } from './font-utils' -import type { LoadComponentsReturnType, ManifestItem } from './load-components' +import type { LoadComponentsReturnType } from './load-components' import type { GetServerSideProps, GetStaticProps, @@ -106,6 +106,7 @@ import { RenderSpan } from './lib/trace/constants' import { ReflectAdapter } from './web/spec-extension/adapters/reflect' import { formatRevalidate } from './lib/revalidate' import { getErrorSource } from '../shared/lib/error-source' +import type { DeepReadonly } from '../shared/lib/deep-readonly' let tryGetPreviewData: typeof import('./api-utils/node/try-get-preview-data').tryGetPreviewData let warn: typeof import('../build/output/log').warn @@ -255,15 +256,15 @@ export type RenderOptsPartial = { unstable_runtimeJS?: false unstable_JsPreload?: false optimizeFonts: FontConfig - fontManifest?: FontManifest + fontManifest?: DeepReadonly optimizeCss: any nextConfigOutput?: 'standalone' | 'export' nextScriptWorkers: any assetQueryString?: string resolvedUrl?: string resolvedAsPath?: string - clientReferenceManifest?: ClientReferenceManifest - nextFontManifest?: NextFontManifest + clientReferenceManifest?: DeepReadonly + nextFontManifest?: DeepReadonly distDir?: string locale?: string locales?: string[] @@ -1436,7 +1437,7 @@ export async function renderToHTMLImpl( const dynamicImports = new Set() for (const mod of reactLoadableModules) { - const manifestItem: ManifestItem = reactLoadableManifest[mod] + const manifestItem = reactLoadableManifest[mod] if (manifestItem) { dynamicImportsIds.add(manifestItem.id) diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index 797079023b4e2..3c983a1f99f25 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -33,6 +33,7 @@ import type { PAGE_TYPES } from '../lib/page-types' import type { Rewrite } from '../lib/load-custom-routes' import { buildCustomRoute } from '../lib/build-custom-route' import { UNDERSCORE_NOT_FOUND_ROUTE } from '../api/constants' +import type { DeepReadonly } from '../shared/lib/deep-readonly' interface WebServerOptions extends Options { webServerConfig: { @@ -48,7 +49,7 @@ interface WebServerOptions extends Options { | typeof import('./app-render/app-render').renderToHTMLOrFlight | undefined incrementalCacheHandler?: any - prerenderManifest: PrerenderManifest | undefined + prerenderManifest: DeepReadonly | undefined interceptionRouteRewrites?: Rewrite[] } } diff --git a/packages/next/src/shared/lib/deep-freeze.test.ts b/packages/next/src/shared/lib/deep-freeze.test.ts new file mode 100644 index 0000000000000..a487c5f5866d0 --- /dev/null +++ b/packages/next/src/shared/lib/deep-freeze.test.ts @@ -0,0 +1,41 @@ +import { deepFreeze } from './deep-freeze' + +describe('freeze', () => { + it('should freeze an object', () => { + const obj = { a: 1, b: 2 } + deepFreeze(obj) + expect(Object.isFrozen(obj)).toBe(true) + }) + + it('should freeze an array', () => { + const arr = [1, 2, 3] + deepFreeze(arr) + expect(Object.isFrozen(arr)).toBe(true) + }) + + it('should freeze nested objects', () => { + const obj = { a: { b: 2 }, c: 3 } + deepFreeze(obj) + expect(Object.isFrozen(obj)).toBe(true) + expect(Object.isFrozen(obj.a)).toBe(true) + }) + + it('should freeze nested arrays', () => { + const arr = [ + [1, 2], + [3, 4], + ] + deepFreeze(arr) + expect(Object.isFrozen(arr)).toBe(true) + expect(Object.isFrozen(arr[0])).toBe(true) + expect(Object.isFrozen(arr[1])).toBe(true) + }) + + it('should freeze nested objects and arrays', () => { + const obj = { a: [1, 2], b: { c: 3 } } + deepFreeze(obj) + expect(Object.isFrozen(obj)).toBe(true) + expect(Object.isFrozen(obj.a)).toBe(true) + expect(Object.isFrozen(obj.b)).toBe(true) + }) +}) diff --git a/packages/next/src/shared/lib/deep-freeze.ts b/packages/next/src/shared/lib/deep-freeze.ts new file mode 100644 index 0000000000000..cc51e767236f5 --- /dev/null +++ b/packages/next/src/shared/lib/deep-freeze.ts @@ -0,0 +1,32 @@ +import type { DeepReadonly } from './deep-readonly' + +/** + * 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 deepFreeze(obj: T): DeepReadonly { + // If the object is already frozen, there's no need to freeze it again. + if (Object.isFrozen(obj)) return obj as DeepReadonly + + // 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 + deepFreeze(item) + } + + return Object.freeze(obj) as DeepReadonly + } + + for (const value of Object.values(obj)) { + if (!value || typeof value !== 'object') continue + deepFreeze(value) + } + + return Object.freeze(obj) as DeepReadonly +} diff --git a/packages/next/src/shared/lib/deep-readonly.ts b/packages/next/src/shared/lib/deep-readonly.ts new file mode 100644 index 0000000000000..f6b700a6b6bd4 --- /dev/null +++ b/packages/next/src/shared/lib/deep-readonly.ts @@ -0,0 +1,12 @@ +/** + * A type that represents a deeply readonly object. This is similar to + * TypeScript's `Readonly` type, but it recursively applies the `readonly` + * modifier to all properties of an object and all elements of arrays. + */ +export type DeepReadonly = T extends (infer R)[] + ? ReadonlyArray> + : T extends object + ? { + readonly [K in keyof T]: DeepReadonly + } + : T diff --git a/packages/next/src/shared/lib/html-context.shared-runtime.ts b/packages/next/src/shared/lib/html-context.shared-runtime.ts index 33b2dce0f06ba..a1c060ef8f2fa 100644 --- a/packages/next/src/shared/lib/html-context.shared-runtime.ts +++ b/packages/next/src/shared/lib/html-context.shared-runtime.ts @@ -3,6 +3,7 @@ import type { ServerRuntime } from 'next/types' import type { NEXT_DATA } from './utils' import type { FontConfig } from '../../server/font-utils' import type { NextFontManifest } from '../../build/webpack/plugins/next-font-manifest-plugin' +import type { DeepReadonly } from './deep-readonly' import { createContext, useContext } from 'react' @@ -45,7 +46,7 @@ export type HtmlProps = { runtime?: ServerRuntime hasConcurrentFeatures?: boolean largePageDataBytes?: number - nextFontManifest?: NextFontManifest + nextFontManifest?: DeepReadonly } export const HtmlContext = createContext(undefined)