Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions src/build/content/prerendered.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from 'node:path'
import { trace } from '@opentelemetry/api'
import { wrapTracer } from '@opentelemetry/api/experimental'
import { glob } from 'fast-glob'
import type { RouteMetadata } from 'next-with-cache-handler-v2/dist/export/routes/types.js'
import pLimit from 'p-limit'
import { satisfies } from 'semver'

Expand Down Expand Up @@ -69,21 +70,44 @@ const buildPagesCacheValue = async (
revalidate: initialRevalidateSeconds,
})

const RSC_SEGMENTS_DIR_SUFFIX = '.segments'
const RSC_SEGMENT_SUFFIX = '.segment.rsc'

const buildAppCacheValue = async (
path: string,
shouldUseAppPageKind: boolean,
): Promise<NetlifyCachedAppPageValue | NetlifyCachedPageValue> => {
const meta = JSON.parse(await readFile(`${path}.meta`, 'utf-8'))
const meta = JSON.parse(await readFile(`${path}.meta`, 'utf-8')) as RouteMetadata
const html = await readFile(`${path}.html`, 'utf-8')

// supporting both old and new cache kind for App Router pages - https://github.com/vercel/next.js/pull/65988
if (shouldUseAppPageKind) {
// segments are normalized and outputted separately for each segment, we denormalize it here and stitch
// fully constructed segmentData to avoid data fetch waterfalls later in cache handler at runtime
// https://github.com/vercel/next.js/blob/def2c6ba75dff754767379afb44c26c30bd3d96b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts#L185
let segmentData: NetlifyCachedAppPageValue['segmentData']
if (meta.segmentPaths) {
const segmentsDir = path + RSC_SEGMENTS_DIR_SUFFIX

segmentData = Object.fromEntries(
await Promise.all(
meta.segmentPaths.map(async (segmentPath: string) => {
const segmentDataFilePath = segmentsDir + segmentPath + RSC_SEGMENT_SUFFIX

const segmentContent = await readFile(segmentDataFilePath, 'base64')
return [segmentPath, segmentContent]
}),
),
)
}

return {
kind: 'APP_PAGE',
html,
rscData: await readFile(`${path}.rsc`, 'base64').catch(() =>
readFile(`${path}.prefetch.rsc`, 'base64'),
),
segmentData,
...meta,
}
}
Expand All @@ -97,7 +121,10 @@ const buildAppCacheValue = async (
if (
!meta.status &&
rsc.includes('NEXT_NOT_FOUND') &&
!meta.headers['x-next-cache-tags'].includes('/@')
!(
typeof meta.headers?.['x-next-cache-tags'] === 'string' &&
meta.headers?.['x-next-cache-tags'].includes('/@')
)
Comment on lines +124 to +127
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note here - I typed meta (see line 80) above, and because headers is typed as OutgoingHttpHeaders (and not Record<string,string>) it required some additional type guard here to satisfy type checking

) {
meta.status = 404
}
Expand Down
18 changes: 17 additions & 1 deletion src/run/handlers/cache.cts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
requestContext.isCacheableAppPage = true
}

const { revalidate, rscData, ...restOfPageValue } = blob.value
const { revalidate, rscData, segmentData, ...restOfPageValue } = blob.value

span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl })

Expand All @@ -375,6 +375,14 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
value: {
...restOfPageValue,
rscData: rscData ? Buffer.from(rscData, 'base64') : undefined,
segmentData: segmentData
? new Map(
Object.entries(segmentData).map(([segmentPath, base64EncodedSegment]) => [
segmentPath,
Buffer.from(base64EncodedSegment, 'base64'),
]),
)
: undefined,
},
}
}
Expand Down Expand Up @@ -416,6 +424,14 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
revalidate: context.revalidate ?? context.cacheControl?.revalidate,
cacheControl: context.cacheControl,
rscData: data.rscData?.toString('base64'),
segmentData: data.segmentData
? Object.fromEntries(
[...data.segmentData.entries()].map(([segmentPath, base64EncodedSegment]) => [
segmentPath,
base64EncodedSegment.toString('base64'),
]),
)
: undefined,
}
}

Expand Down
9 changes: 6 additions & 3 deletions src/shared/cache-types.cts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
IncrementalCachedPageValue,
IncrementalCacheValue,
} from 'next/dist/server/response-cache/types.js'
import type { IncrementalCachedAppPageValue as IncrementalCachedAppPageValueForNewVersions } from 'next-with-cache-handler-v2/dist/server/response-cache/types.js'

export type { CacheHandlerContext } from 'next/dist/server/lib/incremental-cache/index.js'

Expand Down Expand Up @@ -44,17 +45,19 @@ type IncrementalCachedAppPageValueForMultipleVersions = Omit<
'kind'
> & {
kind: 'APP_PAGE'
}
} & Pick<IncrementalCachedAppPageValueForNewVersions, 'segmentData'>
Copy link
Contributor Author

@pieh pieh Sep 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is terrible (stitching type from different next versions, but if I just tried to use types for newer version, all hell broke lose, so this, while awful, felt like only reasonable option without getting stuck in fixing bunch of types


/**
* Used for storing in blobs and reading from blobs
*/
export type NetlifyCachedAppPageValue = Omit<
IncrementalCachedAppPageValueForMultipleVersions,
'rscData'
'rscData' | 'segmentData'
> & {
// Next.js stores rscData as buffer, while we store it as base64 encoded string
// Next.js stores rscData as Buffer, while we store it as base64 encoded string
rscData: string | undefined
// Next.js stores segmentData as Map<string, Buffer>, while we store it as Record<string, string>, where value is base64 encoded string
Copy link
Contributor Author

@pieh pieh Sep 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for clarity - we have to use JSON serializable primitives for our storage in blobs, hence why we need to swap between Map->Record and Buffer->Base64 string

segmentData: Record<string, string> | undefined
revalidate?: Parameters<CacheHandler['set']>[2]['revalidate']
cacheControl?: CacheControl
}
Expand Down
Loading