From 5fcc4f7b7d568f3f0445e8053d0a1d89f2b9e348 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 7 Jun 2025 23:21:46 -0700 Subject: [PATCH 1/3] Add response handling in pages handler --- packages/next/errors.json | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/next/errors.json b/packages/next/errors.json index bfbde4c01aa6..02719e04fa27 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -699,10 +699,8 @@ "698": "Next DevTools: Can't dispatch %s in this environment. This is a bug in Next.js", "699": "Next DevTools: App Dev Overlay is already mounted. This is a bug in Next.js", "700": "Next DevTools: Pages Dev Overlay is already mounted. This is a bug in Next.js", - "701": "Invariant: app-route received invalid cache entry %s", - "702": "Invariant: unexpected APP_ROUTE cache data", - "703": "Route is configured with dynamic = error which cannot be statically generated.", - "704": "Route is configured with dynamic = error be statically generated.", - "705": "Route is configured with dynamic = error that cannot be statically generated.", - "706": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload." + "701": "Route is configured with dynamic = error which cannot be statically generated.", + "702": "Invariant: app-route received invalid cache entry %s", + "703": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload.", + "704": "Invariant: unexpected APP_ROUTE cache data" } From 7aff6633d3a6d73f62b2cff1aaba6c4927ab3937 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sun, 8 Jun 2025 12:43:52 -0700 Subject: [PATCH 2/3] Add response handling in app route handler --- packages/next/errors.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/next/errors.json b/packages/next/errors.json index 02719e04fa27..86ba6ca4ab6c 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -699,8 +699,9 @@ "698": "Next DevTools: Can't dispatch %s in this environment. This is a bug in Next.js", "699": "Next DevTools: App Dev Overlay is already mounted. This is a bug in Next.js", "700": "Next DevTools: Pages Dev Overlay is already mounted. This is a bug in Next.js", - "701": "Route is configured with dynamic = error which cannot be statically generated.", - "702": "Invariant: app-route received invalid cache entry %s", - "703": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload.", - "704": "Invariant: unexpected APP_ROUTE cache data" + "701": "Invariant: app-route received invalid cache entry %s", + "702": "Invariant: unexpected APP_ROUTE cache data", + "703": "Route is configured with dynamic = error which cannot be statically generated.", + "704": "Route is configured with dynamic = error be statically generated.", + "705": "Route is configured with dynamic = error that cannot be statically generated." } From 32f91e2f512657c8aeb319c396f3f36eea99a6e8 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Wed, 11 Jun 2025 12:58:55 +0200 Subject: [PATCH 3/3] Add response handling in app page handler --- packages/next/errors.json | 5 +- packages/next/src/build/templates/app-page.ts | 965 +++++++++++++++--- .../next/src/build/templates/app-route.ts | 27 +- packages/next/src/build/templates/pages.ts | 22 +- packages/next/src/server/base-server.ts | 63 +- .../next/src/server/dev/next-dev-server.ts | 50 +- .../next/src/server/response-cache/types.ts | 1 + 7 files changed, 925 insertions(+), 208 deletions(-) diff --git a/packages/next/errors.json b/packages/next/errors.json index 86ba6ca4ab6c..00830369fff6 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -703,5 +703,8 @@ "702": "Invariant: unexpected APP_ROUTE cache data", "703": "Route is configured with dynamic = error which cannot be statically generated.", "704": "Route is configured with dynamic = error be statically generated.", - "705": "Route is configured with dynamic = error that cannot be statically generated." + "705": "Route is configured with dynamic = error that cannot be statically generated.", + "706": "Invariant: static responses cannot be streamed %s", + "707": "Invariant app-page handler received invalid cache entry %s", + "708": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload." } diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index 15e0264552d6..b9cfa9adce1a 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -15,7 +15,10 @@ import { BaseServerSpan } from '../../server/lib/trace/constants' import { interopDefault } from '../../server/app-render/interop-default' import { NodeNextRequest, NodeNextResponse } from '../../server/base-http/node' import { checkIsAppPPREnabled } from '../../server/lib/experimental/ppr' -import { getFallbackRouteParams } from '../../server/request/fallback-params' +import { + getFallbackRouteParams, + type FallbackRouteParams, +} from '../../server/request/fallback-params' import { setReferenceManifestsSingleton } from '../../server/app-render/encryption-utils' import { isHtmlBotRequest, @@ -27,7 +30,25 @@ import { getIsPossibleServerAction } from '../../server/lib/server-action-reques import { RSC_HEADER, NEXT_ROUTER_PREFETCH_HEADER, + NEXT_IS_PRERENDER_HEADER, + NEXT_DID_POSTPONE_HEADER, } from '../../client/components/app-router-headers' +import { getBotType, isBot } from '../../shared/lib/router/utils/is-bot' +import { + CachedRouteKind, + type CachedAppPageValue, + type CachedPageValue, + type ResponseCacheEntry, + type ResponseGenerator, +} from '../../server/response-cache' +import { decodePathParams } from '../../server/lib/router-utils/decode-path-params' +import { FallbackMode, parseFallbackField } from '../../lib/fallback' +import RenderResult from '../../server/render-result' +import { CACHE_ONE_YEAR, NEXT_CACHE_TAGS_HEADER } from '../../lib/constants' +import type { CacheControl } from '../../server/lib/cache-control' +import { ENCODED_TAGS } from '../../server/stream-utils/encoded-tags' +import { sendRenderResult } from '../../server/send-payload' +import { NoFallbackError } from '../../shared/lib/no-fallback-error.external' // These are injected by the loader afterwards. @@ -62,7 +83,6 @@ export const __next_app__ = { } import * as entryBase from '../../server/app-render/entry-base' with { 'turbopack-transition': 'next-server-utility' } -import { getBotType } from '../../shared/lib/router/utils/is-bot' export * from '../../server/app-render/entry-base' with { 'turbopack-transition': 'next-server-utility' } @@ -105,7 +125,9 @@ export async function handler( const multiZoneDraftMode = process.env .__NEXT_MULTI_ZONE_DRAFT_MODE as any as boolean - const postponed = getRequestMeta(req, 'postponed') + const initialPostponed = getRequestMeta(req, 'postponed') + // TODO: replace with more specific flags + const minimalMode = getRequestMeta(req, 'minimalMode') const prepareResult = await routeModule.prepare(req, res, { srcPage, @@ -133,19 +155,65 @@ export async function handler( subresourceIntegrityManifest, prerenderManifest, isDraftMode, - isOnDemandRevalidate, + + revalidateOnlyGenerated, routerServerContext, nextConfig, } = prepareResult const pathname = parsedUrl.pathname || '/' const normalizedSrcPage = normalizeAppPath(srcPage) - let isIsr = Boolean( - prerenderManifest.dynamicRoutes[normalizedSrcPage] || - prerenderManifest.routes[normalizedSrcPage] || - prerenderManifest.routes[pathname] + + let { isOnDemandRevalidate } = prepareResult + + // TODO: rework this to not be necessary as a middleware + // rewrite should not need to pass this context like this + // maybe we rely on rewrite header instead + let resolvedPathname = getRequestMeta(req, 'rewroteURL') || pathname + + if (resolvedPathname === '/index') { + resolvedPathname = '/' + } + resolvedPathname = decodePathParams(resolvedPathname) + + const prerenderInfo = prerenderManifest.dynamicRoutes[normalizedSrcPage] + const isPrerendered = prerenderManifest.routes[resolvedPathname] + + let isSSG = Boolean( + prerenderInfo || + isPrerendered || + prerenderManifest.routes[normalizedSrcPage] ) + // if the page is dynamicParams: false and this pathname wasn't prerender + // trigger the no fallback handling + if (isSSG && prerenderInfo?.fallback === false && !isPrerendered) { + throw new NoFallbackError() + } + + const userAgent = req.headers['user-agent'] || '' + const botType = getBotType(userAgent) + const isHtmlBot = isHtmlBotRequest(req) + + /** + * If true, this indicates that the request being made is for an app + * prefetch request. + */ + const isPrefetchRSCRequest = + getRequestMeta(req, 'isPrefetchRSCRequest') ?? + Boolean(req.headers[NEXT_ROUTER_PREFETCH_HEADER]) + + // NOTE: Don't delete headers[RSC] yet, it still needs to be used in renderToHTML later + + const isRSCRequest = + getRequestMeta(req, 'isRSCRequest') ?? Boolean(req.headers[RSC_HEADER]) + + const isPossibleServerAction = getIsPossibleServerAction(req) + + /** + * If the route being rendered is an app page, and the ppr feature has been + * enabled, then the given route _could_ support PPR. + */ const couldSupportPPR: boolean = checkIsAppPPREnabled( nextConfig.experimental.ppr ) @@ -164,22 +232,19 @@ export async function handler( // This page supports PPR if it is marked as being `PARTIALLY_STATIC` in the // prerender manifest and this is an app page. - const isRoutePPREnabled = Boolean( + const isRoutePPREnabled: boolean = couldSupportPPR && - (( - prerenderManifest.routes[normalizedSrcPage] ?? - prerenderManifest.routes[pathname] ?? - prerenderManifest.dynamicRoutes[normalizedSrcPage] - )?.renderingMode === 'PARTIALLY_STATIC' || - // Ideally we'd want to check the appConfig to see if this page has PPR - // enabled or not, but that would require plumbing the appConfig through - // to the server during development. We assume that the page supports it - // but only during development. - (hasDebugStaticShellQuery && - (routeModule.isDev || routerServerContext?.experimentalTestProxy))) - ) - - const isDebugFallbackShell = hasDebugFallbackShellQuery && isRoutePPREnabled + (( + prerenderManifest.routes[normalizedSrcPage] ?? + prerenderManifest.dynamicRoutes[normalizedSrcPage] + )?.renderingMode === 'PARTIALLY_STATIC' || + // Ideally we'd want to check the appConfig to see if this page has PPR + // enabled or not, but that would require plumbing the appConfig through + // to the server during development. We assume that the page supports it + // but only during development. + (hasDebugStaticShellQuery && + (routeModule.isDev === true || + routerServerContext?.experimentalTestProxy === true))) const isDebugStaticShell: boolean = hasDebugStaticShellQuery && isRoutePPREnabled @@ -189,23 +254,67 @@ export async function handler( const isDebugDynamicAccesses = isDebugStaticShell && routeModule.isDev === true - const isRSCRequest = - getRequestMeta(req, 'isRSCRequest') || Boolean(req.headers[RSC_HEADER]) + const isDebugFallbackShell = hasDebugFallbackShellQuery && isRoutePPREnabled - const userAgent = req.headers['user-agent'] || '' - const botType = getBotType(userAgent) - const isHtmlBot = isHtmlBotRequest(req) + // If we're in minimal mode, then try to get the postponed information from + // the request metadata. If available, use it for resuming the postponed + // render. + const minimalPostponed = isRoutePPREnabled ? initialPostponed : undefined + + // If PPR is enabled, and this is a RSC request (but not a prefetch), then + // we can use this fact to only generate the flight data for the request + // because we can't cache the HTML (as it's also dynamic). + const isDynamicRSCRequest = + isRoutePPREnabled && isRSCRequest && !isPrefetchRSCRequest + + // Need to read this before it's stripped by stripFlightHeaders. We don't + // need to transfer it to the request meta because it's only read + // within this function; the static segment data should have already been + // generated, so we will always either return a static response or a 404. + const segmentPrefetchHeader = getRequestMeta(req, 'segmentPrefetchRSCRequest') + + // TODO: investigate existing bug with shouldServeStreamingMetadata always + // being true for a revalidate due to modifying the base-server this.renderOpts + // when fixing this to correct logic it causes hydration issue since we set + // serveStreamingMetadata to true during export + let serveStreamingMetadata = !userAgent + ? true + : shouldServeStreamingMetadata(userAgent, nextConfig.htmlLimitedBots) + + if (isHtmlBot && isRoutePPREnabled) { + isSSG = false + serveStreamingMetadata = false + } + + // In development, we always want to generate dynamic HTML. + let supportsDynamicResponse: boolean = + // If we're in development, we always support dynamic HTML, unless it's + // a data request, in which case we only produce static HTML. + routeModule.isDev === true || + // If this is not SSG or does not have static paths, then it supports + // dynamic HTML. + !isSSG || + // If this request has provided postponed data, it supports dynamic + // HTML. + typeof initialPostponed === 'string' || + // If this is a dynamic RSC request, then this render supports dynamic + // HTML (it's dynamic). + isDynamicRSCRequest + + // When html bots request PPR page, perform the full dynamic rendering. const shouldWaitOnAllReady = isHtmlBot && isRoutePPREnabled - // If this is a dynamic route with PPR enabled and the default route - // matches were set, then we should pass the fallback route params to - // the renderer as this is a fallback revalidation request. - const fallbackRouteParams = - pageIsDynamic && - isRoutePPREnabled && - (getRequestMeta(req, 'renderFallbackShell') || isDebugFallbackShell) - ? getFallbackRouteParams(normalizedSrcPage) - : null + let ssgCacheKey: string | null = null + if ( + !isDraftMode && + isSSG && + !supportsDynamicResponse && + !isPossibleServerAction && + !minimalPostponed && + !isDynamicRSCRequest + ) { + ssgCacheKey = resolvedPathname + } const ComponentMod = { ...entryBase, @@ -231,62 +340,97 @@ export async function handler( }) } - const isPossibleServerAction = getIsPossibleServerAction(req) + const method = req.method || 'GET' + const tracer = getTracer() + const activeSpan = tracer.getActiveScopeSpan() - /** - * If true, this indicates that the request being made is for an app - * prefetch request. - */ - const isPrefetchRSCRequest = - getRequestMeta(req, 'isPrefetchRSCRequest') ?? - Boolean(isRSCRequest && req.headers[NEXT_ROUTER_PREFETCH_HEADER]) + try { + const invokeRouteModule = async ( + span: Span | undefined, + context: AppPageRouteHandlerContext + ) => { + const nextReq = new NodeNextRequest(req) + const nextRes = new NodeNextResponse(res) - // If PPR is enabled, and this is a RSC request (but not a prefetch), then - // we can use this fact to only generate the flight data for the request - // because we can't cache the HTML (as it's also dynamic). - const isDynamicRSCRequest = - isRoutePPREnabled && isRSCRequest && !isPrefetchRSCRequest + // TODO: adapt for putting the RDC inside the postponed data + // If we're in dev, and this isn't a prefetch or a server action, + // we should seed the resume data cache. + if (process.env.NODE_ENV === 'development') { + if ( + nextConfig.experimental.dynamicIO && + !isPrefetchRSCRequest && + !context.renderOpts.isPossibleServerAction + ) { + const warmup = await routeModule.warmup(nextReq, nextRes, context) - let supportsDynamicResponse: boolean = - // If we're in development, we always support dynamic HTML - routeModule.isDev === true || - // If this is not SSG or does not have static paths, then it supports - // dynamic HTML. - !isIsr || - // If this request has provided postponed data, it supports dynamic - // HTML. - typeof postponed === 'string' || - // If this is a dynamic RSC request, then this render supports dynamic - // HTML (it's dynamic). - isDynamicRSCRequest + // If the warmup is successful, we should use the resume data + // cache from the warmup. + if (warmup.metadata.devRenderResumeDataCache) { + context.renderOpts.devRenderResumeDataCache = + warmup.metadata.devRenderResumeDataCache + } + } + } - // This is a revalidation request if the request is for a static - // page and it is not being resumed from a postponed render and - // it is not a dynamic RSC request then it is a revalidation - // request. - const isRevalidate = - isIsr && !supportsDynamicResponse && !postponed && !isDynamicRSCRequest - - let serveStreamingMetadata = - // During the export phase of `next build` we're hard-coding - // `serveStreamingMetadata` to `true`, so we need to do the same during - // revalidation. - isRevalidate || - // Otherwise we're checking the user agent to decide if we should - // serve streaming metadata. - shouldServeStreamingMetadata(userAgent, nextConfig.htmlLimitedBots) + return routeModule.render(nextReq, nextRes, context).finally(() => { + if (!span) return - if (isHtmlBot && isRoutePPREnabled) { - isIsr = false - serveStreamingMetadata = false - } + span.setAttributes({ + 'http.status_code': res.statusCode, + 'next.rsc': false, + }) - const method = req.method || 'GET' - const tracer = getTracer() - const activeSpan = tracer.getActiveScopeSpan() + const rootSpanAttributes = tracer.getRootSpanAttributes() + // We were unable to get attributes, probably OTEL is not enabled + if (!rootSpanAttributes) { + return + } - try { - const invokeRouteModule = async (span?: Span) => { + if ( + rootSpanAttributes.get('next.span_type') !== + BaseServerSpan.handleRequest + ) { + console.warn( + `Unexpected root span type '${rootSpanAttributes.get( + 'next.span_type' + )}'. Please report this Next.js issue https://github.com/vercel/next.js` + ) + return + } + + const route = rootSpanAttributes.get('next.route') + if (route) { + const name = `${method} ${route}` + + span.setAttributes({ + 'next.route': route, + 'http.route': route, + 'next.span_name': name, + }) + span.updateName(name) + } else { + span.updateName(`${method} ${req.url}`) + } + }) + } + + const doRender = async ({ + span, + postponed, + fallbackRouteParams, + }: { + span?: Span + /** + * The postponed data for this render. This is only provided when resuming + * a render that has been postponed. + */ + postponed: string | undefined + + /** + * The unknown route params for this render. + */ + fallbackRouteParams: FallbackRouteParams | null + }): Promise => { const context: AppPageRouteHandlerContext = { query, params, @@ -312,7 +456,8 @@ export async function handler( postponed, shouldWaitOnAllReady, serveStreamingMetadata, - supportsDynamicResponse, + supportsDynamicResponse: + typeof postponed === 'string' || supportsDynamicResponse, buildManifest, nextFontManifest, reactLoadableManifest, @@ -323,7 +468,7 @@ export async function handler( dir: routeModule.projectDir, isDraftMode, - isRevalidate, + isRevalidate: isSSG && !postponed && !isDynamicRSCRequest, botType, isOnDemandRevalidate, isPossibleServerAction, @@ -387,75 +532,621 @@ export async function handler( dev: routeModule.isDev, }, } - const nextReq = new NodeNextRequest(req) - const nextRes = new NodeNextResponse(res) - // TODO: adapt for putting the RDC inside the postponed data - // If we're in dev, and this isn't a prefetch or a server action, - // we should seed the resume data cache. - if (process.env.NODE_ENV === 'development') { - if ( - nextConfig.experimental.dynamicIO && - !isPrefetchRSCRequest && - !isPossibleServerAction - ) { - const warmup = await routeModule.warmup(nextReq, nextRes, context) + const result = await invokeRouteModule(span, context) - // If the warmup is successful, we should use the resume data - // cache from the warmup. - if (warmup.metadata.devRenderResumeDataCache) { - context.renderOpts.devRenderResumeDataCache = - warmup.metadata.devRenderResumeDataCache + const { metadata } = result + + const { + cacheControl, + headers = {}, + // Add any fetch tags that were on the page to the response headers. + fetchTags: cacheTags, + } = metadata + + if (cacheTags) { + headers[NEXT_CACHE_TAGS_HEADER] = cacheTags + } + + // Pull any fetch metrics from the render onto the request. + ;(req as any).fetchMetrics = metadata.fetchMetrics + + // we don't throw static to dynamic errors in dev as isSSG + // is a best guess in dev since we don't have the prerender pass + // to know whether the path is actually static or not + if ( + isSSG && + cacheControl?.revalidate === 0 && + !routeModule.isDev && + !isRoutePPREnabled + ) { + const staticBailoutInfo = metadata.staticBailoutInfo + + const err = new Error( + `Page changed from static to dynamic at runtime ${resolvedPathname}${ + staticBailoutInfo?.description + ? `, reason: ${staticBailoutInfo.description}` + : `` + }` + + `\nsee more here https://nextjs.org/docs/messages/app-static-to-dynamic-error` + ) + + if (staticBailoutInfo?.stack) { + const stack = staticBailoutInfo.stack + err.stack = err.message + stack.substring(stack.indexOf('\n')) + } + + throw err + } + + return { + value: { + kind: CachedRouteKind.APP_PAGE, + html: result, + headers, + rscData: metadata.flightData, + postponed: metadata.postponed, + status: metadata.statusCode, + segmentData: metadata.segmentData, + } satisfies CachedAppPageValue, + cacheControl, + } satisfies ResponseCacheEntry + } + + const responseGenerator: ResponseGenerator = async ({ + hasResolved, + previousCacheEntry, + isRevalidating, + span, + }) => { + const isProduction = routeModule.isDev === false + const didRespond = hasResolved || res.writableEnded + + // skip on-demand revalidate if cache is not present and + // revalidate-if-generated is set + if ( + isOnDemandRevalidate && + revalidateOnlyGenerated && + !previousCacheEntry && + !minimalMode + ) { + if (routerServerContext?.render404) { + await routerServerContext.render404(req, res) + } else { + res.statusCode = 404 + res.end('This page could not be found') + } + return null + } + + let fallbackMode: FallbackMode | undefined + + if (prerenderInfo) { + fallbackMode = parseFallbackField(prerenderInfo.fallback) + } + + // When serving a bot request, we want to serve a blocking render and not + // the prerendered page. This ensures that the correct content is served + // to the bot in the head. + if (fallbackMode === FallbackMode.PRERENDER && isBot(userAgent)) { + fallbackMode = FallbackMode.BLOCKING_STATIC_RENDER + } + + if (previousCacheEntry?.isStale === -1) { + isOnDemandRevalidate = true + } + + // TODO: adapt for PPR + // only allow on-demand revalidate for fallback: true/blocking + // or for prerendered fallback: false paths + if ( + isOnDemandRevalidate && + (fallbackMode !== FallbackMode.NOT_FOUND || previousCacheEntry) + ) { + fallbackMode = FallbackMode.BLOCKING_STATIC_RENDER + } + + if ( + !minimalMode && + fallbackMode !== FallbackMode.BLOCKING_STATIC_RENDER && + ssgCacheKey && + !didRespond && + !isDraftMode && + pageIsDynamic && + (isProduction || !isPrerendered) + ) { + let fallbackResponse: ResponseCacheEntry | null | undefined + + if (isRoutePPREnabled && !isRSCRequest) { + // We use the response cache here to handle the revalidation and + // management of the fallback shell. + fallbackResponse = await routeModule.handleResponse({ + cacheKey: isProduction ? normalizedSrcPage : null, + req, + nextConfig, + routeKind: RouteKind.APP_PAGE, + isFallback: true, + prerenderManifest, + isRoutePPREnabled, + responseGenerator: async () => + doRender({ + span, + // We pass `undefined` as rendering a fallback isn't resumed + // here. + postponed: undefined, + fallbackRouteParams: + // If we're in production or we're debugging the fallback + // shell then we should postpone when dynamic params are + // accessed. + isProduction || isDebugFallbackShell + ? getFallbackRouteParams(normalizedSrcPage) + : null, + }), + waitUntil: ctx.waitUntil, + }) + + // If the fallback response was set to null, then we should return null. + if (fallbackResponse === null) return null + + // Otherwise, if we did get a fallback response, we should return it. + if (fallbackResponse) { + // Remove the cache control from the response to prevent it from being + // used in the surrounding cache. + delete fallbackResponse.cacheControl + + return fallbackResponse } } } + // Only requests that aren't revalidating can be resumed. If we have the + // minimal postponed data, then we should resume the render with it. + const postponed = + !isOnDemandRevalidate && !isRevalidating && minimalPostponed + ? minimalPostponed + : undefined - return routeModule.render(nextReq, nextRes, context).finally(() => { - if (!span) return + // When we're in minimal mode, if we're trying to debug the static shell, + // we should just return nothing instead of resuming the dynamic render. + if ( + (isDebugStaticShell || isDebugDynamicAccesses) && + typeof postponed !== 'undefined' + ) { + return { + cacheControl: { revalidate: 1, expire: undefined }, + value: { + kind: CachedRouteKind.PAGES, + html: RenderResult.fromStatic(''), + pageData: {}, + headers: undefined, + status: undefined, + } satisfies CachedPageValue, + } + } - span.setAttributes({ - 'http.status_code': res.statusCode, - 'next.rsc': false, - }) + // If this is a dynamic route with PPR enabled and the default route + // matches were set, then we should pass the fallback route params to + // the renderer as this is a fallback revalidation request. + const fallbackRouteParams = + pageIsDynamic && + isRoutePPREnabled && + (getRequestMeta(req, 'renderFallbackShell') || isDebugFallbackShell) + ? getFallbackRouteParams(pathname) + : null - const rootSpanAttributes = tracer.getRootSpanAttributes() - // We were unable to get attributes, probably OTEL is not enabled - if (!rootSpanAttributes) { - return + // Perform the render. + return doRender({ + span, + postponed, + fallbackRouteParams, + }) + } + + const handleResponse = async (span?: Span): Promise => { + const cacheEntry = await routeModule.handleResponse({ + cacheKey: ssgCacheKey, + responseGenerator: (c) => + responseGenerator({ + span, + ...c, + }), + routeKind: RouteKind.APP_PAGE, + isOnDemandRevalidate, + isRoutePPREnabled, + req, + nextConfig, + prerenderManifest, + waitUntil: ctx.waitUntil, + }) + + if (isDraftMode) { + res.setHeader( + 'Cache-Control', + 'private, no-cache, no-store, max-age=0, must-revalidate' + ) + } + + // In dev, we should not cache pages for any reason. + if (routeModule.isDev) { + res.setHeader('Cache-Control', 'no-store, must-revalidate') + } + + if (!cacheEntry) { + if (ssgCacheKey) { + // A cache entry might not be generated if a response is written + // in `getInitialProps` or `getServerSideProps`, but those shouldn't + // have a cache key. If we do have a cache key but we don't end up + // with a cache entry, then either Next.js or the application has a + // bug that needs fixing. + throw new Error('invariant: cache entry required but not generated') } + return null + } - if ( - rootSpanAttributes.get('next.span_type') !== - BaseServerSpan.handleRequest - ) { - console.warn( - `Unexpected root span type '${rootSpanAttributes.get( - 'next.span_type' - )}'. Please report this Next.js issue https://github.com/vercel/next.js` + if (cacheEntry.value?.kind !== CachedRouteKind.APP_PAGE) { + throw new Error( + `Invariant app-page handler received invalid cache entry ${cacheEntry.value?.kind}` + ) + } + + const didPostpone = typeof cacheEntry.value.postponed === 'string' + + if ( + isSSG && + // We don't want to send a cache header for requests that contain dynamic + // data. If this is a Dynamic RSC request or wasn't a Prefetch RSC + // request, then we should set the cache header. + !isDynamicRSCRequest && + (!didPostpone || isPrefetchRSCRequest) + ) { + if (!minimalMode) { + // set x-nextjs-cache header to match the header + // we set for the image-optimizer + res.setHeader( + 'x-nextjs-cache', + isOnDemandRevalidate + ? 'REVALIDATED' + : cacheEntry.isMiss + ? 'MISS' + : cacheEntry.isStale + ? 'STALE' + : 'HIT' ) - return } + // Set a header used by the client router to signal the response is static + // and should respect the `static` cache staleTime value. + res.setHeader(NEXT_IS_PRERENDER_HEADER, '1') + } + const { value: cachedData } = cacheEntry - const route = rootSpanAttributes.get('next.route') - if (route) { - const name = `${method} ${route}` + // Coerce the cache control parameter from the render. + let cacheControl: CacheControl | undefined - span.setAttributes({ - 'next.route': route, - 'http.route': route, - 'next.span_name': name, + // If this is a resume request in minimal mode it is streamed with dynamic + // content and should not be cached. + if (minimalPostponed) { + cacheControl = { revalidate: 0, expire: undefined } + } + + // If this is in minimal mode and this is a flight request that isn't a + // prefetch request while PPR is enabled, it cannot be cached as it contains + // dynamic content. + else if ( + minimalMode && + isRSCRequest && + !isPrefetchRSCRequest && + isRoutePPREnabled + ) { + cacheControl = { revalidate: 0, expire: undefined } + } else if (!routeModule.isDev) { + // If this is a preview mode request, we shouldn't cache it + if (isDraftMode) { + cacheControl = { revalidate: 0, expire: undefined } + } + + // If this isn't SSG, then we should set change the header only if it is + // not set already. + else if (!isSSG) { + if (!res.getHeader('Cache-Control')) { + cacheControl = { revalidate: 0, expire: undefined } + } + } else if (cacheEntry.cacheControl) { + // If the cache entry has a cache control with a revalidate value that's + // a number, use it. + if (typeof cacheEntry.cacheControl.revalidate === 'number') { + if (cacheEntry.cacheControl.revalidate < 1) { + throw new Error( + `Invalid revalidate configuration provided: ${cacheEntry.cacheControl.revalidate} < 1` + ) + } + + cacheControl = { + revalidate: cacheEntry.cacheControl.revalidate, + expire: cacheEntry.cacheControl?.expire ?? nextConfig.expireTime, + } + } + // Otherwise if the revalidate value is false, then we should use the + // cache time of one year. + else { + cacheControl = { revalidate: CACHE_ONE_YEAR, expire: undefined } + } + } + } + + cacheEntry.cacheControl = cacheControl + + if ( + typeof segmentPrefetchHeader === 'string' && + cachedData?.kind === CachedRouteKind.APP_PAGE && + cachedData.segmentData + ) { + // This is a prefetch request issued by the client Segment Cache. These + // should never reach the application layer (lambda). We should either + // respond from the cache (HIT) or respond with 204 No Content (MISS). + + // Set a header to indicate that PPR is enabled for this route. This + // lets the client distinguish between a regular cache miss and a cache + // miss due to PPR being disabled. In other contexts this header is used + // to indicate that the response contains dynamic data, but here we're + // only using it to indicate that the feature is enabled — the segment + // response itself contains whether the data is dynamic. + res.setHeader(NEXT_DID_POSTPONE_HEADER, '2') + + // Add the cache tags header to the response if it exists and we're in + // minimal mode while rendering a static page. + const tags = cachedData.headers?.[NEXT_CACHE_TAGS_HEADER] + if (minimalMode && isSSG && tags && typeof tags === 'string') { + res.setHeader(NEXT_CACHE_TAGS_HEADER, tags) + } + + const matchedSegment = cachedData.segmentData.get(segmentPrefetchHeader) + if (matchedSegment !== undefined) { + // Cache hit + return sendRenderResult({ + req, + res, + type: 'rsc', + generateEtags: nextConfig.generateEtags, + poweredByHeader: nextConfig.poweredByHeader, + result: RenderResult.fromStatic(matchedSegment), + cacheControl: cacheEntry.cacheControl, }) - span.updateName(name) - } else { - span.updateName(`${method} ${req.url}`) } + + // Cache miss. Either a cache entry for this route has not been generated + // (which technically should not be possible when PPR is enabled, because + // at a minimum there should always be a fallback entry) or there's no + // match for the requested segment. Respond with a 204 No Content. We + // don't bother to respond with 404, because these requests are only + // issued as part of a prefetch. + res.statusCode = 204 + return sendRenderResult({ + req, + res, + type: 'rsc', + generateEtags: nextConfig.generateEtags, + poweredByHeader: nextConfig.poweredByHeader, + result: RenderResult.fromStatic(''), + cacheControl: cacheEntry.cacheControl, + }) + } + + // If there's a callback for `onCacheEntry`, call it with the cache entry + // and the revalidate options. + const onCacheEntry = getRequestMeta(req, 'onCacheEntry') + if (onCacheEntry) { + const finished = await onCacheEntry( + { + ...cacheEntry, + // TODO: remove this when upstream doesn't + // always expect this value to be "PAGE" + value: { + ...cacheEntry.value, + kind: 'PAGE', + }, + }, + { + url: getRequestMeta(req, 'initURL'), + } + ) + if (finished) { + // TODO: maybe we have to end the request? + return null + } + } + + // If the request has a postponed state and it's a resume request we + // should error. + if (didPostpone && minimalPostponed) { + throw new Error( + 'Invariant: postponed state should not be present on a resume request' + ) + } + + if (cachedData.headers) { + const headers = { ...cachedData.headers } + + if (!minimalMode || !isSSG) { + delete headers[NEXT_CACHE_TAGS_HEADER] + } + + for (let [key, value] of Object.entries(headers)) { + if (typeof value === 'undefined') continue + + if (Array.isArray(value)) { + for (const v of value) { + res.appendHeader(key, v) + } + } else if (typeof value === 'number') { + value = value.toString() + res.appendHeader(key, value) + } else { + res.appendHeader(key, value) + } + } + } + + // Add the cache tags header to the response if it exists and we're in + // minimal mode while rendering a static page. + const tags = cachedData.headers?.[NEXT_CACHE_TAGS_HEADER] + if (minimalMode && isSSG && tags && typeof tags === 'string') { + res.setHeader(NEXT_CACHE_TAGS_HEADER, tags) + } + + // If the request is a data request, then we shouldn't set the status code + // from the response because it should always be 200. This should be gated + // behind the experimental PPR flag. + if (cachedData.status && (!isRSCRequest || !isRoutePPREnabled)) { + res.statusCode = cachedData.status + } + + // Mark that the request did postpone. + if (didPostpone) { + res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') + } + + // we don't go through this block when preview mode is true + // as preview mode is a dynamic request (bypasses cache) and doesn't + // generate both HTML and payloads in the same request so continue to just + // return the generated payload + if (isRSCRequest && !isDraftMode) { + // If this is a dynamic RSC request, then stream the response. + if (typeof cachedData.rscData === 'undefined') { + if (cachedData.postponed) { + throw new Error('Invariant: Expected postponed to be undefined') + } + + return sendRenderResult({ + req, + res, + type: 'rsc', + generateEtags: nextConfig.generateEtags, + poweredByHeader: nextConfig.poweredByHeader, + result: cachedData.html, + // Dynamic RSC responses cannot be cached, even if they're + // configured with `force-static` because we have no way of + // distinguishing between `force-static` and pages that have no + // postponed state. + // TODO: distinguish `force-static` from pages with no postponed state (static) + cacheControl: isDynamicRSCRequest + ? { revalidate: 0, expire: undefined } + : cacheEntry.cacheControl, + }) + } + + // As this isn't a prefetch request, we should serve the static flight + // data. + return sendRenderResult({ + req, + res, + type: 'rsc', + generateEtags: nextConfig.generateEtags, + poweredByHeader: nextConfig.poweredByHeader, + result: RenderResult.fromStatic(cachedData.rscData), + cacheControl: cacheEntry.cacheControl, + }) + } + + // This is a request for HTML data. + let body = cachedData.html + + // If there's no postponed state, we should just serve the HTML. This + // should also be the case for a resume request because it's completed + // as a server render (rather than a static render). + if (!didPostpone || minimalMode) { + return sendRenderResult({ + req, + res, + type: 'html', + generateEtags: nextConfig.generateEtags, + poweredByHeader: nextConfig.poweredByHeader, + result: body, + cacheControl: cacheEntry.cacheControl, + }) + } + + // If we're debugging the static shell or the dynamic API accesses, we + // should just serve the HTML without resuming the render. The returned + // HTML will be the static shell so all the Dynamic API's will be used + // during static generation. + if (isDebugStaticShell || isDebugDynamicAccesses) { + // Since we're not resuming the render, we need to at least add the + // closing body and html tags to create valid HTML. + body.chain( + new ReadableStream({ + start(controller) { + controller.enqueue(ENCODED_TAGS.CLOSED.BODY_AND_HTML) + controller.close() + }, + }) + ) + + return sendRenderResult({ + req, + res, + type: 'html', + generateEtags: nextConfig.generateEtags, + poweredByHeader: nextConfig.poweredByHeader, + result: body, + cacheControl: { revalidate: 0, expire: undefined }, + }) + } + + // This request has postponed, so let's create a new transformer that the + // dynamic data can pipe to that will attach the dynamic data to the end + // of the response. + const transformer = new TransformStream() + body.chain(transformer.readable) + + // Perform the render again, but this time, provide the postponed state. + // We don't await because we want the result to start streaming now, and + // we've already chained the transformer's readable to the render result. + doRender({ + span, + postponed: cachedData.postponed, + // This is a resume render, not a fallback render, so we don't need to + // set this. + fallbackRouteParams: null, + }) + .then(async (result) => { + if (!result) { + throw new Error('Invariant: expected a result to be returned') + } + + if (result.value?.kind !== CachedRouteKind.APP_PAGE) { + throw new Error( + `Invariant: expected a page response, got ${result.value?.kind}` + ) + } + + // Pipe the resume result to the transformer. + await result.value.html.pipeTo(transformer.writable) + }) + .catch((err) => { + // An error occurred during piping or preparing the render, abort + // the transformers writer so we can terminate the stream. + transformer.writable.abort(err).catch((e) => { + console.error("couldn't abort transformer", e) + }) + }) + + return sendRenderResult({ + req, + res, + type: 'html', + generateEtags: nextConfig.generateEtags, + poweredByHeader: nextConfig.poweredByHeader, + result: body, + // We don't want to cache the response if it has postponed data because + // the response being sent to the client it's dynamic parts are streamed + // to the client on the same request. + cacheControl: { revalidate: 0, expire: undefined }, }) } // TODO: activeSpan code path is for when wrapped by // next-server can be removed when this is no longer used if (activeSpan) { - return await invokeRouteModule(activeSpan) + await handleResponse(activeSpan) } else { return await tracer.withPropagatedContext(req.headers, () => tracer.trace( @@ -468,7 +1159,7 @@ export async function handler( 'http.target': req.url, }, }, - invokeRouteModule + handleResponse ) ) } @@ -483,7 +1174,7 @@ export async function handler( routePath: srcPage, routeType: 'render', revalidateReason: getRevalidateReason({ - isRevalidate, + isRevalidate: isSSG, isOnDemandRevalidate, }), }, diff --git a/packages/next/src/build/templates/app-route.ts b/packages/next/src/build/templates/app-route.ts index 4b492022381f..485dd516bece 100644 --- a/packages/next/src/build/templates/app-route.ts +++ b/packages/next/src/build/templates/app-route.ts @@ -24,6 +24,7 @@ import { import { decodePathParams } from '../../server/lib/router-utils/decode-path-params' import { getCacheControlHeader } from '../../server/lib/cache-control' import { INFINITE_CACHE, NEXT_CACHE_TAGS_HEADER } from '../../lib/constants' +import { NoFallbackError } from '../../shared/lib/no-fallback-error.external' import { CachedRouteKind, type ResponseCacheEntry, @@ -114,6 +115,7 @@ export async function handler( params, nextConfig, parsedUrl, + isDraftMode, prerenderManifest, routerServerContext, isOnDemandRevalidate, @@ -141,9 +143,20 @@ export async function handler( prerenderManifest.routes[resolvedPathname] ) + if (isIsr && !isDraftMode) { + const isPrerendered = Boolean(prerenderManifest.routes[resolvedPathname]) + const prerenderInfo = prerenderManifest.dynamicRoutes[normalizedSrcPage] + + if (prerenderInfo) { + if (prerenderInfo.fallback === false && !isPrerendered) { + throw new NoFallbackError() + } + } + } + let cacheKey: string | null = null - if (isIsr && !routeModule.isDev) { + if (isIsr && !routeModule.isDev && !isDraftMode) { cacheKey = resolvedPathname // ensure /index and / is normalized to one key cacheKey = cacheKey === '/index' ? '/' : cacheKey @@ -272,8 +285,8 @@ export async function handler( // Attempt using provided waitUntil if available // if it's not we fallback to sendResponse's handling if (pendingWaitUntil) { - if (context.renderOpts.waitUntil) { - context.renderOpts.waitUntil(pendingWaitUntil) + if (ctx.waitUntil) { + ctx.waitUntil(pendingWaitUntil) pendingWaitUntil = undefined } } @@ -390,6 +403,14 @@ export async function handler( ) } + // Draft mode should never be cached + if (isDraftMode) { + res.setHeader( + 'Cache-Control', + 'private, no-cache, no-store, max-age=0, must-revalidate' + ) + } + const headers = fromNodeOutgoingHttpHeaders(cacheEntry.value.headers) if (!(getRequestMeta(req, 'minimalMode') && isIsr)) { diff --git a/packages/next/src/build/templates/pages.ts b/packages/next/src/build/templates/pages.ts index 0743a32d7634..29bd4b29c4f1 100644 --- a/packages/next/src/build/templates/pages.ts +++ b/packages/next/src/build/templates/pages.ts @@ -226,16 +226,18 @@ export async function handler( const prerenderInfo = prerenderManifest.dynamicRoutes[srcPage] - if (prerenderInfo.fallback === false && !isPrerendered) { - throw new NoFallbackError() - } + if (prerenderInfo) { + if (prerenderInfo.fallback === false && !isPrerendered) { + throw new NoFallbackError() + } - if ( - typeof prerenderInfo.fallback === 'string' && - !isPrerendered && - !isNextDataRequest - ) { - isIsrFallback = true + if ( + typeof prerenderInfo.fallback === 'string' && + !isPrerendered && + !isNextDataRequest + ) { + isIsrFallback = true + } } } @@ -589,7 +591,7 @@ export async function handler( return } - if (!getRequestMeta(req, 'minimalMode')) { + if (hasStaticProps && !getRequestMeta(req, 'minimalMode')) { res.setHeader( 'x-nextjs-cache', isOnDemandRevalidate diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 842ae5f53072..56cce7d5b741 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -2641,14 +2641,8 @@ export default abstract class Server< // propagate the request context for dev setRequestMeta(request, getRequestMeta(req)) - addRequestMeta(request, 'postponed', postponed) addRequestMeta(request, 'projectDir', this.dir) addRequestMeta(request, 'isIsrFallback', pagesFallback) - addRequestMeta( - request, - 'renderFallbackShell', - Boolean(fallbackRouteParams) - ) addRequestMeta(request, 'query', query) addRequestMeta(request, 'params', opts.params) addRequestMeta( @@ -2661,7 +2655,6 @@ export default abstract class Server< if (renderOpts.err) { addRequestMeta(request, 'invokeError', renderOpts.err) } - response.statusCode = res.statusCode const handler: ( req: ServerRequest | IncomingMessage, @@ -2670,30 +2663,36 @@ export default abstract class Server< waitUntil: ReturnType } ) => Promise = components.ComponentMod.handler - result = await handler(request, response, { + + const maybeDevRequest = + // we need to capture fetch metrics when they are set + // and can't wait for handler to resolve as the fetch + // metrics are logged on response close which happens + // before handler resolves + process.env.NODE_ENV === 'development' + ? new Proxy(request, { + get(target: any, prop) { + if (typeof target[prop] === 'function') { + return target[prop].bind(target) + } + return target[prop] + }, + set(target: any, prop, value) { + if (prop === 'fetchMetrics') { + ;(req as any).fetchMetrics = value + } + target[prop] = value + return true + }, + }) + : request + + result = await handler(maybeDevRequest, response, { waitUntil: this.getWaitUntil(), }) - if (response.hasHeader('Cache-Control')) { - res.setHeader( - 'Cache-Control', - response.getHeader('Cache-Control') as string - ) - } - setRequestMeta(req, getRequestMeta(request)) - - // this is handled fully in handler - if ( - isAppRouteRouteModule(routeModule) || - isPagesRouteModule(routeModule) - ) { - return null - } - if (!result) { - throw new Error( - `Invariant: missing result from invoking ${pathname} handler` - ) - } + // response is handled fully in handler + return null } else { if (isPagesRouteModule(routeModule)) { // Due to the way we pass data by mutating `renderOpts`, we can't extend @@ -3053,7 +3052,7 @@ export default abstract class Server< } ) } - // If this is a app router page, PPR is enabled, and PFPR is also + // If this is a app router page, PPR is enabled, and PPR is also // enabled, then we should use the fallback renderer. else if ( isRoutePPREnabled && @@ -3149,7 +3148,8 @@ export default abstract class Server< // default _error module in dev doesn't have handler yet components.ComponentMod.handler && (isPagesRouteModule(components.routeModule) || - isAppRouteRouteModule(components.routeModule)) + isAppRouteRouteModule(components.routeModule) || + isAppPageRouteModule(components.routeModule)) ) { if ( routeModule?.isDev && @@ -3199,7 +3199,8 @@ export default abstract class Server< ssgCacheKey && !(isOnDemandRevalidate && revalidateOnlyGenerated) && !isPagesRouteModule(components.routeModule) && - !isAppRouteRouteModule(components.routeModule) + !isAppRouteRouteModule(components.routeModule) && + !isAppPageRouteModule(components.routeModule) ) { // A cache entry might not be generated if a response is written // in `getInitialProps` or `getServerSideProps`, but those shouldn't diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index c0bf0ea9bf34..2137b2f95cbd 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -68,7 +68,7 @@ import { decorateServerError } from '../../shared/lib/error-source' import type { ServerOnInstrumentationRequestError } from '../app-render/types' import type { ServerComponentsHmrCache } from '../response-cache' import { logRequests } from './log-requests' -import { FallbackMode } from '../../lib/fallback' +import { FallbackMode, fallbackModeToFallbackField } from '../../lib/fallback' import type { PagesDevOverlayBridgeType } from '../../client/components/react-dev-overlay/pages/pages-dev-overlay-setup' import { ensureInstrumentationRegistered, @@ -853,6 +853,7 @@ export default class DevServer extends Server { .then(async (res) => { const { prerenderedRoutes: staticPaths, fallbackMode: fallback } = res.value + if (!isAppPath && this.nextConfig.output === 'export') { if (fallback === FallbackMode.BLOCKING_STATIC_RENDER) { throw new Error( @@ -873,33 +874,30 @@ export default class DevServer extends Server { fallbackMode: fallback, } - // we write the static paths to partial manifest for - // fallback handling inside of entry handler's - const rawExistingManifest = await fs.promises.readFile( - pathJoin(this.distDir, PRERENDER_MANIFEST), - 'utf8' - ) - const existingManifest: PrerenderManifest = - JSON.parse(rawExistingManifest) - for (const staticPath of value.staticPaths || []) { - existingManifest.routes[staticPath] = {} as any - } - existingManifest.dynamicRoutes[pathname] = { - fallback: - value.fallbackMode === FallbackMode.PRERENDER - ? `${pathname}.html` - : value.fallbackMode === FallbackMode.BLOCKING_STATIC_RENDER - ? null - : false, - } as any - - const updatedManifest = JSON.stringify(existingManifest) - - if (updatedManifest !== rawExistingManifest) { - await fs.promises.writeFile( + if (res.value?.fallbackMode !== undefined) { + // we write the static paths to partial manifest for + // fallback handling inside of entry handler's + const rawExistingManifest = await fs.promises.readFile( pathJoin(this.distDir, PRERENDER_MANIFEST), - updatedManifest + 'utf8' ) + const existingManifest: PrerenderManifest = + JSON.parse(rawExistingManifest) + for (const staticPath of value.staticPaths || []) { + existingManifest.routes[staticPath] = {} as any + } + existingManifest.dynamicRoutes[pathname] = { + fallback: fallbackModeToFallbackField(res.value.fallbackMode, page), + } as any + + const updatedManifest = JSON.stringify(existingManifest) + + if (updatedManifest !== rawExistingManifest) { + await fs.promises.writeFile( + pathJoin(this.distDir, PRERENDER_MANIFEST), + updatedManifest + ) + } } this.staticPathsCache.set(pathname, value) return value diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 08a0d5ee2351..fc2478bf93d2 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -187,6 +187,7 @@ export type ResponseGenerator = (state: { hasResolved: boolean previousCacheEntry?: IncrementalResponseCacheEntry | null isRevalidating?: boolean + span?: any }) => Promise export const enum IncrementalCacheKind {