Skip to content

feat: support 'use cache' #2862

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 30, 2025
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
842 changes: 547 additions & 295 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"msw": "^2.0.7",
"netlify-cli": "^20.1.1",
"next": "^15.0.0-canary.28",
"next-with-cache-handler-v2": "npm:[email protected]",
"os": "^0.1.2",
"outdent": "^0.8.0",
"p-limit": "^5.0.0",
Expand All @@ -89,13 +90,18 @@
"uuid": "^10.0.0",
"vitest": "^3.0.0"
},
"overrides": {
"react": "19.0.0-rc.0",
"react-dom": "19.0.0-rc.0"
},
"clean-package": {
"indent": 2,
"remove": [
"clean-package",
"dependencies",
"devDependencies",
"scripts"
"scripts",
"overrides"
]
}
}
30 changes: 22 additions & 8 deletions src/build/content/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ import { join as posixJoin, sep as posixSep } from 'node:path/posix'
import { trace } from '@opentelemetry/api'
import { wrapTracer } from '@opentelemetry/api/experimental'
import glob from 'fast-glob'
import { prerelease, lt as semverLowerThan, lte as semverLowerThanOrEqual } from 'semver'
import { prerelease, satisfies, lt as semverLowerThan, lte as semverLowerThanOrEqual } from 'semver'

import { RUN_CONFIG } from '../../run/constants.js'
import { PluginContext } from '../plugin-context.js'
import type { RunConfig } from '../../run/config.js'
import { RUN_CONFIG_FILE } from '../../run/constants.js'
import type { PluginContext, RequiredServerFilesManifest } from '../plugin-context.js'

const tracer = wrapTracer(trace.getTracer('Next runtime'))

Expand Down Expand Up @@ -54,7 +55,9 @@ export const copyNextServerCode = async (ctx: PluginContext): Promise<void> => {
throw error
}
}
const reqServerFiles = JSON.parse(await readFile(reqServerFilesPath, 'utf-8'))
const reqServerFiles = JSON.parse(
await readFile(reqServerFilesPath, 'utf-8'),
) as RequiredServerFilesManifest

// if the resolved dist folder does not match the distDir of the required-server-files.json
// this means the path got altered by a plugin like nx and contained ../../ parts so we have to reset it
Expand All @@ -73,8 +76,17 @@ export const copyNextServerCode = async (ctx: PluginContext): Promise<void> => {
// write our run-config.json to the root dir so that we can easily get the runtime config of the required-server-files.json
// without the need to know about the monorepo or distDir configuration upfront.
await writeFile(
join(ctx.serverHandlerDir, RUN_CONFIG),
JSON.stringify(reqServerFiles.config),
join(ctx.serverHandlerDir, RUN_CONFIG_FILE),
JSON.stringify({
nextConfig: reqServerFiles.config,
// only enable setting up 'use cache' handler when Next.js supports CacheHandlerV2 as we don't have V1 compatible implementation
// see https://github.com/vercel/next.js/pull/76687 first released in v15.3.0-canary.13
enableUseCacheHandler: ctx.nextVersion
? satisfies(ctx.nextVersion, '>=15.3.0-canary.13', {
includePrerelease: true,
})
: false,
} satisfies RunConfig),
'utf-8',
)

Expand Down Expand Up @@ -336,9 +348,11 @@ const replaceMiddlewareManifest = async (sourcePath: string, destPath: string) =
}

export const verifyHandlerDirStructure = async (ctx: PluginContext) => {
const runConfig = JSON.parse(await readFile(join(ctx.serverHandlerDir, RUN_CONFIG), 'utf-8'))
const { nextConfig } = JSON.parse(
await readFile(join(ctx.serverHandlerDir, RUN_CONFIG_FILE), 'utf-8'),
) as RunConfig

const expectedBuildIDPath = join(ctx.serverHandlerDir, runConfig.distDir, 'BUILD_ID')
const expectedBuildIDPath = join(ctx.serverHandlerDir, nextConfig.distDir, 'BUILD_ID')
if (!existsSync(expectedBuildIDPath)) {
ctx.failBuild(
`Failed creating server handler. BUILD_ID file not found at expected location "${expectedBuildIDPath}".`,
Expand Down
9 changes: 7 additions & 2 deletions src/run/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ import { join, resolve } from 'node:path'

import type { NextConfigComplete } from 'next/dist/server/config-shared.js'

import { PLUGIN_DIR, RUN_CONFIG } from './constants.js'
import { PLUGIN_DIR, RUN_CONFIG_FILE } from './constants.js'
import { setInMemoryCacheMaxSizeFromNextConfig } from './storage/storage.cjs'

export type RunConfig = {
nextConfig: NextConfigComplete
enableUseCacheHandler: boolean
}

/**
* Get Next.js config from the build output
*/
export const getRunConfig = async () => {
return JSON.parse(await readFile(resolve(PLUGIN_DIR, RUN_CONFIG), 'utf-8'))
return JSON.parse(await readFile(resolve(PLUGIN_DIR, RUN_CONFIG_FILE), 'utf-8')) as RunConfig
}

type NextConfigForMultipleVersions = NextConfigComplete & {
Expand Down
2 changes: 1 addition & 1 deletion src/run/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ import { fileURLToPath } from 'node:url'
export const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
export const PLUGIN_DIR = resolve(`${MODULE_DIR}../../..`)
// a file where we store the required-server-files config object in to access during runtime
export const RUN_CONFIG = 'run-config.json'
export const RUN_CONFIG_FILE = 'run-config.json'
22 changes: 12 additions & 10 deletions src/run/handlers/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import type { OutgoingHttpHeaders } from 'http'
import { ComputeJsOutgoingMessage, toComputeResponse, toReqRes } from '@fastly/http-compute-js'
import type { Context } from '@netlify/functions'
import { Span } from '@opentelemetry/api'
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
import type { WorkerRequestHandler } from 'next/dist/server/lib/types.js'

import { getRunConfig, setRunConfig } from '../config.js'
import {
adjustDateHeader,
setCacheControlHeaders,
Expand All @@ -18,15 +18,22 @@ import { setFetchBeforeNextPatchedIt } from '../storage/storage.cjs'

import { getLogger, type RequestContext } from './request-context.cjs'
import { getTracer, recordWarning } from './tracer.cjs'
import { configureUseCacheHandlers } from './use-cache-handler.js'
import { setupWaitUntil } from './wait-until.cjs'

// make use of global fetch before Next.js applies any patching
setFetchBeforeNextPatchedIt(globalThis.fetch)
// configure globals that Next.js make use of before we start importing any Next.js code
// as some globals are consumed at import time
const { nextConfig, enableUseCacheHandler } = await getRunConfig()
if (enableUseCacheHandler) {
configureUseCacheHandlers()
}
setRunConfig(nextConfig)
setupWaitUntil()

const nextImportPromise = import('../next.cjs')

setupWaitUntil()

let nextHandler: WorkerRequestHandler, nextConfig: NextConfigComplete
let nextHandler: WorkerRequestHandler

/**
* When Next.js proxies requests externally, it writes the response back as-is.
Expand Down Expand Up @@ -61,11 +68,6 @@ export default async (

if (!nextHandler) {
await tracer.withActiveSpan('initialize next server', async () => {
// set the server config
const { getRunConfig, setRunConfig } = await import('../config.js')
nextConfig = await getRunConfig()
setRunConfig(nextConfig)

const { getMockedRequestHandler } = await nextImportPromise
const url = new URL(request.url)

Expand Down
21 changes: 21 additions & 0 deletions src/run/handlers/tags-handler.cts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ async function getTagRevalidatedAt(
return tagManifest.revalidatedAt
}

/**
* Get the most recent revalidation timestamp for a list of tags
*/
export async function getMostRecentTagRevalidationTimestamp(tags: string[]) {
if (tags.length === 0) {
return 0
}

const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })

const timestampsOrNulls = await Promise.all(
tags.map((tag) => getTagRevalidatedAt(tag, cacheStore)),
)

const timestamps = timestampsOrNulls.filter((timestamp) => timestamp !== null)
if (timestamps.length === 0) {
return 0
}
return Math.max(...timestamps)
}

Comment on lines +28 to +48
Copy link
Contributor Author

Choose a reason for hiding this comment

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

To support interface of use cache handler we need method that will return most recent tag expiration timestamp .. or 0 if none of tags were ever revalidated (as opposed to just checking if tag is stale that is used in response cache handler)

/**
* Check if any of the tags were invalidated since the given timestamp
*/
Expand Down
Loading
Loading