From 92305933d0fd7175301e59255fe395747b64cff6 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 1 May 2026 10:20:19 +0200 Subject: [PATCH 01/17] Add cache-adapter: standalone HTTP function for cache operations Expose RESTful routes (GET/PUT/DELETE /cache/*, POST /cache/revalidate-tags) for remote cache operations, backed by the pluggable IncrementalCache, TagCache, and CDNInvalidationHandler interfaces. Guarded by disableIncrementalCache config option. --- packages/core/src/adapters/cache-adapter.ts | 302 ++++++++++++++++++ packages/core/src/build/adapter.ts | 12 +- packages/core/src/build/createCacheBundle.ts | 53 +++ packages/core/src/build/generateOutput.ts | 7 + .../core/src/core/createGenericHandler.ts | 20 +- packages/core/src/plugins/resolve.ts | 1 + packages/core/src/types/open-next.ts | 23 ++ 7 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/adapters/cache-adapter.ts create mode 100644 packages/core/src/build/createCacheBundle.ts diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts new file mode 100644 index 00000000..d2c837f8 --- /dev/null +++ b/packages/core/src/adapters/cache-adapter.ts @@ -0,0 +1,302 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { InternalEvent, InternalResult } from "@/types/open-next"; +import type { + CacheEntryType, + CacheValue, + OpenNextHandlerOptions, +} from "@/types/overrides"; + +import { createGenericHandler } from "../core/createGenericHandler.js"; +import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; +import { writeTags } from "../utils/cache.js"; +import { runWithOpenNextRequestContext } from "../utils/promise.js"; +import { toReadableStream } from "../utils/stream.js"; + +import { debug, error } from "./logger.js"; + +globalThis.__openNextAls = new AsyncLocalStorage(); + +const SOFT_TAG_PREFIX = "_N_T_/"; + +// Whether caches have been initialized +let initialized = false; + +async function initializeCaches() { + if (initialized) return; + const config = globalThis.openNextConfig; + + globalThis.incrementalCache = await resolveIncrementalCache( + config.cacheHandler?.incrementalCache ?? config.default?.override?.incrementalCache + ); + + globalThis.tagCache = await resolveTagCache( + config.cacheHandler?.tagCache ?? config.default?.override?.tagCache + ); + + globalThis.cdnInvalidationHandler = await resolveCdnInvalidation( + config.cacheHandler?.cdnInvalidation ?? config.default?.override?.cdnInvalidation + ); + + initialized = true; +} + +///////////// +// Handler // +///////////// + +export const handler = await createGenericHandler({ + handler: defaultHandler, + type: "cache", +}); + +async function defaultHandler( + event: InternalEvent, + options?: OpenNextHandlerOptions +): Promise { + debug("cache handler event", event); + + try { + await initializeCaches(); + } catch (e) { + error("Failed to initialize caches", e); + return buildErrorResponse("Internal server error", 500); + } + + const { method, rawPath, query, body } = event; + + try { + // POST /cache/revalidate-tags + if (method === "POST" && rawPath === "/cache/revalidate-tags") { + return await handleRevalidateTags(body); + } + + // All other operations must be on /cache/* + if (!rawPath.startsWith("/cache/")) { + return buildErrorResponse("Not Found", 404); + } + + const key = decodeURIComponent(rawPath.slice("/cache/".length)); + + if (!key) { + return buildErrorResponse("Missing cache key", 400); + } + + const cacheType: CacheEntryType = query?.type === "fetch" ? "fetch" : "cache"; + + switch (method) { + case "GET": + return await handleGet(key, cacheType); + case "PUT": + return await handleSet(key, cacheType, body); + case "DELETE": + return await handleDelete(key); + default: + return buildErrorResponse("Method Not Allowed", 405); + } + } catch (e) { + error("Failed to handle cache request", e); + return buildErrorResponse("Internal server error", 500); + } +} + +////////////////////// +// Route handlers // +////////////////////// + +async function handleGet(key: string, cacheType: CacheEntryType): Promise { + debug("get", { key, cacheType }); + + try { + const result = await globalThis.incrementalCache.get(key, cacheType); + + if (!result) { + return buildJsonResponse({ found: false, value: null }, 200); + } + + return buildJsonResponse( + { + found: true, + value: result.value ?? null, + lastModified: result.lastModified, + shouldBypassTagCache: result.shouldBypassTagCache, + }, + 200 + ); + } catch (e) { + error("Failed to get cache entry", e); + return buildErrorResponse("Failed to get cache entry", 500); + } +} + +async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): Promise { + debug("set", { key, cacheType }); + + let payload: { + value?: Record; + } = {}; + + if (body && body.length > 0) { + try { + payload = JSON.parse(body.toString("utf-8")); + } catch { + return buildErrorResponse("Invalid JSON body", 400); + } + } + + if (!payload.value) { + return buildErrorResponse("Missing 'value' in request body", 400); + } + + try { + await globalThis.incrementalCache.set(key, payload.value as CacheValue, cacheType); + return buildJsonResponse({ ok: true }, 200); + } catch (e) { + error("Failed to set cache entry", e); + return buildErrorResponse("Failed to set cache entry", 500); + } +} + +async function handleDelete(key: string): Promise { + debug("delete", { key }); + + try { + await globalThis.incrementalCache.delete(key); + return buildJsonResponse({ ok: true }, 200); + } catch (e) { + error("Failed to delete cache entry", e); + return buildErrorResponse("Failed to delete cache entry", 500); + } +} + +async function handleRevalidateTags(body?: Buffer): Promise { + debug("revalidateTags"); + + if (!body || body.length === 0) { + return buildErrorResponse("Missing request body", 400); + } + + let tags: string[]; + try { + const parsed = JSON.parse(body.toString("utf-8")); + tags = Array.isArray(parsed.tags) ? parsed.tags : []; + } catch { + return buildErrorResponse("Invalid JSON body", 400); + } + + if (tags.length === 0) { + return buildErrorResponse("Missing 'tags' array in request body", 400); + } + + try { + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + if (globalThis.tagCache.mode === "nextMode") { + const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; + + await writeTags(tags); + if (paths.length > 0) { + await globalThis.cdnInvalidationHandler.invalidatePaths( + paths.map((path) => ({ + initialPath: path, + rawPath: path, + resolvedRoutes: [ + { + route: path, + type: "app", + isFallback: false, + }, + ], + })) + ); + } + return; + } + + for (const tag of tags) { + debug("revalidateTag", tag); + const paths = await globalThis.tagCache.getByTag(tag); + debug("Items", paths); + const toInsert = paths.map((path) => ({ + path, + tag, + })); + + if (tag.startsWith(SOFT_TAG_PREFIX)) { + for (const path of paths) { + const _tags = await globalThis.tagCache.getByPath(path); + const hardTags = _tags.filter((t) => !t.startsWith(SOFT_TAG_PREFIX)); + for (const hardTag of hardTags) { + const _paths = await globalThis.tagCache.getByTag(hardTag); + debug({ hardTag, _paths }); + toInsert.push( + ..._paths.map((path) => ({ + path, + tag: hardTag, + })) + ); + } + } + } + + await writeTags(toInsert); + + const uniquePaths = Array.from( + new Set(toInsert.filter((t) => t.tag.startsWith(SOFT_TAG_PREFIX)).map((t) => `/${t.path}`)) + ); + if (uniquePaths.length > 0) { + await globalThis.cdnInvalidationHandler.invalidatePaths( + uniquePaths.map((path) => ({ + initialPath: path, + rawPath: path, + resolvedRoutes: [ + { + route: path, + type: "app", + isFallback: false, + }, + ], + })) + ); + } + } + }); + + return buildJsonResponse({ revalidated: tags }, 200); + } catch (e) { + error("Failed to revalidate tags", e); + return buildErrorResponse("Failed to revalidate tags", 500); + } +} + +//////////////////////// +// Response builders // +//////////////////////// + +function buildJsonResponse(data: unknown, statusCode: number): InternalResult { + const body = JSON.stringify(data); + return { + type: "core", + statusCode, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }; +} + +function buildErrorResponse(message: string, statusCode: number): InternalResult { + debug(message, statusCode); + const body = JSON.stringify({ error: message }); + return { + type: "core", + statusCode, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }; +} diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index 6ca36b41..da10aca6 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -16,6 +16,7 @@ import { compileCache } from "./compileCache.js"; import { compileOpenNextConfig } from "./compileConfig.js"; import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; import { createCacheAssets, createStaticAssets } from "./createAssets.js"; +import { createCacheBundle } from "./createCacheBundle.js"; import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; @@ -58,6 +59,7 @@ export type OpenNextAdapterOptions = { skipRevalidation?: boolean; skipImageOptimization?: boolean; skipWarmer?: boolean; + skipCache?: boolean; skipGenerateOutput?: boolean; middlewareOptions?: { forceOnlyBuildOnce?: boolean }; serverBundle: { @@ -245,13 +247,19 @@ export function buildAdapter( console.log("Image optimization bundle created"); } - // Step 11: Warmer bundle + // Step 11: Cache bundle + if (!adapterOptions.skipCache && config.dangerous?.disableIncrementalCache !== true) { + await createCacheBundle(buildOpts, bundleDefaults?.cache); + console.log("Cache bundle created"); + } + + // Step 12: Warmer bundle if (!adapterOptions.skipWarmer) { await createWarmerBundle(buildOpts, bundleDefaults?.warmer); console.log("Warmer bundle created"); } - // Step 12: Generate output + // Step 13: Generate output if (!adapterOptions.skipGenerateOutput) { const output = adapterOptions.generateOutput ? await adapterOptions.generateOutput(buildOpts) diff --git a/packages/core/src/build/createCacheBundle.ts b/packages/core/src/build/createCacheBundle.ts new file mode 100644 index 00000000..e61b9b4f --- /dev/null +++ b/packages/core/src/build/createCacheBundle.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import path from "node:path"; + +import logger from "../logger.js"; +import type { DefaultOverrides } from "../plugins/resolve.js"; +import { openNextResolvePlugin } from "../plugins/resolve.js"; + +import * as buildHelper from "./helper.js"; + +export async function createCacheBundle( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { + logger.info("Bundling cache function..."); + + const { config, outputDir } = options; + + // Create output folder + const outputPath = path.join(outputDir, "cache-function"); + fs.mkdirSync(outputPath, { recursive: true }); + + // Copy open-next.config.mjs into the bundle + buildHelper.copyOpenNextConfig(options.buildDir, outputPath); + + // Build Lambda code + await buildHelper.esbuildAsync( + { + external: ["next"], + entryPoints: [path.join(options.openNextDistDir, "adapters", "cache-adapter.js")], + outfile: path.join(outputPath, "index.mjs"), + plugins: [ + openNextResolvePlugin({ + fnName: "cache", + overrides: { + converter: config.cacheHandler?.override?.converter, + wrapper: config.cacheHandler?.override?.wrapper, + incrementalCache: config.cacheHandler?.incrementalCache, + tagCache: config.cacheHandler?.tagCache, + cdnInvalidation: config.cacheHandler?.cdnInvalidation, + }, + defaultOverrides: { + converter: defaultOverrides?.converter ?? "node", + wrapper: defaultOverrides?.wrapper, + incrementalCache: defaultOverrides?.incrementalCache, + tagCache: defaultOverrides?.tagCache, + cdnInvalidation: defaultOverrides?.cdnInvalidation, + }, + }), + ], + }, + options + ); +} diff --git a/packages/core/src/build/generateOutput.ts b/packages/core/src/build/generateOutput.ts index e9915da6..ad109652 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -86,6 +86,7 @@ export interface OpenNextOutput { initializationFunction?: BaseFunction; warmer?: BaseFunction; revalidationFunction?: BaseFunction; + cacheFunction?: BaseFunction; }; } @@ -345,6 +346,12 @@ export async function buildOpenNextOutput(options: BuildOptions): Promise = { + imageOptimization: "imageOptimization", + revalidate: "revalidate", + warmer: "warmer", + middleware: "middleware", + initializationFunction: "initializationFunction", + cache: "cacheHandler", +}; type GenericHandler< Type extends HandlerType, @@ -31,7 +46,8 @@ export async function createGenericHandler< const config: OpenNextConfig = await import("./open-next.config.mjs").then((m) => m.default); globalThis.openNextConfig = config; - const handlerConfig = config[handler.type]; + const configKey = handlerTypeToConfigKey[handler.type]; + const handlerConfig = config[configKey]; const override = handlerConfig && "override" in handlerConfig ? (handlerConfig.override as DefaultOverrideOptions) diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 65d0a3d0..537a1370 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -72,6 +72,7 @@ export type BundleType = | "imageOptimization" | "revalidation" | "warmer" + | "cache" | "tagCache"; export type BundleDefaults = Partial>; diff --git a/packages/core/src/types/open-next.ts b/packages/core/src/types/open-next.ts index 8216e491..23099e82 100644 --- a/packages/core/src/types/open-next.ts +++ b/packages/core/src/types/open-next.ts @@ -482,6 +482,29 @@ export interface OpenNextConfig { tagCache?: IncludedTagCache | LazyLoadedOverride; }; + /** + * Override the default cache handler function. + * By default, works on lambda. + * Supports only node runtime + */ + cacheHandler?: DefaultFunctionOptions & { + /** + * Override the default incremental cache. + * @default "s3" + */ + incrementalCache?: IncludedIncrementalCache | LazyLoadedOverride; + /** + * Override the default tag cache. + * @default "dynamodb" + */ + tagCache?: IncludedTagCache | LazyLoadedOverride; + /** + * Override the default cdn invalidation handler for on demand revalidation. + * @default "dummy" + */ + cdnInvalidation?: IncludedCDNInvalidationHandler | LazyLoadedOverride; + }; + /** * Dangerous options. This break some functionnality but can be useful in some cases. */ From 02ea1f68f7e8eb9387cd423d329fd85152443c5c Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 1 May 2026 11:03:54 +0200 Subject: [PATCH 02/17] Add cache override option with fetch, local, and dummy implementations --- packages/core/src/adapters/cache-adapter.ts | 6 +- packages/core/src/adapters/middleware.ts | 3 + packages/core/src/core/createMainHandler.ts | 3 + packages/core/src/core/resolve.ts | 13 ++- packages/core/src/overrides/cache/dummy.ts | 17 ++++ packages/core/src/overrides/cache/fetch.ts | 44 ++++++++++ packages/core/src/overrides/cache/local.ts | 89 +++++++++++++++++++++ packages/core/src/plugins/resolve.ts | 2 + packages/core/src/types/global.ts | 8 ++ packages/core/src/types/open-next.ts | 10 +++ packages/core/src/types/overrides.ts | 13 +++ 11 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/overrides/cache/dummy.ts create mode 100644 packages/core/src/overrides/cache/fetch.ts create mode 100644 packages/core/src/overrides/cache/local.ts diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index d2c837f8..71eb5355 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -1,11 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import type { InternalEvent, InternalResult } from "@/types/open-next"; -import type { - CacheEntryType, - CacheValue, - OpenNextHandlerOptions, -} from "@/types/overrides"; +import type { CacheEntryType, CacheValue, OpenNextHandlerOptions } from "@/types/overrides"; import { createGenericHandler } from "../core/createGenericHandler.js"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; diff --git a/packages/core/src/adapters/middleware.ts b/packages/core/src/adapters/middleware.ts index 295ec809..43038f8d 100644 --- a/packages/core/src/adapters/middleware.ts +++ b/packages/core/src/adapters/middleware.ts @@ -11,6 +11,7 @@ import { debug, error } from "../adapters/logger"; import { createGenericHandler } from "../core/createGenericHandler"; import { resolveAssetResolver, + resolveCache, resolveIncrementalCache, resolveOriginResolver, resolveProxyRequest, @@ -46,6 +47,8 @@ const defaultHandler = async ( globalThis.incrementalCache = await resolveIncrementalCache(middlewareConfig?.override?.incrementalCache); + globalThis.cache = await resolveCache(middlewareConfig?.override?.cache); + const requestId = Math.random().toString(36); // We run everything in the async local storage context so that it is available in the external middleware diff --git a/packages/core/src/core/createMainHandler.ts b/packages/core/src/core/createMainHandler.ts index 480aa21b..eb6129b3 100644 --- a/packages/core/src/core/createMainHandler.ts +++ b/packages/core/src/core/createMainHandler.ts @@ -6,6 +6,7 @@ import { generateUniqueId } from "../adapters/util"; import { openNextHandler } from "./requestHandler"; import { resolveAssetResolver, + resolveCache, resolveCdnInvalidation, resolveConverter, resolveIncrementalCache, @@ -41,6 +42,8 @@ export async function createMainHandler() { ); } + globalThis.cache = await resolveCache(thisFunction.override?.cache); + globalThis.proxyExternalRequest = await resolveProxyRequest(thisFunction.override?.proxyExternalRequest); globalThis.cdnInvalidationHandler = await resolveCdnInvalidation(thisFunction.override?.cdnInvalidation); diff --git a/packages/core/src/core/resolve.ts b/packages/core/src/core/resolve.ts index 8bb13557..2bea8501 100644 --- a/packages/core/src/core/resolve.ts +++ b/packages/core/src/core/resolve.ts @@ -7,7 +7,7 @@ import type { OpenNextConfig, OverrideOptions, } from "@/types/open-next"; -import type { Converter, TagCache, Wrapper } from "@/types/overrides"; +import type { Cache, Converter, TagCache, Wrapper } from "@/types/overrides"; // Just a little utility type to remove undefined from a type type RemoveUndefined = T extends undefined ? never : T; @@ -157,3 +157,14 @@ export async function resolveCdnInvalidation(cdnInvalidation: OverrideOptions["c const m_1 = await import("../overrides/cdnInvalidation/dummy.js"); return m_1.default; } + +/** + * @__PURE__ + */ +export async function resolveCache(cache: OverrideOptions["cache"]): Promise { + if (typeof cache === "function") { + return cache(); + } + const m_1 = await import("../overrides/cache/dummy.js"); + return m_1.default; +} diff --git a/packages/core/src/overrides/cache/dummy.ts b/packages/core/src/overrides/cache/dummy.ts new file mode 100644 index 00000000..1f6575e6 --- /dev/null +++ b/packages/core/src/overrides/cache/dummy.ts @@ -0,0 +1,17 @@ +import type { Cache } from "@/types/overrides"; +import { IgnorableError } from "@/utils/error"; + +const dummyCache: Cache = { + name: "dummy", + get: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, + set: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, + delete: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, +}; + +export default dummyCache; diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts new file mode 100644 index 00000000..ce399faf --- /dev/null +++ b/packages/core/src/overrides/cache/fetch.ts @@ -0,0 +1,44 @@ +import type { Cache } from "@/types/overrides"; + +const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; + +const fetchCache: Cache = { + name: "fetch-cache", + get: async (key, cacheType) => { + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${cacheType ? `?type=${cacheType}` : ""}`; + const response = await fetch(url, { method: "GET" }); + if (!response.ok) { + return null; + } + const data = (await response.json()) as { + found: boolean; + value?: unknown; + lastModified?: number; + shouldBypassTagCache?: boolean; + }; + if (!data.found) { + return null; + } + const result: Record = { + value: data.value, + lastModified: data.lastModified, + shouldBypassTagCache: data.shouldBypassTagCache, + }; + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + return result as any; + }, + set: async (key, value, _cacheType) => { + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; + await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + }, + delete: async (key) => { + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; + await fetch(url, { method: "DELETE" }); + }, +}; + +export default fetchCache; diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts new file mode 100644 index 00000000..d3656a80 --- /dev/null +++ b/packages/core/src/overrides/cache/local.ts @@ -0,0 +1,89 @@ +import path from "node:path"; + +import type { InternalEvent, InternalResult } from "@/types/open-next"; +import type { Cache } from "@/types/overrides"; +import { getMonorepoRelativePath } from "@/utils/normalize-path"; +import { fromReadableStream } from "@/utils/stream"; + +let handler: ((event: InternalEvent) => Promise) | null = null; + +async function getHandler() { + if (!handler) { + const cacheHandlerPath = path.join(getMonorepoRelativePath(), "cache-function/index.mjs"); + const m = await import(cacheHandlerPath); + handler = m.handler; + } + return handler; +} + +const localCache: Cache = { + name: "local-cache", + get: async (key, cacheType) => { + const h = (await getHandler())!; + const encodedKey = encodeURIComponent(key); + const url = `https://on/cache/${encodedKey}`; + const event: InternalEvent = { + type: "core", + method: "GET", + rawPath: `/cache/${encodedKey}`, + url, + headers: {}, + query: cacheType ? { type: cacheType } : {}, + cookies: {}, + remoteAddress: "127.0.0.1", + }; + const result = await h(event); + const bodyText = await fromReadableStream(result.body); + const data = JSON.parse(bodyText) as { + found: boolean; + value?: unknown; + lastModified?: number; + shouldBypassTagCache?: boolean; + }; + if (!data.found) { + return null; + } + const res = { + value: data.value, + lastModified: data.lastModified, + shouldBypassTagCache: data.shouldBypassTagCache, + }; + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + return res as any; + }, + set: async (key, value, _cacheType) => { + const h = (await getHandler())!; + const encodedKey = encodeURIComponent(key); + const url = `https://on/cache/${encodedKey}`; + const event: InternalEvent = { + type: "core", + method: "PUT", + rawPath: `/cache/${encodedKey}`, + url, + headers: { "Content-Type": "application/json" }, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + body: Buffer.from(JSON.stringify({ value })), + }; + await h(event); + }, + delete: async (key) => { + const h = (await getHandler())!; + const encodedKey = encodeURIComponent(key); + const url = `https://on/cache/${encodedKey}`; + const event: InternalEvent = { + type: "core", + method: "DELETE", + rawPath: `/cache/${encodedKey}`, + url, + headers: {}, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + }; + await h(event); + }, +}; + +export default localCache; diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 537a1370..87ed3f61 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -32,6 +32,7 @@ export interface IPluginSettings { warmer?: LazyLoadedOverride | IncludedWarmer; proxyExternalRequest?: OverrideOptions["proxyExternalRequest"]; cdnInvalidation?: OverrideOptions["cdnInvalidation"]; + cache?: OverrideOptions["cache"]; }; defaultOverrides?: DefaultOverrides; fnName?: string; @@ -49,6 +50,7 @@ const nameToFolder = { warmer: "warmer", proxyExternalRequest: "proxyExternalRequest", cdnInvalidation: "cdnInvalidation", + cache: "cache", }; export type OverrideKey = keyof typeof nameToFolder; diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 289fe144..65e03701 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -4,6 +4,7 @@ import type { OutgoingHttpHeaders } from "node:http"; import type { AssetResolver, CDNInvalidationHandler, + Cache, IncrementalCache, ProxyExternalRequest, Queue, @@ -195,6 +196,13 @@ declare global { */ var openNextVersion: string; + /** + * The cache client used to communicate with the cache handler function. + * Only available in main functions. + * Defined in `createMainHandler`. + */ + var cache: Cache; + /** * The function that is used when resolving external rewrite requests. * Only available in main functions diff --git a/packages/core/src/types/open-next.ts b/packages/core/src/types/open-next.ts index 23099e82..46e472f1 100644 --- a/packages/core/src/types/open-next.ts +++ b/packages/core/src/types/open-next.ts @@ -6,6 +6,7 @@ import type { WarmerEvent, WarmerResponse } from "../adapters/warmer-function"; import type { AssetResolver, CDNInvalidationHandler, + Cache, Converter, ImageLoader, IncrementalCache, @@ -221,6 +222,8 @@ export type IncludedOriginResolver = "pattern-env" | "dummy"; export type IncludedWarmer = "aws-lambda" | "dummy"; +export type IncludedCache = "fetch" | "local" | "dummy"; + export type IncludedProxyExternalRequest = "node" | "fetch" | "dummy"; export type IncludedCDNInvalidationHandler = "cloudfront" | "dummy"; @@ -280,6 +283,13 @@ export interface OverrideOptions extends DefaultOverrideOptions { * @default "dummy" */ cdnInvalidation?: IncludedCDNInvalidationHandler | LazyLoadedOverride; + + /** + * Add possibility to override the default cache client used to communicate with the cache handler function. + * Can be used to connect to a remote cache handler via HTTP fetch or to use a direct in-process cache. + * @default undefined - Falls back to using the incremental cache directly. + */ + cache?: IncludedCache | LazyLoadedOverride; } export interface InstallOptions { diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index 84589959..a5990cf6 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -253,6 +253,19 @@ export type ProxyExternalRequest = BaseOverride & { proxy: (event: InternalEvent) => Promise; }; +export type Cache = BaseOverride & { + get( + key: string, + cacheType?: CacheType + ): Promise> | null>; + set( + key: string, + value: CacheValue, + isFetch?: CacheType + ): Promise; + delete(key: string): Promise; +}; + type CDNPath = { initialPath: string; rawPath: string; From f09ebf453605b19b7a7afdec052357c6d017725d Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 1 May 2026 11:49:14 +0200 Subject: [PATCH 03/17] Refactor cache GET response to split metadata into headers and body The cache adapter GET response now encodes metadata into x-opennext-cache-* headers and only the relevant payload in the response body. Adds parseCacheGetResponse helper to reconstruct the cache value on the client side. - Not-found entries return 404 with x-opennext-cache-found: false header - Composable, fetch, and route entries return body as text/plain - Page, app, and redirect entries return structured JSON body - Individual data/meta headers are split into x-opennext-cache-header-{name} --- packages/core/src/adapters/cache-adapter.ts | 186 +++++++++++++++++-- packages/core/src/overrides/cache/fetch.ts | 25 +-- packages/core/src/overrides/cache/local.ts | 17 +- packages/core/src/utils/cache-get.ts | 196 ++++++++++++++++++++ 4 files changed, 377 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/utils/cache-get.ts diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index 71eb5355..9e43931b 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -1,7 +1,15 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import type { StoredComposableCacheEntry } from "@/types/cache"; import type { InternalEvent, InternalResult } from "@/types/open-next"; -import type { CacheEntryType, CacheValue, OpenNextHandlerOptions } from "@/types/overrides"; +import type { + CacheEntryType, + CachedFile, + CachedFetchValue, + CacheValue, + OpenNextHandlerOptions, + WithLastModified, +} from "@/types/overrides"; import { createGenericHandler } from "../core/createGenericHandler.js"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; @@ -106,19 +114,20 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise { } } -//////////////////////// +///////////////////////////// +// Cache GET response builder // +///////////////////////////// + +function buildCacheGetResponse(result: WithLastModified>): InternalResult { + const value = result.value!; + + const headers: Record = { + "x-opennext-cache-found": "true", + "Cache-Control": "no-store", + }; + + if (result.lastModified !== undefined) { + headers["x-opennext-cache-last-modified"] = String(result.lastModified); + } + if (result.shouldBypassTagCache) { + headers["x-opennext-cache-should-bypass"] = "true"; + } + + if ("kind" in value && value.kind === "FETCH") { + return buildFetchResponse(value as CachedFetchValue, headers); + } + + if ("type" in value) { + return buildCachedFileResponse(value as CachedFile, headers); + } + + return buildComposableResponse(value as StoredComposableCacheEntry, headers); +} + +function buildFetchResponse( + value: CachedFetchValue, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "fetch"; + headers["x-opennext-cache-fetch-kind"] = "FETCH"; + headers["x-opennext-cache-fetch-data-url"] = value.data.url; + + if (value.data.status !== undefined) { + headers["x-opennext-cache-fetch-data-status"] = String(value.data.status); + } + if (value.data.tags) { + headers["x-opennext-cache-fetch-data-tags"] = JSON.stringify(value.data.tags); + } + if (value.tags) { + headers["x-opennext-cache-fetch-tags"] = JSON.stringify(value.tags); + } + + for (const [key, val] of Object.entries(value.data.headers)) { + headers[`x-opennext-cache-header-${key}`] = val; + } + + const body = value.data.body; + return { + type: "core", + statusCode: 200, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; +} + +function buildCachedFileResponse( + value: CachedFile, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "cache"; + headers["x-opennext-cache-sub-type"] = value.type; + + if (value.meta?.status !== undefined) { + headers["x-opennext-cache-meta-status"] = String(value.meta.status); + } + if (value.meta?.postponed !== undefined) { + headers["x-opennext-cache-meta-postponed"] = value.meta.postponed; + } + if (value.meta?.headers) { + for (const [key, val] of Object.entries(value.meta.headers)) { + if (val !== undefined) { + headers[`x-opennext-cache-header-${key}`] = val; + } + } + } + + switch (value.type) { + case "route": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(value.body), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; + } + case "page": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(JSON.stringify({ json: value.json, html: value.html })), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + case "app": { + return { + type: "core", + statusCode: 200, + body: toReadableStream( + JSON.stringify({ + html: value.html, + rsc: value.rsc, + segmentData: value.segmentData, + }) + ), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + case "redirect": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(JSON.stringify(value.props ?? {})), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + } +} + +function buildComposableResponse( + value: StoredComposableCacheEntry, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "composable"; + headers["x-opennext-cache-composable-stale"] = String(value.stale); + headers["x-opennext-cache-composable-expire"] = String(value.expire); + headers["x-opennext-cache-composable-timestamp"] = String(value.timestamp); + headers["x-opennext-cache-composable-revalidate"] = String(value.revalidate); + headers["x-opennext-cache-composable-tags"] = JSON.stringify(value.tags); + + return { + type: "core", + statusCode: 200, + body: toReadableStream(value.value), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; +} + +////////////////////////// // Response builders // -//////////////////////// +////////////////////////// function buildJsonResponse(data: unknown, statusCode: number): InternalResult { const body = JSON.stringify(data); diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index ce399faf..bdfda9e7 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -1,4 +1,5 @@ import type { Cache } from "@/types/overrides"; +import { parseCacheGetResponse } from "@/utils/cache-get"; const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; @@ -7,25 +8,13 @@ const fetchCache: Cache = { get: async (key, cacheType) => { const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${cacheType ? `?type=${cacheType}` : ""}`; const response = await fetch(url, { method: "GET" }); - if (!response.ok) { - return null; - } - const data = (await response.json()) as { - found: boolean; - value?: unknown; - lastModified?: number; - shouldBypassTagCache?: boolean; - }; - if (!data.found) { - return null; - } - const result: Record = { - value: data.value, - lastModified: data.lastModified, - shouldBypassTagCache: data.shouldBypassTagCache, - }; + const bodyText = await response.text(); + const headers: Record = {}; + response.headers.forEach((v, k) => { + headers[k] = v; + }); // oxlint-disable-next-line @typescript-eslint/no-explicit-any - return result as any; + return parseCacheGetResponse(headers, bodyText) as any; }, set: async (key, value, _cacheType) => { const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index d3656a80..399be738 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { InternalEvent, InternalResult } from "@/types/open-next"; import type { Cache } from "@/types/overrides"; +import { parseCacheGetResponse } from "@/utils/cache-get"; import { getMonorepoRelativePath } from "@/utils/normalize-path"; import { fromReadableStream } from "@/utils/stream"; @@ -34,22 +35,8 @@ const localCache: Cache = { }; const result = await h(event); const bodyText = await fromReadableStream(result.body); - const data = JSON.parse(bodyText) as { - found: boolean; - value?: unknown; - lastModified?: number; - shouldBypassTagCache?: boolean; - }; - if (!data.found) { - return null; - } - const res = { - value: data.value, - lastModified: data.lastModified, - shouldBypassTagCache: data.shouldBypassTagCache, - }; // oxlint-disable-next-line @typescript-eslint/no-explicit-any - return res as any; + return parseCacheGetResponse(result.headers, bodyText) as any; }, set: async (key, value, _cacheType) => { const h = (await getHandler())!; diff --git a/packages/core/src/utils/cache-get.ts b/packages/core/src/utils/cache-get.ts new file mode 100644 index 00000000..c4fcc0a8 --- /dev/null +++ b/packages/core/src/utils/cache-get.ts @@ -0,0 +1,196 @@ +import type { StoredComposableCacheEntry } from "@/types/cache"; +import type { CachedFile, CachedFetchValue, WithLastModified } from "@/types/overrides"; + +type HeadersMap = Record; + +type Base = { + lastModified?: number; + shouldBypassTagCache?: boolean; +} + +function getHeaderValue(headers: HeadersMap, name: string): string | undefined { + const v = headers[name]; + if (typeof v === "string") return v; + if (Array.isArray(v) && v.length > 0) return v[0]; + return undefined; +} + +function getHeaderNumber(headers: HeadersMap, name: string): number | undefined { + const v = getHeaderValue(headers, name); + if (v === undefined) return undefined; + const n = Number(v); + return Number.isNaN(n) ? undefined : n; +} + +function collectPrefixedHeaders(headers: HeadersMap, prefix: string): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.startsWith(prefix)) { + const originalName = key.slice(prefix.length); + result[originalName] = value; + } + } + return result; +} + +export function parseCacheGetResponse( + headers: HeadersMap, + bodyText: string +): WithLastModified< + | (CachedFile & { revalidate?: number | false }) + | (CachedFetchValue & { revalidate?: number | false }) + | (StoredComposableCacheEntry & { revalidate?: number | false }) +> | null { + const found = getHeaderValue(headers, "x-opennext-cache-found"); + if (found !== "true") return null; + + const cacheType = getHeaderValue(headers, "x-opennext-cache-type"); + const lastModified = getHeaderNumber(headers, "x-opennext-cache-last-modified"); + const shouldBypass = getHeaderValue(headers, "x-opennext-cache-should-bypass") === "true"; + + const base : Base = { + ...(lastModified !== undefined ? { lastModified } : {}), + ...(shouldBypass ? { shouldBypassTagCache: true as const } : {}), + }; + + if (cacheType === "composable") { + return reconstructComposable(headers, bodyText, base); + } + + if (cacheType === "fetch") { + return reconstructFetch(headers, bodyText, base); + } + + return reconstructCachedFile(headers, bodyText, base); +} + +function reconstructComposable( + headers: HeadersMap, + bodyText: string, + base: Base +) { + const stale = getHeaderNumber(headers, "x-opennext-cache-composable-stale"); + const expire = getHeaderNumber(headers, "x-opennext-cache-composable-expire"); + const timestamp = getHeaderNumber(headers, "x-opennext-cache-composable-timestamp"); + const revalidate = getHeaderNumber(headers, "x-opennext-cache-composable-revalidate"); + const tagsStr = getHeaderValue(headers, "x-opennext-cache-composable-tags"); + const tags = tagsStr ? JSON.parse(tagsStr) : []; + + if (stale === undefined || expire === undefined || timestamp === undefined || revalidate === undefined) { + return null; + } + + return { + value: { + value: bodyText, + tags, + stale, + expire, + timestamp, + revalidate, + } satisfies StoredComposableCacheEntry, + ...base, + }; +} + +function reconstructFetch( + headers: HeadersMap, + bodyText: string, + base: Base +) { + const kind = getHeaderValue(headers, "x-opennext-cache-fetch-kind"); + if (kind !== "FETCH") return null; + + const url = getHeaderValue(headers, "x-opennext-cache-fetch-data-url") ?? ""; + const status = getHeaderNumber(headers, "x-opennext-cache-fetch-data-status"); + const dataTagsStr = getHeaderValue(headers, "x-opennext-cache-fetch-data-tags"); + const dataTags = dataTagsStr ? JSON.parse(dataTagsStr) : undefined; + const fetchTagsStr = getHeaderValue(headers, "x-opennext-cache-fetch-tags"); + const fetchTags = fetchTagsStr ? JSON.parse(fetchTagsStr) : undefined; + const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate"); + + const dataHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-") as Record; + + const value: CachedFetchValue & { revalidate?: number | false } = { + kind: "FETCH", + data: { + headers: dataHeaders, + body: bodyText, + url, + ...(status !== undefined ? { status } : {}), + ...(dataTags !== undefined ? { tags: dataTags } : {}), + }, + ...(fetchTags !== undefined ? { tags: fetchTags } : {}), + ...(revalidate !== undefined ? { revalidate } : {}), + }; + + return { value, ...base }; +} + +function reconstructCachedFile( + headers: HeadersMap, + bodyText: string, + base: Base +) { + const subType = getHeaderValue(headers, "x-opennext-cache-sub-type"); + const metaStatus = getHeaderNumber(headers, "x-opennext-cache-meta-status"); + const metaPostponed = getHeaderValue(headers, "x-opennext-cache-meta-postponed"); + const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate"); + + const metaHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-"); + const hasMetaHeaders = Object.keys(metaHeaders).length > 0; + + const meta: Record = {}; + if (metaStatus !== undefined) meta.status = metaStatus; + if (metaPostponed !== undefined) meta.postponed = metaPostponed; + if (hasMetaHeaders) meta.headers = metaHeaders; + + const hasMeta = metaStatus !== undefined || metaPostponed !== undefined || hasMetaHeaders; + + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const extra: Record = {}; + if (hasMeta) extra.meta = meta; + if (revalidate !== undefined) extra.revalidate = revalidate; + + switch (subType) { + case "route": { + return { + value: { type: "route", body: bodyText, ...extra } satisfies CachedFile, + ...base, + }; + } + case "page": { + const parsed = JSON.parse(bodyText) as { json: object; html: string }; + return { + value: { type: "page", html: parsed.html, json: parsed.json, ...extra } satisfies CachedFile, + ...base, + }; + } + case "app": { + const parsed = JSON.parse(bodyText) as { + html: string; + rsc: string; + segmentData?: Record; + }; + return { + value: { + type: "app", + html: parsed.html, + rsc: parsed.rsc, + ...(parsed.segmentData ? { segmentData: parsed.segmentData } : {}), + ...extra, + } satisfies CachedFile, + ...base, + }; + } + case "redirect": { + const props = JSON.parse(bodyText); + return { + value: { type: "redirect", props, ...extra } satisfies CachedFile, + ...base, + }; + } + default: + return null; + } +} From 78119ede5723f132cf2ecba9cac3aa962f3a591d Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 1 May 2026 13:12:27 +0200 Subject: [PATCH 04/17] consolidate caching: remove incrementalCache and tagCache from OverrideOptions, delegate to cache override Move tag revalidation logic (hasBeenRevalidated, writeTags, CDN invalidation) from the Next.js Cache class into the cache handler (cache-adapter.ts). The cache override now handles all tag operations transparently in get/set/revalidateTags. Update cache.ts, composable-cache.ts, and cacheInterceptor.ts to use globalThis.cache. --- packages/core/src/adapters/cache-adapter.ts | 85 +++++++- packages/core/src/adapters/cache.ts | 193 ++---------------- .../core/src/adapters/composable-cache.ts | 58 +----- packages/core/src/adapters/middleware.ts | 6 - .../core/src/build/edge/createEdgeBundle.ts | 7 +- packages/core/src/build/generateOutput.ts | 12 +- .../build/middleware/buildNodeMiddleware.ts | 7 +- packages/core/src/core/createMainHandler.ts | 6 - .../core/src/core/routing/cacheInterceptor.ts | 15 +- packages/core/src/overrides/cache/dummy.ts | 3 + packages/core/src/overrides/cache/fetch.ts | 7 + packages/core/src/overrides/cache/local.ts | 16 ++ packages/core/src/plugins/resolve.ts | 8 +- packages/core/src/types/global.ts | 8 +- packages/core/src/types/open-next.ts | 12 -- packages/core/src/types/overrides.ts | 1 + packages/core/src/utils/cache.ts | 14 +- 17 files changed, 161 insertions(+), 297 deletions(-) diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index 9e43931b..8ef5416b 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -8,12 +8,13 @@ import type { CachedFetchValue, CacheValue, OpenNextHandlerOptions, + TagCache, WithLastModified, } from "@/types/overrides"; import { createGenericHandler } from "../core/createGenericHandler.js"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; -import { writeTags } from "../utils/cache.js"; +import { getTagsFromValue, writeTags } from "../utils/cache.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; import { toReadableStream } from "../utils/stream.js"; @@ -31,11 +32,11 @@ async function initializeCaches() { const config = globalThis.openNextConfig; globalThis.incrementalCache = await resolveIncrementalCache( - config.cacheHandler?.incrementalCache ?? config.default?.override?.incrementalCache + config.cacheHandler?.incrementalCache ); globalThis.tagCache = await resolveTagCache( - config.cacheHandler?.tagCache ?? config.default?.override?.tagCache + config.cacheHandler?.tagCache ); globalThis.cdnInvalidationHandler = await resolveCdnInvalidation( @@ -127,6 +128,37 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise 0) { + const revalidated = await checkTagRevalidation(key, tags, result); + if (revalidated) { + return { + type: "core", + statusCode: 404, + body: toReadableStream(""), + isBase64Encoded: false, + headers: { + "x-opennext-cache-found": "false", + "x-opennext-cache-tag-status": "revalidated", + "Cache-Control": "no-store", + }, + }; + } + } + } + return buildCacheGetResponse(result); } catch (e) { error("Failed to get cache entry", e); @@ -134,6 +166,22 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise> +): Promise { + if (globalThis.openNextConfig?.dangerous?.disableTagCache) { + return false; + } + const lastModified = cacheEntry.lastModified ?? Date.now(); + if (globalThis.tagCache.mode === "nextMode") { + return tags.length > 0 && (await globalThis.tagCache.hasBeenRevalidated(tags, lastModified)); + } + const _lastModified = await globalThis.tagCache.getLastModified(key, lastModified); + return _lastModified === -1; +} + async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): Promise { debug("set", { key, cacheType }); @@ -155,6 +203,37 @@ async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): try { await globalThis.incrementalCache.set(key, payload.value as CacheValue, cacheType); + + // Write tags for non-composable and non-nextMode tag caches + const tagCache = globalThis.tagCache; + if (tagCache.mode !== "nextMode" && !globalThis.openNextConfig?.dangerous?.disableTagCache) { + let derivedTags: string[] = []; + + if (cacheType === "cache") { + const tags = getTagsFromValue(payload.value as Parameters[0]); + derivedTags = tags; + } else if (cacheType === "fetch") { + const fetchValue = payload.value as Record; + const data = fetchValue.data as Record | undefined; + derivedTags = (fetchValue.tags as string[]) ?? (data?.tags as string[]) ?? []; + } + + if (derivedTags.length > 0) { + const storedTags = await tagCache.getByPath(key); + const tagsToWrite = derivedTags.filter((tag) => !storedTags.includes(tag)); + if (tagsToWrite.length > 0) { + await writeTags( + tagsToWrite.map((tag) => ({ + path: key, + tag, + revalidatedAt: 1, + })), + tagCache + ); + } + } + } + return buildJsonResponse({ ok: true }, 200); } catch (e) { error("Failed to set cache entry", e); diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index 9cd66847..8f154a04 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -1,12 +1,8 @@ import type { CacheHandlerValue, IncrementalCacheContext, IncrementalCacheValue } from "@/types/cache"; -import { getTagsFromValue, hasBeenRevalidated, writeTags } from "@/utils/cache"; - import { isBinaryContentType } from "../utils/binary"; import { debug, error, warn } from "./logger"; -export const SOFT_TAG_PREFIX = "_N_T_/"; - function isFetchCache(options?: { kindHint?: "app" | "pages" | "fetch"; kind?: "FETCH" }): boolean { if (typeof options === "object") { return options.kindHint === "fetch" || options.kind === "FETCH"; @@ -29,47 +25,19 @@ export default class Cache { return null; } - const softTags = typeof options === "object" ? options.softTags : []; - const tags = typeof options === "object" ? options.tags : []; - return isFetchCache(options) ? this.getFetchCache(key, softTags, tags) : this.getIncrementalCache(key); + return isFetchCache(options) ? this.getFetchCache(key) : this.getIncrementalCache(key); } - async getFetchCache(key: string, softTags?: string[], tags?: string[]) { - debug("get fetch cache", { key, softTags, tags }); + async getFetchCache(key: string) { + debug("get fetch cache", { key }); try { - const cachedEntry = await globalThis.incrementalCache.get(key, "fetch"); - - if (cachedEntry?.value === undefined) return null; - - const _tags = [...(tags ?? []), ...(softTags ?? [])]; - const _lastModified = cachedEntry.lastModified ?? Date.now(); - const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache - ? false - : await hasBeenRevalidated<"fetch">(key, _tags, cachedEntry); + const result = await globalThis.cache.get(key, "fetch"); - if (_hasBeenRevalidated) return null; - - // For cases where we don't have tags, we need to ensure that the soft tags are not being revalidated - // We only need to check for the path as it should already contain all the tags - if ((tags ?? []).length === 0) { - // Then we need to find the path for the given key - const path = softTags?.find( - (tag) => tag.startsWith(SOFT_TAG_PREFIX) && !tag.endsWith("layout") && !tag.endsWith("page") - ); - if (path) { - const hasPathBeenUpdated = cachedEntry.shouldBypassTagCache - ? false - : await hasBeenRevalidated<"fetch">(path.replace(SOFT_TAG_PREFIX, ""), [], cachedEntry); - if (hasPathBeenUpdated) { - // In case the path has been revalidated, we don't want to use the fetch cache - return null; - } - } - } + if (!result?.value) return null; return { - lastModified: _lastModified, - value: cachedEntry.value, + lastModified: result.lastModified ?? Date.now(), + value: result.value, } as CacheHandlerValue; } catch (e) { // We can usually ignore errors here as they are usually due to cache not being found @@ -80,7 +48,7 @@ export default class Cache { async getIncrementalCache(key: string): Promise { try { - const cachedEntry = await globalThis.incrementalCache.get(key, "cache"); + const cachedEntry = await globalThis.cache.get(key, "cache"); if (!cachedEntry?.value) { return null; @@ -89,12 +57,7 @@ export default class Cache { const cacheData = cachedEntry.value; const meta = cacheData.meta; - const tags = getTagsFromValue(cacheData); const _lastModified = cachedEntry.lastModified ?? Date.now(); - const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache - ? false - : await hasBeenRevalidated(key, tags, cachedEntry); - if (_hasBeenRevalidated) return null; const store = globalThis.__openNextAls.getStore(); if (store) { @@ -174,14 +137,14 @@ export default class Cache { const detachedPromise = globalThis.__openNextAls.getStore()?.pendingPromiseRunner.withResolvers(); try { if (data === null || data === undefined) { - await globalThis.incrementalCache.delete(key); + await globalThis.cache.delete(key); } else { const revalidate = this.extractRevalidateForSet(ctx); switch (data.kind) { case "ROUTE": case "APP_ROUTE": { const { body, status, headers } = data; - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "route", @@ -201,7 +164,7 @@ export default class Cache { const { html, pageData, status, headers } = data; const isAppPath = typeof pageData === "string"; if (isAppPath) { - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "app", @@ -216,7 +179,7 @@ export default class Cache { "cache" ); } else { - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "page", @@ -237,7 +200,7 @@ export default class Cache { segmentToWrite[segmentPath] = segmentContent.toString("utf8"); } } - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "app", @@ -256,10 +219,10 @@ export default class Cache { break; } case "FETCH": - await globalThis.incrementalCache.set(key, data, "fetch"); + await globalThis.cache.set(key, data, "fetch"); break; case "REDIRECT": - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "redirect", @@ -275,7 +238,6 @@ export default class Cache { } } - await this.updateTagsOnSet(key, data, ctx); debug("Finished setting cache"); } catch (e) { error("Failed to set cache", e); @@ -296,135 +258,12 @@ export default class Cache { } try { - if (globalThis.tagCache.mode === "nextMode") { - const paths = (await globalThis.tagCache.getPathsByTags?.(_tags)) ?? []; - - await writeTags(_tags); - if (paths.length > 0) { - // TODO: we should introduce a new method in cdnInvalidationHandler to invalidate paths by tags for cdn that supports it - // It also means that we'll need to provide the tags used in every request to the wrapper or converter. - await globalThis.cdnInvalidationHandler.invalidatePaths( - paths.map((path) => ({ - initialPath: path, - rawPath: path, - resolvedRoutes: [ - { - route: path, - // TODO: ideally here we should check if it's an app router page or route - type: "app", - isFallback: false, - }, - ], - })) - ); - } - return; - } - - for (const tag of _tags) { - debug("revalidateTag", tag); - // Find all keys with the given tag - const paths = await globalThis.tagCache.getByTag(tag); - debug("Items", paths); - const toInsert = paths.map((path) => ({ - path, - tag, - })); - - // If the tag is a soft tag, we should also revalidate the hard tags - if (tag.startsWith(SOFT_TAG_PREFIX)) { - for (const path of paths) { - // We need to find all hard tags for a given path - const _tags = await globalThis.tagCache.getByPath(path); - const hardTags = _tags.filter((t) => !t.startsWith(SOFT_TAG_PREFIX)); - // For every hard tag, we need to find all paths and revalidate them - for (const hardTag of hardTags) { - const _paths = await globalThis.tagCache.getByTag(hardTag); - debug({ hardTag, _paths }); - toInsert.push( - ..._paths.map((path) => ({ - path, - tag: hardTag, - })) - ); - } - } - } - - // Update all keys with the given tag with revalidatedAt set to now - await writeTags(toInsert); - - // We can now invalidate all paths in the CDN - // This only applies to `revalidateTag`, not to `res.revalidate()` - const uniquePaths = Array.from( - new Set( - toInsert - // We need to filter fetch cache key as they are not in the CDN - .filter((t) => t.tag.startsWith(SOFT_TAG_PREFIX)) - .map((t) => `/${t.path}`) - ) - ); - if (uniquePaths.length > 0) { - await globalThis.cdnInvalidationHandler.invalidatePaths( - uniquePaths.map((path) => ({ - initialPath: path, - rawPath: path, - resolvedRoutes: [ - { - route: path, - // TODO: ideally here we should check if it's an app router page or route - type: "app", - isFallback: false, - }, - ], - })) - ); - } - } + await globalThis.cache.revalidateTags(_tags); } catch (e) { error("Failed to revalidate tag", e); } } - // TODO: We should delete/update tags in this method - // This will require an update to the tag cache interface - private async updateTagsOnSet(key: string, data?: IncrementalCacheValue, ctx?: IncrementalCacheContext) { - if ( - globalThis.openNextConfig.dangerous?.disableTagCache || - globalThis.tagCache.mode === "nextMode" || - // Here it means it's a delete - !data - ) { - return; - } - // Write derivedTags to the tag cache - // If we use an in house version of getDerivedTags in build we should use it here instead of next's one - const derivedTags: string[] = - data?.kind === "FETCH" - ? //@ts-expect-error - On older versions of next, ctx was a number, but for these cases we use data?.data?.tags - (ctx?.tags ?? data?.data?.tags ?? []) // before version 14 next.js used data?.data?.tags so we keep it for backward compatibility - : data?.kind === "PAGE" - ? (data.headers?.["x-next-cache-tags"]?.split(",") ?? []) - : []; - debug("derivedTags", derivedTags); - - // Get all tags stored in dynamodb for the given key - // If any of the derived tags are not stored in dynamodb for the given key, write them - const storedTags = await globalThis.tagCache.getByPath(key); - const tagsToWrite = derivedTags.filter((tag) => !storedTags.includes(tag)); - if (tagsToWrite.length > 0) { - await writeTags( - tagsToWrite.map((tag) => ({ - path: key, - tag: tag, - // In case the tags are not there we just need to create them - // but we don't want them to return from `getLastModified` as they are not stale - revalidatedAt: 1, - })) - ); - } - } - private extractRevalidateForSet(ctx?: IncrementalCacheContext): number | false | undefined { if (ctx === undefined) { return undefined; diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index a6fb19c3..820fbd41 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -1,6 +1,5 @@ import type { ComposableCacheEntry, ComposableCacheHandler } from "@/types/cache"; import type { CacheValue } from "@/types/overrides"; -import { writeTags } from "@/utils/cache"; import { fromReadableStream, toReadableStream } from "@/utils/stream"; import { debug } from "./logger"; @@ -21,26 +20,13 @@ export default { })); } } - const result = await globalThis.incrementalCache.get(cacheKey, "composable"); + const result = await globalThis.cache.get(cacheKey, "composable"); if (!result?.value?.value) { return undefined; } debug("composable cache result", result); - // We need to check if the tags associated with this entry has been revalidated - if (globalThis.tagCache.mode === "nextMode" && result.value.tags.length > 0) { - const hasBeenRevalidated = result.shouldBypassTagCache - ? false - : await globalThis.tagCache.hasBeenRevalidated(result.value.tags, result.lastModified); - if (hasBeenRevalidated) return undefined; - } else if (globalThis.tagCache.mode === "original" || globalThis.tagCache.mode === undefined) { - const hasBeenRevalidated = result.shouldBypassTagCache - ? false - : (await globalThis.tagCache.getLastModified(cacheKey, result.lastModified)) === -1; - if (hasBeenRevalidated) return undefined; - } - return { ...result.value, value: toReadableStream(result.value.value), @@ -61,7 +47,7 @@ export default { const entry = await promiseEntry.finally(() => { pendingWritePromiseMap.delete(cacheKey); }); - await globalThis.incrementalCache.set( + await globalThis.cache.set( cacheKey, { ...entry, @@ -69,13 +55,6 @@ export default { }, "composable" ); - if (globalThis.tagCache.mode === "original") { - const storedTags = await globalThis.tagCache.getByPath(cacheKey); - const tagsToWrite = entry.tags.filter((tag) => !storedTags.includes(tag)); - if (tagsToWrite.length > 0) { - await writeTags(tagsToWrite.map((tag) => ({ tag, path: cacheKey }))); - } - } }, async refreshTags() { @@ -89,12 +68,8 @@ export default { * - From Next.js 16, the method takes `tags: string[]` */ async getExpiration(...tags: string[] | string[][]) { - if (globalThis.tagCache.mode === "nextMode") { - // Use `.flat()` to accommodate both signatures - return globalThis.tagCache.getLastRevalidated(tags.flat()); - } - // We always return 0 here, original tag cache are handled directly in the get part - // TODO: We need to test this more, i'm not entirely sure that this is working as expected + // Tag revalidation is handled transparently in the cache layer's get(), + // so we always return 0 here to let get() determine freshness. return 0; }, @@ -102,29 +77,10 @@ export default { * This method is only used before Next.js 16 */ async expireTags(...tags: string[]) { - if (globalThis.tagCache.mode === "nextMode") { - return writeTags(tags); - } - const tagCache = globalThis.tagCache; - const revalidatedAt = Date.now(); - // For the original mode, we have more work to do here. - // We need to find all paths linked to to these tags - const pathsToUpdate = await Promise.all( - tags.map(async (tag) => { - const paths = await tagCache.getByTag(tag); - return paths.map((path) => ({ - path, - tag, - revalidatedAt, - })); - }) - ); - // We need to deduplicate paths, we use a set for that - const setToWrite = new Set<{ path: string; tag: string }>(); - for (const entry of pathsToUpdate.flat()) { - setToWrite.add(entry); + const flatTags = tags.flat(); + if (flatTags.length > 0) { + await globalThis.cache.revalidateTags(flatTags); } - await writeTags(Array.from(setToWrite)); }, // This one is necessary for older versions of next diff --git a/packages/core/src/adapters/middleware.ts b/packages/core/src/adapters/middleware.ts index 43038f8d..340e4aab 100644 --- a/packages/core/src/adapters/middleware.ts +++ b/packages/core/src/adapters/middleware.ts @@ -12,11 +12,9 @@ import { createGenericHandler } from "../core/createGenericHandler"; import { resolveAssetResolver, resolveCache, - resolveIncrementalCache, resolveOriginResolver, resolveProxyRequest, resolveQueue, - resolveTagCache, } from "../core/resolve"; import { constructNextUrl } from "../core/routing/util"; import routingHandler, { @@ -41,12 +39,8 @@ const defaultHandler = async ( const assetResolver = await resolveAssetResolver(middlewareConfig?.assetResolver); - globalThis.tagCache = await resolveTagCache(middlewareConfig?.override?.tagCache); - globalThis.queue = await resolveQueue(middlewareConfig?.override?.queue); - globalThis.incrementalCache = await resolveIncrementalCache(middlewareConfig?.override?.incrementalCache); - globalThis.cache = await resolveCache(middlewareConfig?.override?.cache); const requestId = Math.random().toString(36); diff --git a/packages/core/src/build/edge/createEdgeBundle.ts b/packages/core/src/build/edge/createEdgeBundle.ts index 9cd63616..cbeef42f 100644 --- a/packages/core/src/build/edge/createEdgeBundle.ts +++ b/packages/core/src/build/edge/createEdgeBundle.ts @@ -77,8 +77,7 @@ export async function buildEdgeBundle({ overrides: { wrapper: override("wrapper"), converter: override("converter"), - tagCache: override("tagCache"), - incrementalCache: override("incrementalCache"), + cache: override("cache"), queue: override("queue"), originResolver: override("originResolver"), proxyExternalRequest: override("proxyExternalRequest"), @@ -86,9 +85,7 @@ export async function buildEdgeBundle({ defaultOverrides: { wrapper: defaultOverrides?.wrapper ?? "@opennextjs/core/overrides/wrappers/dummy.js", converter: defaultOverrides?.converter ?? defaultConverter, - tagCache: defaultOverrides?.tagCache ?? "@opennextjs/core/overrides/tagCache/dummy.js", - incrementalCache: - defaultOverrides?.incrementalCache ?? "@opennextjs/core/overrides/incrementalCache/dummy.js", + cache: defaultOverrides?.cache ?? "@opennextjs/core/overrides/cache/dummy.js", queue: defaultOverrides?.queue ?? "@opennextjs/core/overrides/queue/direct.js", originResolver: defaultOverrides?.originResolver ?? "@opennextjs/core/overrides/originResolver/pattern-env.js", diff --git a/packages/core/src/build/generateOutput.ts b/packages/core/src/build/generateOutput.ts index ad109652..19248ed3 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -143,18 +143,20 @@ async function extractOverrideFn(override?: DefaultOverrideOptions) { return { wrapper, converter }; } +//TODO: fix this, this is stupid async function extractCommonOverride(override?: OverrideOptions) { if (!override) { return { queue: "sqs", - incrementalCache: "s3", - tagCache: "dynamodb", + incrementalCache: "s3" as const, + tagCache: "dynamodb" as const, }; } const queue = await extractOverrideName("sqs", override.queue); - const incrementalCache = await extractOverrideName("s3", override.incrementalCache); - const tagCache = await extractOverrideName("dynamodb", override.tagCache); - return { queue, incrementalCache, tagCache }; + // incrementalCache and tagCache are no longer in OverrideOptions — they use defaults. + // When using a composite cache (default), composite.ts wraps s3 + dynamodb internally. + // Custom implementations should be provided via the cacheHandler config or a custom cache override. + return { queue, incrementalCache: "s3" as const, tagCache: "dynamodb" as const }; } function prefixPattern(basePath: string) { diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index 8f1957c4..81d49cc0 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -65,8 +65,7 @@ export async function buildExternalNodeMiddleware( overrides: { wrapper: override("wrapper"), converter: override("converter"), - tagCache: override("tagCache"), - incrementalCache: override("incrementalCache"), + cache: override("cache"), queue: override("queue"), originResolver: override("originResolver"), proxyExternalRequest: override("proxyExternalRequest"), @@ -74,9 +73,7 @@ export async function buildExternalNodeMiddleware( defaultOverrides: { wrapper: defaultOverrides?.wrapper ?? "@opennextjs/core/overrides/wrappers/node.js", converter: defaultOverrides?.converter ?? "@opennextjs/core/overrides/converters/node.js", - tagCache: defaultOverrides?.tagCache ?? "@opennextjs/core/overrides/tagCache/dummy.js", - incrementalCache: - defaultOverrides?.incrementalCache ?? "@opennextjs/core/overrides/incrementalCache/dummy.js", + cache: defaultOverrides?.cache ?? "@opennextjs/core/overrides/cache/dummy.js", queue: defaultOverrides?.queue ?? "@opennextjs/core/overrides/queue/direct.js", originResolver: defaultOverrides?.originResolver ?? "@opennextjs/core/overrides/originResolver/pattern-env.js", diff --git a/packages/core/src/core/createMainHandler.ts b/packages/core/src/core/createMainHandler.ts index eb6129b3..a158742d 100644 --- a/packages/core/src/core/createMainHandler.ts +++ b/packages/core/src/core/createMainHandler.ts @@ -9,10 +9,8 @@ import { resolveCache, resolveCdnInvalidation, resolveConverter, - resolveIncrementalCache, resolveProxyRequest, resolveQueue, - resolveTagCache, resolveWrapper, } from "./resolve"; @@ -32,10 +30,6 @@ export async function createMainHandler() { // Default queue globalThis.queue = await resolveQueue(thisFunction.override?.queue); - globalThis.incrementalCache = await resolveIncrementalCache(thisFunction.override?.incrementalCache); - - globalThis.tagCache = await resolveTagCache(thisFunction.override?.tagCache); - if (config.middleware?.external !== true) { globalThis.assetResolver = await resolveAssetResolver( globalThis.openNextConfig.middleware?.assetResolver diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index 271b123f..d54b5781 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -4,7 +4,6 @@ import { NextConfig, PrerenderManifest } from "@/config/index"; import type { InternalEvent, InternalResult, MiddlewareEvent, PartialResult } from "@/types/open-next"; import type { CacheValue } from "@/types/overrides"; import { isBinaryContentType } from "@/utils/binary"; -import { getTagsFromValue, hasBeenRevalidated } from "@/utils/cache"; import { emptyReadableStream, toReadableStream } from "@/utils/stream"; import { debug, error } from "../../adapters/logger"; @@ -340,24 +339,12 @@ export async function cacheInterceptor( } else if (localizedPath === "") { pathToUse = "/index"; } - const cachedData = await globalThis.incrementalCache.get(pathToUse); + const cachedData = await globalThis.cache.get(pathToUse); debug("cached data in interceptor", cachedData); if (!cachedData?.value) { return event; } - // We need to check the tag cache now - if (cachedData.value?.type === "app" || cachedData.value?.type === "route") { - const tags = getTagsFromValue(cachedData.value); - - const _hasBeenRevalidated = cachedData.shouldBypassTagCache - ? false - : await hasBeenRevalidated(localizedPath, tags, cachedData); - - if (_hasBeenRevalidated) { - return event; - } - } const host = event.headers.host; switch (cachedData?.value?.type) { case "app": diff --git a/packages/core/src/overrides/cache/dummy.ts b/packages/core/src/overrides/cache/dummy.ts index 1f6575e6..f48cb92b 100644 --- a/packages/core/src/overrides/cache/dummy.ts +++ b/packages/core/src/overrides/cache/dummy.ts @@ -12,6 +12,9 @@ const dummyCache: Cache = { delete: async () => { throw new IgnorableError('"Dummy" cache does not cache anything'); }, + revalidateTags: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, }; export default dummyCache; diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index bdfda9e7..0707267c 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -28,6 +28,13 @@ const fetchCache: Cache = { const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; await fetch(url, { method: "DELETE" }); }, + revalidateTags: async (tags) => { + await fetch(`${CACHE_URL}/cache/revalidate-tags`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags }), + }); + }, }; export default fetchCache; diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index 399be738..543f36b3 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -71,6 +71,22 @@ const localCache: Cache = { }; await h(event); }, + revalidateTags: async (tags) => { + const h = (await getHandler())!; + const url = `https://on/cache/revalidate-tags`; + const event: InternalEvent = { + type: "core", + method: "POST", + rawPath: `/cache/revalidate-tags`, + url, + headers: { "Content-Type": "application/json" }, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + body: Buffer.from(JSON.stringify({ tags })), + }; + await h(event); + }, }; export default localCache; diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 87ed3f61..6992d5ad 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -8,12 +8,14 @@ import type { Plugin } from "esbuild"; import type { DefaultOverrideOptions, IncludedImageLoader, + IncludedIncrementalCache, IncludedOriginResolver, + IncludedTagCache, IncludedWarmer, LazyLoadedOverride, OverrideOptions, } from "@/types/open-next"; -import type { ImageLoader, OriginResolver, Warmer } from "@/types/overrides"; +import type { ImageLoader, IncrementalCache, OriginResolver, TagCache, Warmer } from "@/types/overrides"; import logger from "../logger.js"; import { getCrossPlatformPathRegex } from "../utils/regex.js"; @@ -24,9 +26,9 @@ export interface IPluginSettings { wrapper?: DefaultOverrideOptions["wrapper"]; // oxlint-disable-next-line @typescript-eslint/no-explicit-any - generic overrides for flexibility converter?: DefaultOverrideOptions["converter"]; - tagCache?: OverrideOptions["tagCache"]; + tagCache?: IncludedTagCache | LazyLoadedOverride; queue?: OverrideOptions["queue"]; - incrementalCache?: OverrideOptions["incrementalCache"]; + incrementalCache?: IncludedIncrementalCache | LazyLoadedOverride; imageLoader?: LazyLoadedOverride | IncludedImageLoader; originResolver?: LazyLoadedOverride | IncludedOriginResolver; warmer?: LazyLoadedOverride | IncludedWarmer; diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 65e03701..552bc664 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -75,15 +75,15 @@ declare global { // Needed in the cache adapter /** * The cache adapter for incremental static regeneration. - * Only available in main functions and in the external middleware when `enableCacheInterception` is `true`. - * Defined in `createMainHandler` and in `adapters/middleware.ts`. + * Only set in the cache handler function (cache-adapter.ts) from `cacheHandler` config. + * Not available in main functions or middleware anymore — use `globalThis.cache` instead. */ var incrementalCache: IncrementalCache; /** * The cache adapter for the tag cache. - * Only available in main functions and in the external middleware when `enableCacheInterception` is `true`. - * Defined in `createMainHandler` and in `adapters/middleware.ts`. + * Only set in the cache handler function (cache-adapter.ts) from `cacheHandler` config. + * Not available in main functions or middleware anymore — use `globalThis.cache` instead. */ var tagCache: TagCache; diff --git a/packages/core/src/types/open-next.ts b/packages/core/src/types/open-next.ts index 46e472f1..a7045250 100644 --- a/packages/core/src/types/open-next.ts +++ b/packages/core/src/types/open-next.ts @@ -254,18 +254,6 @@ export interface DefaultOverrideOptions< } export interface OverrideOptions extends DefaultOverrideOptions { - /** - * Add possibility to override the default s3 cache. Used for fetch cache and html/rsc/json cache. - * @default "s3" - */ - incrementalCache?: IncludedIncrementalCache | LazyLoadedOverride; - - /** - * Add possibility to override the default tag cache. Used for revalidateTags and revalidatePath. - * @default "dynamodb" - */ - tagCache?: IncludedTagCache | LazyLoadedOverride; - /** * Add possibility to override the default queue. Used for isr. * @default "sqs" diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index a5990cf6..9c4f980c 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -264,6 +264,7 @@ export type Cache = BaseOverride & { isFetch?: CacheType ): Promise; delete(key: string): Promise; + revalidateTags(tags: string[]): Promise; }; type CDNPath = { diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index 090bc247..dc7600a6 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -2,6 +2,7 @@ import type { CacheEntryType, CacheValue, OriginalTagCacheWriteInput, + TagCache, WithLastModified, } from "@/types/overrides"; @@ -10,7 +11,8 @@ import { debug } from "../adapters/logger"; export async function hasBeenRevalidated( key: string, tags: string[], - cacheEntry: WithLastModified> + cacheEntry: WithLastModified>, + tagCache: TagCache = globalThis.tagCache ): Promise { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return false; @@ -24,11 +26,11 @@ export async function hasBeenRevalidated( return false; } const lastModified = cacheEntry.lastModified ?? Date.now(); - if (globalThis.tagCache.mode === "nextMode") { - return tags.length === 0 ? false : await globalThis.tagCache.hasBeenRevalidated(tags, lastModified); + if (tagCache.mode === "nextMode") { + return tags.length === 0 ? false : await tagCache.hasBeenRevalidated(tags, lastModified); } // TODO: refactor this, we should introduce a new method in the tagCache interface so that both implementations use hasBeenRevalidated - const _lastModified = await globalThis.tagCache.getLastModified(key, lastModified); + const _lastModified = await tagCache.getLastModified(key, lastModified); return _lastModified === -1; } @@ -56,7 +58,7 @@ function getTagKey(tag: string | OriginalTagCacheWriteInput): string { }); } -export async function writeTags(tags: (string | OriginalTagCacheWriteInput)[]): Promise { +export async function writeTags(tags: (string | OriginalTagCacheWriteInput)[], tagCache: TagCache = globalThis.tagCache): Promise { const store = globalThis.__openNextAls.getStore(); debug("Writing tags", tags, store); if (!store || globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -78,5 +80,5 @@ export async function writeTags(tags: (string | OriginalTagCacheWriteInput)[]): // Here we know that we have the correct type // oxlint-disable-next-line @typescript-eslint/no-explicit-any - writeTags accepts a union type that typescript cannot infer correctly - await globalThis.tagCache.writeTags(tagsToWrite as any); + await tagCache.writeTags(tagsToWrite as any); } From cf3f56c70176a81fe2a91055cf77cde7c38af3f8 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 1 May 2026 13:17:20 +0200 Subject: [PATCH 05/17] fix Co-authored-by: Copilot --- packages/core/src/core/resolve.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/resolve.ts b/packages/core/src/core/resolve.ts index 2bea8501..6b09e35a 100644 --- a/packages/core/src/core/resolve.ts +++ b/packages/core/src/core/resolve.ts @@ -44,7 +44,9 @@ export async function resolveWrapper< * @returns * @__PURE__ */ -export async function resolveTagCache(tagCache: OverrideOptions["tagCache"]): Promise { +export async function resolveTagCache( + tagCache: RemoveUndefined["tagCache"] +): Promise { if (typeof tagCache === "function") { return tagCache(); } @@ -72,7 +74,9 @@ export async function resolveQueue(queue: OverrideOptions["queue"]) { * @returns * @__PURE__ */ -export async function resolveIncrementalCache(incrementalCache: OverrideOptions["incrementalCache"]) { +export async function resolveIncrementalCache( + incrementalCache: RemoveUndefined["incrementalCache"] +) { if (typeof incrementalCache === "function") { return incrementalCache(); } From 9ca43976e6188639d24ec870768efaf477e67ed8 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 1 May 2026 15:45:55 +0200 Subject: [PATCH 06/17] fix and update test Co-authored-by: Copilot --- packages/core/src/adapters/cache-adapter.ts | 11 +- packages/core/src/adapters/cache.ts | 1 + packages/core/src/utils/cache-get.ts | 22 +- packages/core/src/utils/cache.ts | 5 +- .../tests/adapters/cache-adapter.test.ts | 659 ++++++++++++++++++ .../tests-unit/tests/adapters/cache.test.ts | 607 +++------------- .../tests/adapters/composable-cache.test.ts | 355 ++-------- .../core/routing/cacheInterceptor.test.ts | 100 +-- .../tests/overrides/cache/fetch.test.ts | 195 ++++++ .../tests/overrides/cache/local.test.ts | 209 ++++++ .../tests-unit/tests/utils/cache-get.test.ts | 359 ++++++++++ 11 files changed, 1611 insertions(+), 912 deletions(-) create mode 100644 packages/tests-unit/tests/adapters/cache-adapter.test.ts create mode 100644 packages/tests-unit/tests/overrides/cache/fetch.test.ts create mode 100644 packages/tests-unit/tests/overrides/cache/local.test.ts create mode 100644 packages/tests-unit/tests/utils/cache-get.test.ts diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index 8ef5416b..6ed44cbf 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -8,7 +8,6 @@ import type { CachedFetchValue, CacheValue, OpenNextHandlerOptions, - TagCache, WithLastModified, } from "@/types/overrides"; @@ -31,13 +30,9 @@ async function initializeCaches() { if (initialized) return; const config = globalThis.openNextConfig; - globalThis.incrementalCache = await resolveIncrementalCache( - config.cacheHandler?.incrementalCache - ); + globalThis.incrementalCache = await resolveIncrementalCache(config.cacheHandler?.incrementalCache); - globalThis.tagCache = await resolveTagCache( - config.cacheHandler?.tagCache - ); + globalThis.tagCache = await resolveTagCache(config.cacheHandler?.tagCache); globalThis.cdnInvalidationHandler = await resolveCdnInvalidation( config.cacheHandler?.cdnInvalidation ?? config.default?.override?.cdnInvalidation @@ -135,7 +130,7 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise; type Base = { lastModified?: number; shouldBypassTagCache?: boolean; -} +}; function getHeaderValue(headers: HeadersMap, name: string): string | undefined { const v = headers[name]; @@ -48,7 +48,7 @@ export function parseCacheGetResponse( const lastModified = getHeaderNumber(headers, "x-opennext-cache-last-modified"); const shouldBypass = getHeaderValue(headers, "x-opennext-cache-should-bypass") === "true"; - const base : Base = { + const base: Base = { ...(lastModified !== undefined ? { lastModified } : {}), ...(shouldBypass ? { shouldBypassTagCache: true as const } : {}), }; @@ -64,11 +64,7 @@ export function parseCacheGetResponse( return reconstructCachedFile(headers, bodyText, base); } -function reconstructComposable( - headers: HeadersMap, - bodyText: string, - base: Base -) { +function reconstructComposable(headers: HeadersMap, bodyText: string, base: Base) { const stale = getHeaderNumber(headers, "x-opennext-cache-composable-stale"); const expire = getHeaderNumber(headers, "x-opennext-cache-composable-expire"); const timestamp = getHeaderNumber(headers, "x-opennext-cache-composable-timestamp"); @@ -93,11 +89,7 @@ function reconstructComposable( }; } -function reconstructFetch( - headers: HeadersMap, - bodyText: string, - base: Base -) { +function reconstructFetch(headers: HeadersMap, bodyText: string, base: Base) { const kind = getHeaderValue(headers, "x-opennext-cache-fetch-kind"); if (kind !== "FETCH") return null; @@ -127,11 +119,7 @@ function reconstructFetch( return { value, ...base }; } -function reconstructCachedFile( - headers: HeadersMap, - bodyText: string, - base: Base -) { +function reconstructCachedFile(headers: HeadersMap, bodyText: string, base: Base) { const subType = getHeaderValue(headers, "x-opennext-cache-sub-type"); const metaStatus = getHeaderNumber(headers, "x-opennext-cache-meta-status"); const metaPostponed = getHeaderValue(headers, "x-opennext-cache-meta-postponed"); diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index dc7600a6..d6c2071a 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -58,7 +58,10 @@ function getTagKey(tag: string | OriginalTagCacheWriteInput): string { }); } -export async function writeTags(tags: (string | OriginalTagCacheWriteInput)[], tagCache: TagCache = globalThis.tagCache): Promise { +export async function writeTags( + tags: (string | OriginalTagCacheWriteInput)[], + tagCache: TagCache = globalThis.tagCache +): Promise { const store = globalThis.__openNextAls.getStore(); debug("Writing tags", tags, store); if (!store || globalThis.openNextConfig.dangerous?.disableTagCache) { diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts new file mode 100644 index 00000000..7e0a1316 --- /dev/null +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -0,0 +1,659 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { handler } from "@opennextjs/core/adapters/cache-adapter"; +import type { InternalEvent, InternalResult, OpenNextConfig } from "@opennextjs/core/types/open-next"; +import { fromReadableStream } from "@opennextjs/core/utils/stream"; +import { type Mock, vi, describe, expect, it, beforeEach } from "vitest"; + +const mockResolveIncrementalCache = vi.hoisted(() => vi.fn()); +const mockResolveTagCache = vi.hoisted(() => vi.fn()); +const mockResolveCdnInvalidation = vi.hoisted(() => vi.fn()); + +const mockIncrementalCache = vi.hoisted(() => ({ + name: "mock", + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +})); + +const mockTagCache = vi.hoisted(() => ({ + name: "mock", + mode: "original", + getByTag: vi.fn(), + getByPath: vi.fn(), + getLastModified: vi.fn(), + writeTags: vi.fn(), + hasBeenRevalidated: vi.fn(), + getPathsByTags: undefined as Mock | undefined, +})); + +const mockCdnInvalidationHandler = vi.hoisted(() => ({ + name: "mock", + invalidatePaths: vi.fn(), +})); + +vi.mock("@opennextjs/core/core/resolve", () => ({ + resolveIncrementalCache: mockResolveIncrementalCache, + resolveTagCache: mockResolveTagCache, + resolveCdnInvalidation: mockResolveCdnInvalidation, +})); + +vi.mock("@opennextjs/core/core/createGenericHandler", () => ({ + createGenericHandler: vi.fn( + async ({ + handler: h, + }: { + handler: (event: InternalEvent, options?: unknown) => Promise; + }) => { + //@ts-ignore + globalThis.openNextConfig = { + dangerous: {}, + } as Partial; + return async (event: InternalEvent, options?: unknown) => h(event, options); + } + ), +})); + +function createEvent(overrides: Partial = {}): InternalEvent { + return { + type: "core", + method: "GET", + rawPath: "/cache/test-key", + url: "https://on/cache/test-key", + headers: {}, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + ...overrides, + }; +} + +async function runHandler(event: InternalEvent): Promise { + return globalThis.__openNextAls.run( + { + requestId: "test-request", + pendingPromiseRunner: { + withResolvers: () => ({ + resolve: vi.fn(), + promise: Promise.resolve(), + }), + }, + isISRRevalidation: false, + writtenTags: new Set(), + }, + () => handler(event) + ); +} + +describe("cache-adapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.__openNextAls = new AsyncLocalStorage(); + // @ts-ignore + globalThis.openNextConfig = { dangerous: {} } as Partial; + mockResolveIncrementalCache.mockResolvedValue(mockIncrementalCache); + mockResolveTagCache.mockResolvedValue(mockTagCache); + mockResolveCdnInvalidation.mockResolvedValue(mockCdnInvalidationHandler); + mockTagCache.mode = "original"; + mockTagCache.getPathsByTags = undefined; + }); + + describe("routing", () => { + it("should return 404 for non-cache paths", async () => { + const event = createEvent({ rawPath: "/other/path" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(404); + const body = await fromReadableStream(result.body); + expect(body).toContain("Not Found"); + }); + + it("should return 400 for missing cache key", async () => { + const event = createEvent({ rawPath: "/cache/" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + const body = await fromReadableStream(result.body); + expect(body).toContain("Missing cache key"); + }); + + it("should return 405 for unknown method", async () => { + const event = createEvent({ method: "PATCH" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(405); + const body = await fromReadableStream(result.body); + expect(body).toContain("Method Not Allowed"); + }); + }); + + describe("GET /cache/:key", () => { + it("should return 404 when cache entry is not found", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-found"]).toBe("false"); + }); + + it("should return 404 when cache entry value is missing", async () => { + mockIncrementalCache.get.mockResolvedValue({}); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-found"]).toBe("false"); + }); + + it("should return 200 with route cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "route-body" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-found"]).toBe("true"); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("route"); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + const body = await fromReadableStream(result.body); + expect(body).toBe("route-body"); + }); + + it("should return 200 with page cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "page", html: "", json: { data: 1 } }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("page"); + const body = await fromReadableStream(result.body); + const parsed = JSON.parse(body); + expect(parsed).toEqual({ html: "", json: { data: 1 } }); + }); + + it("should return 200 with app cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "app", + html: "", + rsc: "rsc-data", + segmentData: { seg1: "data1" }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("app"); + const body = await fromReadableStream(result.body); + const parsed = JSON.parse(body); + expect(parsed.html).toBe(""); + expect(parsed.rsc).toBe("rsc-data"); + expect(parsed.segmentData).toEqual({ seg1: "data1" }); + }); + + it("should return 200 with redirect cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "redirect", props: { destination: "/new" } }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("redirect"); + }); + + it("should return 200 with fetch cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + kind: "FETCH", + data: { + headers: { "content-type": "text/plain" }, + body: "fetch-body", + url: "https://example.com", + status: 200, + }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("fetch"); + expect(result.headers["x-opennext-cache-fetch-kind"]).toBe("FETCH"); + expect(result.headers["x-opennext-cache-fetch-data-url"]).toBe("https://example.com"); + const body = await fromReadableStream(result.body); + expect(body).toBe("fetch-body"); + }); + + it("should use ?type=fetch when query param is provided", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + const event = createEvent({ query: { type: "fetch" } }); + + await runHandler(event); + + expect(mockIncrementalCache.get).toHaveBeenCalledWith("test-key", "fetch"); + }); + + it("should use ?type=cache by default", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + + await runHandler(createEvent()); + + expect(mockIncrementalCache.get).toHaveBeenCalledWith("test-key", "cache"); + }); + + it("should return 500 when incremental cache throws", async () => { + mockIncrementalCache.get.mockRejectedValue(new Error("cache error")); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(500); + }); + }); + + describe("tag revalidation in GET", () => { + it("should return cached value when there are no tags", async () => { + mockTagCache.mode = "original"; + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); + }); + + it("should check tag revalidation in nextMode", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.hasBeenRevalidated.mockResolvedValue(false); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(mockTagCache.hasBeenRevalidated).toHaveBeenCalledWith(["tag1"], 1000); + expect(result.statusCode).toBe(200); + }); + + it("should return 404 when tags have been revalidated in nextMode", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.hasBeenRevalidated.mockResolvedValue(true); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-tag-status"]).toBe("revalidated"); + }); + + it("should check last modified in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(mockTagCache.getLastModified).toHaveBeenCalledWith("test-key", 1000); + expect(result.statusCode).toBe(200); + }); + + it("should return 404 when tags have been revalidated in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(-1); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-tag-status"]).toBe("revalidated"); + }); + + it("should skip tag revalidation when shouldBypassTagCache is true", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + shouldBypassTagCache: true, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); + expect(mockTagCache.hasBeenRevalidated).not.toHaveBeenCalled(); + }); + + it("should skip tag revalidation when disableTagCache is true", async () => { + // @ts-ignore + globalThis.openNextConfig = { + dangerous: { disableTagCache: true }, + } as Partial; + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); + expect(mockTagCache.hasBeenRevalidated).not.toHaveBeenCalled(); + }); + }); + + describe("PUT /cache/:key", () => { + it("should return 400 when body is missing", async () => { + const result = await runHandler(createEvent({ method: "PUT" })); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is empty", async () => { + const event = createEvent({ method: "PUT", body: Buffer.from("") }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when value is missing in body", async () => { + const event = createEvent({ method: "PUT", body: Buffer.from(JSON.stringify({})) }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is invalid JSON", async () => { + const event = createEvent({ method: "PUT", body: Buffer.from("invalid json") }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should set cache entry and return 200", async () => { + const value = { type: "route", body: "content" }; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockIncrementalCache.set).toHaveBeenCalledWith("test-key", value, "cache"); + const body = await fromReadableStream(result.body); + expect(JSON.parse(body)).toEqual({ ok: true }); + }); + + it("should write derived tags for non-nextMode tag caches", async () => { + mockTagCache.getByPath.mockResolvedValue([]); + mockTagCache.mode = "original"; + + const value = { + type: "route", + body: "content", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalled(); + }); + + it("should skip tag writing in nextMode", async () => { + mockTagCache.mode = "nextMode"; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value: { type: "route", body: "content" } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should skip tag writing when disableTagCache is true", async () => { + // @ts-ignore + globalThis.openNextConfig = { + dangerous: { disableTagCache: true }, + } as Partial; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value: { type: "route", body: "content" } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should skip writing tags that are already stored", async () => { + mockTagCache.getByPath.mockResolvedValue(["tag1", "tag2"]); + + const value = { + type: "route", + body: "content", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should return 500 when set fails", async () => { + mockIncrementalCache.set.mockRejectedValue(new Error("set error")); + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value: { type: "route", body: "content" } })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(500); + }); + }); + + describe("DELETE /cache/:key", () => { + it("should delete cache entry and return 200", async () => { + const result = await runHandler(createEvent({ method: "DELETE" })); + + expect(result.statusCode).toBe(200); + expect(mockIncrementalCache.delete).toHaveBeenCalledWith("test-key"); + const body = await fromReadableStream(result.body); + expect(JSON.parse(body)).toEqual({ ok: true }); + }); + + it("should return 500 when delete fails", async () => { + mockIncrementalCache.delete.mockRejectedValue(new Error("delete error")); + + const result = await runHandler(createEvent({ method: "DELETE" })); + + expect(result.statusCode).toBe(500); + }); + }); + + describe("POST /cache/revalidate-tags", () => { + it("should return 400 when body is missing", async () => { + const event = createEvent({ rawPath: "/cache/revalidate-tags", method: "POST" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is empty", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(""), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when tags array is missing", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({})), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when tags are empty array", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: [] })), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is invalid JSON", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from("not json"), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should revalidate tags in nextMode without getPathsByTags", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.getPathsByTags = undefined; + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.writeTags).toHaveBeenCalled(); + const body = await fromReadableStream(result.body); + const parsed = JSON.parse(body); + expect(parsed.revalidated).toEqual(["tag1"]); + }); + + it("should revalidate tags in nextMode with getPathsByTags and invalidate CDN", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.getPathsByTags = vi.fn().mockResolvedValue(["/path1"]); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.writeTags).toHaveBeenCalled(); + expect(mockCdnInvalidationHandler.invalidatePaths).toHaveBeenCalledWith([ + expect.objectContaining({ initialPath: "/path1", rawPath: "/path1" }), + ]); + }); + + it("should revalidate tags in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + mockTagCache.getByPath.mockResolvedValue([]); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getByTag).toHaveBeenCalledWith("tag1"); + expect(mockTagCache.writeTags).toHaveBeenCalled(); + }); + + it("should invalidate CDN for soft tags in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/some-path"]); + mockTagCache.getByPath.mockResolvedValue([]); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["_N_T_//some-path"] })), + }); + + await runHandler(event); + + expect(mockCdnInvalidationHandler.invalidatePaths).toHaveBeenCalled(); + }); + + it("should return 500 when revalidation fails", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockRejectedValue(new Error("tag error")); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(500); + }); + }); +}); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index 347f35c1..ef6f8e46 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -1,5 +1,30 @@ -import Cache, { SOFT_TAG_PREFIX } from "@opennextjs/core/adapters/cache.js"; -import { type Mock, vi } from "vitest"; +import Cache from "@opennextjs/core/adapters/cache.js"; +import { vi } from "vitest"; + +const cache = { + name: "mock", + get: vi.fn().mockResolvedValue({ + value: { + type: "route", + body: "{}", + }, + lastModified: Date.now(), + }), + set: vi.fn(), + delete: vi.fn(), + revalidateTags: vi.fn(), +}; +globalThis.cache = cache; + +globalThis.__openNextAls = { + getStore: vi.fn().mockReturnValue({ + pendingPromiseRunner: { + withResolvers: vi.fn().mockReturnValue({ + resolve: vi.fn(), + }), + }, + }), +}; declare global { var openNextConfig: { @@ -9,59 +34,16 @@ declare global { } describe("CacheHandler", () => { - let cache: Cache; + let instance: Cache; vi.useFakeTimers().setSystemTime("2024-01-02T00:00:00Z"); const getFetchCacheSpy = vi.spyOn(Cache.prototype, "getFetchCache"); const getIncrementalCache = vi.spyOn(Cache.prototype, "getIncrementalCache"); - const incrementalCache = { - name: "mock", - get: vi.fn().mockResolvedValue({ - value: { - type: "route", - body: "{}", - }, - lastModified: Date.now(), - }), - set: vi.fn(), - delete: vi.fn(), - }; - globalThis.incrementalCache = incrementalCache; - - const tagCache = { - name: "mock", - mode: "original", - hasBeenRevalidated: vi.fn(), - getByTag: vi.fn(), - getByPath: vi.fn(), - getLastModified: vi.fn().mockResolvedValue(new Date("2024-01-02T00:00:00Z").getTime()), - writeTags: vi.fn(), - getPathsByTags: undefined as Mock | undefined, - }; - globalThis.tagCache = tagCache; - - const invalidateCdnHandler = { - name: "mock", - invalidatePaths: vi.fn(), - }; - globalThis.cdnInvalidationHandler = invalidateCdnHandler; - - globalThis.__openNextAls = { - getStore: vi.fn().mockReturnValue({ - pendingPromiseRunner: { - withResolvers: vi.fn().mockReturnValue({ - resolve: vi.fn(), - }), - }, - writtenTags: new Set(), - }), - }; - beforeEach(() => { vi.clearAllMocks(); - cache = new Cache(); + instance = new Cache(); globalThis.openNextConfig = { dangerous: { @@ -69,15 +51,13 @@ describe("CacheHandler", () => { }, }; globalThis.isNextAfter15 = false; - tagCache.mode = "original"; - tagCache.getPathsByTags = undefined; }); describe("get", () => { it("Should return null for cache miss", async () => { - incrementalCache.get.mockResolvedValueOnce({}); + cache.get.mockResolvedValueOnce({}); - const result = await cache.get("key"); + const result = await instance.get("key"); expect(result).toBeNull(); }); @@ -88,68 +68,51 @@ describe("CacheHandler", () => { }); it("Should return null when incremental cache is disabled", async () => { - const result = await cache.get("key"); + const result = await instance.get("key"); expect(result).toBeNull(); }); it("Should not set cache when incremental cache is disabled", async () => { - globalThis.openNextConfig.dangerous.disableIncrementalCache = true; - - await cache.set("key", { kind: "REDIRECT", props: {} }); + await instance.set("key", { kind: "REDIRECT", props: {} }); - expect(incrementalCache.set).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); }); it("Should not delete cache when incremental cache is disabled", async () => { - globalThis.openNextConfig.dangerous.disableIncrementalCache = true; - - await cache.set("key", undefined); + await instance.set("key", undefined); - expect(incrementalCache.delete).not.toHaveBeenCalled(); + expect(cache.delete).not.toHaveBeenCalled(); }); }); describe("fetch cache", () => { it("Should retrieve cache from fetch cache when hint is fetch (next14)", async () => { - await cache.get("key", { kindHint: "fetch" }); + await instance.get("key", { kindHint: "fetch" }); expect(getFetchCacheSpy).toHaveBeenCalled(); }); describe("next15", () => { it("Should retrieve cache from fetch cache when hint is fetch", async () => { - await cache.get("key", { kind: "FETCH" }); + await instance.get("key", { kind: "FETCH" }); expect(getFetchCacheSpy).toHaveBeenCalled(); }); - it("Should return null when tag cache last modified is -1", async () => { - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await cache.get("key", { kind: "FETCH" }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(result).toBeNull(); - }); + it("Should return null when fetch cache entry is not found", async () => { + cache.get.mockResolvedValueOnce(null); - it("Should return null with nextMode tag cache that has been revalidated", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); + const result = await instance.get("key", { kind: "FETCH" }); - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag"], - }); expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); expect(result).toBeNull(); }); it("Should return null when incremental cache throws", async () => { - incrementalCache.get.mockRejectedValueOnce(new Error("Error retrieving cache")); + cache.get.mockRejectedValueOnce(new Error("Error retrieving cache")); - const result = await cache.get("key", { kind: "FETCH" }); + const result = await instance.get("key", { kind: "FETCH" }); expect(getFetchCacheSpy).toHaveBeenCalled(); expect(result).toBeNull(); @@ -161,51 +124,14 @@ describe("CacheHandler", () => { it.each(["app", "pages", undefined])( "Should retrieve cache from incremental cache when hint is not fetch: %s", async (kindHint) => { - await cache.get("key", { kindHint: kindHint as any }); + await instance.get("key", { kindHint: kindHint as any }); expect(getIncrementalCache).toHaveBeenCalled(); } ); - it("Should return null when tag cache last modified is -1", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - }, - lastModified: Date.now(), - }); - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - - it("Should return null with nextMode tag cache that has been revalidated", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - meta: { - headers: { - "x-next-cache-tags": "tag", - }, - }, - }, - lastModified: Date.now(), - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - it("Should return value when cache data type is route", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: "{}", @@ -213,7 +139,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -226,7 +152,7 @@ describe("CacheHandler", () => { }); it("Should return base64 encoded value when cache data type is route and content is binary", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: Buffer.from("hello").toString("base64"), @@ -239,7 +165,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -255,7 +181,7 @@ describe("CacheHandler", () => { }); it("Should return value when cache data type is app", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "", @@ -267,7 +193,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -285,7 +211,7 @@ describe("CacheHandler", () => { }); it("Should return value when cache data type is page", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "page", html: "", @@ -297,7 +223,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "pages" }); + const result = await instance.get("key", { kindHint: "pages" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -314,7 +240,7 @@ describe("CacheHandler", () => { it("Should return value when cache data type is app with segmentData and postponed (Next 15+)", async () => { globalThis.isNextAfter15 = true; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "", @@ -332,87 +258,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(result).toEqual({ - value: { - kind: "APP_PAGE", - html: "", - rscData: Buffer.from("rsc-data"), - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - segmentData: new Map([ - ["segment1", Buffer.from("data1")], - ["segment2", Buffer.from("data2")], - ]), - }, - lastModified: Date.now(), - }); - }); - - it("Should return value when cache data type is app with segmentData and postponed (Next 15+)", async () => { - globalThis.isNextAfter15 = true; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "", - rsc: "rsc-data", - segmentData: { - segment1: "data1", - segment2: "data2", - }, - meta: { - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - }, - }, - lastModified: Date.now(), - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(result).toEqual({ - value: { - kind: "APP_PAGE", - html: "", - rscData: Buffer.from("rsc-data"), - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - segmentData: new Map([ - ["segment1", Buffer.from("data1")], - ["segment2", Buffer.from("data2")], - ]), - }, - lastModified: Date.now(), - }); - }); - - it("Should return value when cache data type is app with segmentData and postponed (Next 15+)", async () => { - globalThis.isNextAfter15 = true; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "", - rsc: "rsc-data", - segmentData: { - segment1: "data1", - segment2: "data2", - }, - meta: { - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - }, - }, - lastModified: Date.now(), - }); - - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -433,14 +279,14 @@ describe("CacheHandler", () => { }); it("Should return value when cache data type is redirect", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "redirect", }, lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -452,9 +298,9 @@ describe("CacheHandler", () => { }); it("Should return null when incremental cache fails", async () => { - incrementalCache.get.mockRejectedValueOnce(new Error("Error")); + cache.get.mockRejectedValueOnce(new Error("Error")); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toBeNull(); @@ -464,20 +310,20 @@ describe("CacheHandler", () => { describe("set", () => { it("Should delete cache when data is undefined", async () => { - await cache.set("key", undefined); + await instance.set("key", undefined); - expect(incrementalCache.delete).toHaveBeenCalled(); + expect(cache.delete).toHaveBeenCalled(); }); it("Should set cache when for ROUTE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "ROUTE", body: Buffer.from("{}"), status: 200, headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "route", body: "{}", meta: { status: 200, headers: {} } }, "cache" @@ -485,7 +331,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for APP_ROUTE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "APP_ROUTE", body: Buffer.from("{}"), status: 200, @@ -494,7 +340,7 @@ describe("CacheHandler", () => { }, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "route", @@ -506,7 +352,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for PAGE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "PAGE", html: "", pageData: {}, @@ -514,7 +360,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "page", @@ -526,7 +372,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for PAGES", async () => { - await cache.set("key", { + await instance.set("key", { kind: "PAGES", html: "", pageData: "rsc", @@ -534,7 +380,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -547,7 +393,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for APP_PAGE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "APP_PAGE", html: "", rscData: Buffer.from("rsc"), @@ -555,7 +401,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -573,7 +419,7 @@ describe("CacheHandler", () => { ["segment2", Buffer.from("data2")], ]); - await cache.set("key", { + await instance.set("key", { kind: "APP_PAGE", html: "", rscData: Buffer.from("rsc"), @@ -583,7 +429,7 @@ describe("CacheHandler", () => { postponed: "postponed-data", }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -604,7 +450,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for FETCH", async () => { - await cache.set("key", { + await instance.set("key", { kind: "FETCH", data: { headers: {}, @@ -616,7 +462,7 @@ describe("CacheHandler", () => { revalidate: 60, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { kind: "FETCH", @@ -634,9 +480,9 @@ describe("CacheHandler", () => { }); it("Should set cache when for REDIRECT", async () => { - await cache.set("key", { kind: "REDIRECT", props: {} }); + await instance.set("key", { kind: "REDIRECT", props: {} }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "redirect", @@ -647,20 +493,20 @@ describe("CacheHandler", () => { }); it("Should not set cache when for IMAGE (not implemented)", async () => { - await cache.set("key", { + await instance.set("key", { kind: "IMAGE", etag: "etag", buffer: Buffer.from("hello"), extension: "png", }); - expect(incrementalCache.set).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); }); it("Should not throw when set cache throws", async () => { - incrementalCache.set.mockRejectedValueOnce(new Error("Error")); + cache.set.mockRejectedValueOnce(new Error("Error")); - await expect(cache.set("key", { kind: "REDIRECT", props: {} })).resolves.not.toThrow(); + await expect(instance.set("key", { kind: "REDIRECT", props: {} })).resolves.not.toThrow(); }); }); @@ -669,304 +515,45 @@ describe("CacheHandler", () => { globalThis.openNextConfig.dangerous.disableTagCache = false; globalThis.openNextConfig.dangerous.disableIncrementalCache = false; }); + it("Should do nothing if disableIncrementalCache is true", async () => { globalThis.openNextConfig.dangerous.disableIncrementalCache = true; - await cache.revalidateTag("tag"); + await instance.revalidateTag("tag"); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); it("Should do nothing if disableTagCache is true", async () => { globalThis.openNextConfig.dangerous.disableTagCache = true; - await cache.revalidateTag("tag"); + await instance.revalidateTag("tag"); - expect(tagCache.writeTags).not.toHaveBeenCalled(); - // Reset the config - globalThis.openNextConfig.dangerous.disableTagCache = false; + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); - it("Should call tagCache.writeTags", async () => { - tagCache.getByTag.mockResolvedValueOnce(["/path"]); - await cache.revalidateTag("tag"); - - expect(tagCache.getByTag).toHaveBeenCalledWith("tag"); + it("Should call cache.revalidateTags with single tag", async () => { + await instance.revalidateTag("tag"); - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { - path: "/path", - tag: "tag", - }, - ]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag"]); }); - it("Should call invalidateCdnHandler.invalidatePaths", async () => { - tagCache.getByTag.mockResolvedValueOnce(["/path"]); - tagCache.getByPath.mockResolvedValueOnce([]); - await cache.revalidateTag(`${SOFT_TAG_PREFIX}path`); + it("Should call cache.revalidateTags with array of tags", async () => { + await instance.revalidateTag(["tag1", "tag2"]); - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { - path: "/path", - tag: `${SOFT_TAG_PREFIX}path`, - }, - ]); - - expect(invalidateCdnHandler.invalidatePaths).toHaveBeenCalled(); - }); - - it("Should not call invalidateCdnHandler.invalidatePaths for fetch cache key ", async () => { - tagCache.getByTag.mockResolvedValueOnce(["123456"]); - await cache.revalidateTag("tag"); - - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { - path: "123456", - tag: "tag", - }, - ]); - - expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled(); - }); - - it("Should only call writeTags for nextMode", async () => { - tagCache.mode = "nextMode"; - await cache.revalidateTag(["tag1", "tag2"]); - - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag1", "tag2"]); - expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled(); - }); - - it("Should not call writeTags when the tag list is empty for nextMode", async () => { - tagCache.mode = "nextMode"; - await cache.revalidateTag([]); - - expect(tagCache.writeTags).not.toHaveBeenCalled(); - expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled(); - }); - - it("Should call writeTags and invalidateCdnHandler.invalidatePaths for nextMode that supports getPathsByTags", async () => { - tagCache.mode = "nextMode"; - tagCache.getPathsByTags = vi.fn().mockResolvedValueOnce(["/path"]); - await cache.revalidateTag("tag"); - - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag"]); - expect(invalidateCdnHandler.invalidatePaths).toHaveBeenCalledWith([ - { - initialPath: "/path", - rawPath: "/path", - resolvedRoutes: [ - { - type: "app", - route: "/path", - isFallback: false, - }, - ], - }, - ]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"]); }); - }); - - describe("shouldBypassTagCache", () => { - describe("fetch cache", () => { - it("Should bypass tag cache validation when shouldBypassTagCache is true", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag1"], - }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - expect(result?.value).toEqual({ - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }); - }); - - it("Should not bypass tag cache validation when shouldBypassTagCache is false", async () => { - tagCache.mode = "nextMode"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - shouldBypassTagCache: false, - }); - - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag1"], - }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); - - it("Should not bypass tag cache validation when shouldBypassTagCache is undefined", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - // shouldBypassTagCache not set - }); - - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag1"], - }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); - it("Should bypass path validation when shouldBypassTagCache is true for soft tags", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { - kind: "FETCH", - softTags: [`${SOFT_TAG_PREFIX}path`], - }); + it("Should not call cache.revalidateTags when tags array is empty", async () => { + await instance.revalidateTag([]); - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); - describe("incremental cache", () => { - it("Should bypass tag cache validation when shouldBypassTagCache is true", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - expect(result?.value?.kind).toEqual("APP_ROUTE"); - }); + it("Should not throw when revalidateTags fails", async () => { + cache.revalidateTags.mockRejectedValueOnce(new Error("Error")); - it("Should not bypass tag cache validation when shouldBypassTagCache is false", async () => { - tagCache.mode = "nextMode"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - meta: { headers: { "x-next-cache-tags": "tag" } }, - }, - lastModified: Date.now(), - shouldBypassTagCache: false, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); - - it("Should return null when tag cache indicates revalidation and shouldBypassTagCache is false", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - meta: { headers: { "x-next-cache-tags": "tag" } }, - }, - lastModified: Date.now(), - shouldBypassTagCache: false, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - - it("Should return value when tag cache indicates revalidation but shouldBypassTagCache is true", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - expect(result?.value?.kind).toEqual("APP_ROUTE"); - }); + await expect(instance.revalidateTag("tag")).resolves.not.toThrow(); }); }); }); diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index 35b723b4..e126bcde 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -2,59 +2,41 @@ import ComposableCache from "@opennextjs/core/adapters/composable-cache"; import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; import { vi } from "vitest"; +const cache = { + name: "mock", + get: vi.fn().mockResolvedValue({ + value: { + type: "route", + body: "{}", + tags: ["tag1", "tag2"], + stale: 0, + timestamp: Date.now(), + expire: Date.now() + 1000, + revalidate: 3600, + value: "test-value", + }, + lastModified: Date.now(), + }), + set: vi.fn(), + delete: vi.fn(), + revalidateTags: vi.fn(), +}; +globalThis.cache = cache; + +globalThis.__openNextAls = { + getStore: () => ({ + pendingPromiseRunner: { + withResolvers: vi.fn().mockReturnValue({ + resolve: vi.fn(), + }), + }, + writtenTags: new Set(), + }), +}; + describe("Composable cache handler", () => { vi.useFakeTimers().setSystemTime("2024-01-02T00:00:00Z"); - const incrementalCache = { - name: "mock", - get: vi.fn().mockResolvedValue({ - value: { - type: "route", - body: "{}", - tags: ["tag1", "tag2"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - value: "test-value", - }, - lastModified: Date.now(), - }), - set: vi.fn(), - delete: vi.fn(), - }; - globalThis.incrementalCache = incrementalCache; - - const tagCache = { - name: "mock", - mode: "original" as string | undefined, - hasBeenRevalidated: vi.fn(), - getByTag: vi.fn().mockResolvedValue(["path1", "path2"]), - getByPath: vi.fn().mockResolvedValue(["tag1"]), - getLastModified: vi.fn().mockResolvedValue(new Date("2024-01-02T00:00:00Z").getTime()), - getLastRevalidated: vi.fn().mockResolvedValue(0), - writeTags: vi.fn(), - }; - globalThis.tagCache = tagCache; - - const invalidateCdnHandler = { - name: "mock", - invalidatePaths: vi.fn(), - }; - globalThis.cdnInvalidationHandler = invalidateCdnHandler; - const writtenTags = new Set(); - - globalThis.__openNextAls = { - getStore: () => ({ - pendingPromiseRunner: { - withResolvers: vi.fn().mockReturnValue({ - resolve: vi.fn(), - }), - }, - writtenTags, - }), - }; - beforeEach(() => { vi.clearAllMocks(); @@ -67,17 +49,17 @@ describe("Composable cache handler", () => { }); describe("get", () => { - it("should return cached entry when available and not revalidated", async () => { + it("should return cached entry when available", async () => { const result = await ComposableCache.get("test-key"); - expect(incrementalCache.get).toHaveBeenCalledWith("test-key", "composable"); + expect(cache.get).toHaveBeenCalledWith("test-key", "composable"); expect(result).toBeDefined(); expect(result?.tags).toEqual(["tag1", "tag2"]); expect(result?.value).toBeInstanceOf(ReadableStream); }); it("should return undefined when cache entry does not exist", async () => { - incrementalCache.get.mockResolvedValueOnce(null); + cache.get.mockResolvedValueOnce(null); const result = await ComposableCache.get("non-existent-key"); @@ -85,7 +67,7 @@ describe("Composable cache handler", () => { }); it("should return undefined when cache entry has no value", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: null, lastModified: Date.now(), }); @@ -95,74 +77,8 @@ describe("Composable cache handler", () => { expect(result).toBeUndefined(); }); - it("should check tag revalidation in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.hasBeenRevalidated).toHaveBeenCalledWith(["tag1", "tag2"], expect.any(Number)); - expect(result).toBeDefined(); - }); - - it("should return undefined when tags have been revalidated in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); - - const result = await ComposableCache.get("test-key"); - - expect(result).toBeUndefined(); - }); - - it("should skip tag check when tags array is empty in nextMode", async () => { - tagCache.mode = "nextMode"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - tags: [], - value: "test-value", - }, - lastModified: Date.now(), - }); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).toBeDefined(); - }); - - it("should check last modified in original mode", async () => { - tagCache.mode = "original"; - tagCache.getLastModified.mockResolvedValueOnce(Date.now()); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.getLastModified).toHaveBeenCalledWith("test-key", expect.any(Number)); - expect(result).toBeDefined(); - }); - - it("should return undefined when entry has been revalidated in original mode", async () => { - tagCache.mode = "original"; - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await ComposableCache.get("test-key"); - - expect(result).toBeUndefined(); - }); - - it("should handle undefined tag cache mode", async () => { - tagCache.mode = undefined; - tagCache.getLastModified.mockResolvedValueOnce(Date.now()); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.getLastModified).toHaveBeenCalled(); - expect(result).toBeDefined(); - }); - it("should return undefined on cache read error", async () => { - incrementalCache.get.mockRejectedValueOnce(new Error("Cache error")); + cache.get.mockRejectedValueOnce(new Error("Cache error")); const result = await ComposableCache.get("test-key"); @@ -194,12 +110,7 @@ describe("Composable cache handler", () => { }); describe("set", () => { - beforeEach(() => { - writtenTags.clear(); - }); - - it("should set cache entry and handle tags in original mode", async () => { - tagCache.mode = "original"; + it("should set cache entry", async () => { const entry = { value: toReadableStream("test-value"), tags: ["tag1", "tag2"], @@ -211,7 +122,7 @@ describe("Composable cache handler", () => { await ComposableCache.set("test-key", Promise.resolve(entry)); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "test-key", expect.objectContaining({ tags: ["tag1", "tag2"], @@ -219,64 +130,6 @@ describe("Composable cache handler", () => { }), "composable" ); - expect(tagCache.getByPath).toHaveBeenCalledWith("test-key"); - }); - - it("should write new tags not already stored", async () => { - tagCache.mode = "original"; - tagCache.getByPath.mockResolvedValueOnce(["tag1"]); - - const entry = { - value: toReadableStream("test-value"), - tags: ["tag1", "tag2", "tag3"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }; - - await ComposableCache.set("test-key", Promise.resolve(entry)); - - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { tag: "tag2", path: "test-key" }, - { tag: "tag3", path: "test-key" }, - ]); - }); - - it("should not write tags if all are already stored", async () => { - tagCache.mode = "original"; - tagCache.getByPath.mockResolvedValueOnce(["tag1", "tag2"]); - - const entry = { - value: toReadableStream("test-value"), - tags: ["tag1", "tag2"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }; - - await ComposableCache.set("test-key", Promise.resolve(entry)); - - expect(tagCache.writeTags).not.toHaveBeenCalled(); - }); - - it("should skip tag handling in nextMode", async () => { - tagCache.mode = "nextMode"; - - const entry = { - value: toReadableStream("test-value"), - tags: ["tag1", "tag2"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }; - - await ComposableCache.set("test-key", Promise.resolve(entry)); - - expect(tagCache.getByPath).not.toHaveBeenCalled(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); }); it("should convert ReadableStream to string", async () => { @@ -291,7 +144,7 @@ describe("Composable cache handler", () => { await ComposableCache.set("test-key", Promise.resolve(entry)); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "test-key", expect.objectContaining({ value: "test-content", @@ -306,131 +159,43 @@ describe("Composable cache handler", () => { await ComposableCache.refreshTags(); // Should not call any methods - expect(incrementalCache.get).not.toHaveBeenCalled(); - expect(incrementalCache.set).not.toHaveBeenCalled(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); }); - describe("getExpiration (Next 15)", () => { - it("should return last revalidated time in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.getLastRevalidated.mockResolvedValueOnce(123456); - - const result = await ComposableCache.getExpiration("tag1", "tag2"); - - expect(tagCache.getLastRevalidated).toHaveBeenCalledWith(["tag1", "tag2"]); - expect(result).toBe(123456); - }); - - it("should return 0 in original mode", async () => { - tagCache.mode = "original"; - + describe("getExpiration", () => { + it("should return 0 regardless of arguments", async () => { const result = await ComposableCache.getExpiration("tag1", "tag2"); expect(result).toBe(0); }); - it("should return 0 when mode is undefined", async () => { - tagCache.mode = undefined; - - const result = await ComposableCache.getExpiration("tag1", "tag2"); - - expect(result).toBe(0); - }); - }); - - describe("getExpiration (Next 16)", () => { - it("should return last revalidated time in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.getLastRevalidated.mockResolvedValueOnce(123456); - - const result = await ComposableCache.getExpiration(["tag1", "tag2"]); - - expect(tagCache.getLastRevalidated).toHaveBeenCalledWith(["tag1", "tag2"]); - expect(result).toBe(123456); - }); - - it("should return 0 in original mode", async () => { - tagCache.mode = "original"; - + it("should return 0 for array argument (Next 16 signature)", async () => { const result = await ComposableCache.getExpiration(["tag1", "tag2"]); expect(result).toBe(0); }); - it("should return 0 when mode is undefined", async () => { - tagCache.mode = undefined; - - const result = await ComposableCache.getExpiration(["tag1", "tag2"]); + it("should return 0 for empty args", async () => { + const result = await ComposableCache.getExpiration(); expect(result).toBe(0); }); }); describe("expireTags", () => { - beforeEach(() => { - writtenTags.clear(); - }); - it("should write tags directly in nextMode", async () => { - tagCache.mode = "nextMode"; - - await ComposableCache.expireTags("tag1", "tag2"); - - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag1", "tag2"]); - }); - - it("should find paths and write tag mappings in original mode", async () => { - tagCache.mode = "original"; - tagCache.getByTag.mockImplementation(async (tag) => { - if (tag === "tag1") return ["path1", "path2"]; - if (tag === "tag2") return ["path2", "path3"]; - return []; - }); - - await ComposableCache.expireTags("tag1", "tag2"); - - expect(tagCache.getByTag).toHaveBeenCalledWith("tag1"); - expect(tagCache.getByTag).toHaveBeenCalledWith("tag2"); - expect(tagCache.writeTags).toHaveBeenCalledWith( - expect.arrayContaining([ - { path: "path1", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag2", revalidatedAt: expect.any(Number) }, - { path: "path3", tag: "tag2", revalidatedAt: expect.any(Number) }, - ]) - ); - }); - - it("should deduplicate paths in original mode", async () => { - tagCache.mode = "original"; - tagCache.getByTag.mockImplementation(async (tag) => { - if (tag === "tag1") return ["path1", "path2"]; - if (tag === "tag2") return ["path1", "path2"]; - return []; - }); - + it("should call cache.revalidateTags with flat tags array", async () => { await ComposableCache.expireTags("tag1", "tag2"); - const writtenTags = tagCache.writeTags.mock.calls[0][0]; - expect(writtenTags).toHaveLength(4); // 2 paths × 2 tags = 4 unique combinations - expect(writtenTags).toEqual( - expect.arrayContaining([ - { path: "path1", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path1", tag: "tag2", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag2", revalidatedAt: expect.any(Number) }, - ]) - ); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"]); }); - it("should handle empty paths in original mode", async () => { - tagCache.mode = "original"; - tagCache.getByTag.mockResolvedValue([]); - - await ComposableCache.expireTags("tag1"); + it("should not call revalidateTags when no tags provided", async () => { + await ComposableCache.expireTags(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); }); @@ -439,9 +204,9 @@ describe("Composable cache handler", () => { await ComposableCache.receiveExpiredTags("tag1", "tag2"); // Should not call any methods - expect(incrementalCache.get).not.toHaveBeenCalled(); - expect(incrementalCache.set).not.toHaveBeenCalled(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); }); @@ -460,7 +225,7 @@ describe("Composable cache handler", () => { await ComposableCache.set("integration-key", Promise.resolve(entry)); // Verify it was stored - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "integration-key", expect.objectContaining({ value: "integration-test", @@ -470,7 +235,7 @@ describe("Composable cache handler", () => { ); // Mock the get response - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { ...entry, value: "integration-test", @@ -518,8 +283,8 @@ describe("Composable cache handler", () => { const results = await Promise.all(promises); - expect(incrementalCache.set).toHaveBeenCalledTimes(2); - expect(incrementalCache.get).not.toHaveBeenCalled(); + expect(cache.set).toHaveBeenCalledTimes(2); + expect(cache.get).not.toHaveBeenCalled(); expect(results[2]).toBeDefined(); expect(results[3]).toBeDefined(); diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index 4b2475a7..0dcfde57 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -46,19 +46,12 @@ function createEvent(event: PartialEvent): MiddlewareEvent { }; } -const incrementalCache = { +const cache = { name: "mock", get: vi.fn(), set: vi.fn(), delete: vi.fn(), -}; - -const tagCache = { - name: "mock", - getByTag: vi.fn(), - getByPath: vi.fn(), - getLastModified: vi.fn(), - writeTags: vi.fn(), + revalidateTags: vi.fn(), }; const queue = { @@ -68,12 +61,10 @@ const queue = { declare global { var queue: Queue; - var incrementalCache: any; - var tagCache: any; + var cache: any; } -globalThis.incrementalCache = incrementalCache; -globalThis.tagCache = tagCache; +globalThis.cache = cache; globalThis.queue = queue; beforeEach(() => { @@ -110,12 +101,12 @@ describe("cacheInterceptor", () => { expect(result).toEqual(event); }); - it("should take no action when incremental cache throws", async () => { + it("should take no action when cache throws", async () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockRejectedValueOnce(new Error("mock error")); + cache.get.mockRejectedValueOnce(new Error("mock error")); const result = await cacheInterceptor(event); expect(result).toEqual(event); @@ -125,7 +116,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -151,64 +142,11 @@ describe("cacheInterceptor", () => { ); }); - it("should take no action when tagCache lasModified is -1 for app type", async () => { - const event = createEvent({ - url: "/albums", - }); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "Hello, world!", - }, - }); - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await cacheInterceptor(event); - - expect(result).toEqual(event); - }); - - it("should bypass the tag cache when shouldBypassTagCache is true", async () => { - const event = createEvent({ - url: "/albums", - }); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "Hello, world!", - }, - shouldBypassTagCache: true, - }); - - await cacheInterceptor(event); - - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - }); - - it("should take no action when tagCache lasModified is -1 for route type", async () => { - const event = createEvent({ - url: "/albums", - }); - - const body = "route"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: body, - revalidate: false, - }, - lastModified: new Date("2024-01-01T23:58:00Z").getTime(), - }); - tagCache.getLastModified.mockResolvedValueOnce(-1); - const result = await cacheInterceptor(event); - expect(result).toEqual(event); - }); - it("should retrieve page router content from stale cache", async () => { const event = createEvent({ url: "/revalidate", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "page", html: "Hello, world!", @@ -240,7 +178,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/revalidate", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "page", html: "Hello, world!", @@ -272,7 +210,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "redirect", meta: { @@ -301,7 +239,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "?", html: "Hello, world!", @@ -318,7 +256,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = JSON.stringify({ message: "Hello from API" }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -358,7 +296,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "randomBinaryData"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -398,7 +336,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "API response"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -440,7 +378,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "Simple response"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -473,7 +411,7 @@ describe("cacheInterceptor", () => { url: "/albums", rewriteStatusCode: 403, }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -489,7 +427,7 @@ describe("cacheInterceptor", () => { url: "/albums", rewriteStatusCode: 203, }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -507,7 +445,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -525,7 +463,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts new file mode 100644 index 00000000..5ed63aeb --- /dev/null +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -0,0 +1,195 @@ +import fetchCache from "@opennextjs/core/overrides/cache/fetch"; +import { vi, describe, expect, it, beforeEach, afterEach } from "vitest"; + +// Helper: convert a plain headers object to a Map (mimics Headers API) +function toHeadersMap(headers: Record): Map { + const map = new Map(); + for (const [key, value] of Object.entries(headers)) { + map.set(key, value); + } + return map; +} + +function mockFetch(resp: { + headers: Record; + body: string; + status?: number; +}) { + const response = { + ok: true, + status: resp.status ?? 200, + text: vi.fn().mockResolvedValue(resp.body), + headers: toHeadersMap(resp.headers), + }; + global.fetch = vi.fn().mockResolvedValue(response); +} + +describe("fetch cache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should have name 'fetch-cache'", () => { + expect(fetchCache.name).toBe("fetch-cache"); + }); + + describe("get", () => { + it("should make a GET request to the correct URL", async () => { + mockFetch({ headers: {} as Record, body: "" }); + + await fetchCache.get("my-key"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/my-key", { method: "GET" }); + }); + + it("should encode the key in the URL", async () => { + mockFetch({ headers: {} as Record, body: "" }); + + await fetchCache.get("special/key"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/special%2Fkey", { method: "GET" }); + }); + + it("should add type query param when cacheType is provided", async () => { + mockFetch({ headers: {} as Record, body: "" }); + + await fetchCache.get("key", "fetch"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key?type=fetch", { method: "GET" }); + }); + + it("should return null when x-opennext-cache-found is not true", async () => { + // No x-opennext-cache-found header → parseCacheGetResponse returns null + mockFetch({ headers: { "content-type": "text/plain" }, body: "" }); + + const result = await fetchCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should return null for cache miss (found = false)", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "false", + "x-opennext-cache-type": "cache", + "Cache-Control": "no-store", + }, + body: "", + }); + + const result = await fetchCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should reconstruct a route cache entry from the response", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1000", + "Cache-Control": "no-store", + }, + body: "route-body-content", + }); + + const result = await fetchCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "route", + body: "route-body-content", + }); + expect(result!.lastModified).toBe(1000); + }); + + it("should reconstruct a fetch cache entry from the response", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-fetch-data-url": "https://example.com", + "x-opennext-cache-fetch-data-status": "200", + "Cache-Control": "no-store", + }, + body: '{"data":"value"}', + }); + + const result = await fetchCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toMatchObject({ + kind: "FETCH", + data: { + url: "https://example.com", + status: 200, + body: '{"data":"value"}', + }, + }); + }); + + it("should pass response headers (as plain object) and body text to parseCacheGetResponse", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "Content-Type": "text/plain", + }, + body: "hello", + }); + + const result = await fetchCache.get("key"); + + // parseCacheGetResponse receives the headers as a plain Record + // and the body text, then returns the parsed result + expect(result).not.toBeNull(); + expect(result!.value).toMatchObject({ type: "route", body: "hello" }); + }); + }); + + describe("set", () => { + it("should make a PUT request with JSON body", async () => { + const value = { type: "route", body: "content" }; + await fetchCache.set("key", value); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + }); + + it("should encode the key in the URL", async () => { + await fetchCache.set("special/key", {}); + + expect(global.fetch).toHaveBeenCalledWith("/cache/special%2Fkey", expect.any(Object)); + }); + }); + + describe("delete", () => { + it("should make a DELETE request", async () => { + await fetchCache.delete("key"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key", { method: "DELETE" }); + }); + }); + + describe("revalidateTags", () => { + it("should make a POST request with tags body", async () => { + await fetchCache.revalidateTags(["tag1", "tag2"]); + + expect(global.fetch).toHaveBeenCalledWith("/cache/revalidate-tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags: ["tag1", "tag2"] }), + }); + }); + }); +}); diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts new file mode 100644 index 00000000..c458c949 --- /dev/null +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -0,0 +1,209 @@ +import localCache from "@opennextjs/core/overrides/cache/local"; +import type { InternalResult } from "@opennextjs/core/types/open-next"; +import { toReadableStream } from "@opennextjs/core/utils/stream"; +import { vi, describe, expect, it, beforeEach } from "vitest"; + +vi.mock("@opennextjs/core/utils/normalize-path", () => ({ + getMonorepoRelativePath: vi.fn().mockReturnValue("/mock/root"), +})); + +const mockHandler = vi.fn(); + +vi.mock("/mock/root/cache-function/index.mjs", () => ({ + handler: mockHandler, +})); + +function createMockResult(overrides: Partial & { bodyText?: string } = {}): InternalResult { + const { bodyText, ...rest } = overrides; + return { + type: "core", + statusCode: 200, + body: toReadableStream(bodyText ?? ""), + isBase64Encoded: false, + headers: {}, + ...rest, + }; +} + +describe("local cache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should have name 'local-cache'", () => { + expect(localCache.name).toBe("local-cache"); + }); + + describe("get", () => { + it("should construct a GET InternalEvent with the correct rawPath", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("my-key"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("GET"); + expect(event.rawPath).toBe("/cache/my-key"); + expect(event.url).toBe("https://on/cache/my-key"); + }); + + it("should encode the key in rawPath and url", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("special/key"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.rawPath).toBe("/cache/special%2Fkey"); + expect(event.url).toBe("https://on/cache/special%2Fkey"); + }); + + it("should add type query param when cacheType is provided", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("key", "fetch"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.query).toEqual({ type: "fetch" }); + }); + + it("should return null when x-opennext-cache-found is missing", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: { "content-type": "text/plain" } }) + ); + + const result = await localCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should return null for cache miss (found = false)", async () => { + mockHandler.mockResolvedValue( + createMockResult({ + bodyText: "", + headers: { "x-opennext-cache-found": "false", "Cache-Control": "no-store" }, + }) + ); + + const result = await localCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should reconstruct a route cache entry from handler result", async () => { + mockHandler.mockResolvedValue( + createMockResult({ + bodyText: "route-body", + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1000", + "Cache-Control": "no-store", + "Content-Type": "text/plain", + }, + }) + ); + + const result = await localCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ type: "route", body: "route-body" }); + expect(result!.lastModified).toBe(1000); + }); + + it("should reconstruct a fetch cache entry from handler result", async () => { + mockHandler.mockResolvedValue( + createMockResult({ + bodyText: '{"data":"value"}', + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-fetch-data-url": "https://example.com", + "x-opennext-cache-fetch-data-status": "200", + "Cache-Control": "no-store", + }, + }) + ); + + const result = await localCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toMatchObject({ + kind: "FETCH", + data: { + url: "https://example.com", + status: 200, + body: '{"data":"value"}', + }, + }); + }); + }); + + describe("set", () => { + it("should construct a PUT InternalEvent with JSON body", async () => { + mockHandler.mockResolvedValue(createMockResult()); + const value = { type: "route", body: "content" }; + + await localCache.set("key", value); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("PUT"); + expect(event.rawPath).toBe("/cache/key"); + expect(event.headers).toEqual({ "Content-Type": "application/json" }); + expect(event.body).toEqual(Buffer.from(JSON.stringify({ value }))); + }); + + it("should encode the key", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.set("special/key", {}); + + const event = mockHandler.mock.calls[0][0]; + expect(event.rawPath).toBe("/cache/special%2Fkey"); + }); + }); + + describe("delete", () => { + it("should construct a DELETE InternalEvent", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.delete("key"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("DELETE"); + expect(event.rawPath).toBe("/cache/key"); + }); + }); + + describe("revalidateTags", () => { + it("should construct a POST InternalEvent with tags body", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.revalidateTags(["tag1", "tag2"]); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("POST"); + expect(event.rawPath).toBe("/cache/revalidate-tags"); + expect(event.body).toEqual(Buffer.from(JSON.stringify({ tags: ["tag1", "tag2"] }))); + }); + }); + + describe("handler caching", () => { + it("should reuse the handler across multiple calls", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("key1"); + await localCache.get("key2"); + + expect(mockHandler).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/tests-unit/tests/utils/cache-get.test.ts b/packages/tests-unit/tests/utils/cache-get.test.ts new file mode 100644 index 00000000..d15c65f2 --- /dev/null +++ b/packages/tests-unit/tests/utils/cache-get.test.ts @@ -0,0 +1,359 @@ +import { parseCacheGetResponse } from "@opennextjs/core/utils/cache-get"; +import { describe, expect, it } from "vitest"; + +describe("parseCacheGetResponse", () => { + it("should return null when x-opennext-cache-found is not 'true'", () => { + const result = parseCacheGetResponse({ "x-opennext-cache-type": "cache" }, "body"); + expect(result).toBeNull(); + }); + + it("should return null when x-opennext-cache-found is 'false'", () => { + const result = parseCacheGetResponse( + { "x-opennext-cache-found": "false", "x-opennext-cache-type": "cache" }, + "body" + ); + expect(result).toBeNull(); + }); + + describe("composable", () => { + it("should reconstruct a composable cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "composable", + "x-opennext-cache-composable-stale": "100", + "x-opennext-cache-composable-expire": "200", + "x-opennext-cache-composable-timestamp": "300", + "x-opennext-cache-composable-revalidate": "400", + "x-opennext-cache-composable-tags": '["tag1","tag2"]', + }; + const result = parseCacheGetResponse(headers, "test-value"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + value: "test-value", + tags: ["tag1", "tag2"], + stale: 100, + expire: 200, + timestamp: 300, + revalidate: 400, + }); + }); + + it("should return null when required composable fields are missing", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "composable", + "x-opennext-cache-composable-stale": "100", + }; + const result = parseCacheGetResponse(headers, "test-value"); + + expect(result).toBeNull(); + }); + + it("should handle empty tags array in composable entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "composable", + "x-opennext-cache-composable-stale": "100", + "x-opennext-cache-composable-expire": "200", + "x-opennext-cache-composable-timestamp": "300", + "x-opennext-cache-composable-revalidate": "400", + }; + const result = parseCacheGetResponse(headers, "test-value"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + value: "test-value", + tags: [], + stale: 100, + expire: 200, + timestamp: 300, + revalidate: 400, + }); + }); + }); + + describe("fetch", () => { + it("should reconstruct a fetch cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-fetch-data-url": "https://example.com", + "x-opennext-cache-fetch-data-status": "200", + "x-opennext-cache-fetch-data-tags": '["tag1"]', + "x-opennext-cache-fetch-tags": '["tag2"]', + "x-opennext-cache-revalidate": "60", + "x-opennext-cache-header-content-type": "application/json", + }; + const result = parseCacheGetResponse(headers, '{"data":"value"}'); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + kind: "FETCH", + data: { + headers: { "content-type": "application/json" }, + body: '{"data":"value"}', + url: "https://example.com", + status: 200, + tags: ["tag1"], + }, + tags: ["tag2"], + revalidate: 60, + }); + }); + + it("should return null when fetch kind is not FETCH", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "OTHER", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).toBeNull(); + }); + + it("should handle fetch entry without optional fields", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + kind: "FETCH", + data: { + headers: {}, + body: "body", + url: "", + }, + }); + }); + + it("should collect prefixed headers", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-header-content-type": "text/plain", + "x-opennext-cache-header-x-custom": "custom-value", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + const value = result!.value as { data: { headers: Record } }; + expect(value.data.headers).toEqual({ + "content-type": "text/plain", + "x-custom": "custom-value", + }); + }); + + it("should handle array-valued headers", () => { + const headers: Record = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-header-set-cookie": ["cookie1", "cookie2"], + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + const value = result!.value as { data: { headers: Record } }; + expect(value.data.headers["set-cookie"]).toEqual(["cookie1", "cookie2"]); + }); + }); + + describe("cached file", () => { + it("should reconstruct a route cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-meta-status": "200", + "x-opennext-cache-revalidate": "300", + }; + const result = parseCacheGetResponse(headers, "route body"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "route", + body: "route body", + meta: { status: 200 }, + revalidate: 300, + }); + }); + + it("should reconstruct a page cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "page", + }; + const body = JSON.stringify({ html: "", json: { data: "value" } }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "page", + html: "", + json: { data: "value" }, + }); + }); + + it("should reconstruct an app cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "app", + }; + const body = JSON.stringify({ + html: "", + rsc: "rsc-data", + segmentData: { seg1: "data1" }, + }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "app", + html: "", + rsc: "rsc-data", + segmentData: { seg1: "data1" }, + }); + }); + + it("should reconstruct an app cache entry without segmentData", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "app", + }; + const body = JSON.stringify({ html: "", rsc: "rsc-data" }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "app", + html: "", + rsc: "rsc-data", + }); + }); + + it("should reconstruct a redirect cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "redirect", + }; + const body = JSON.stringify({ destination: "/new-path" }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "redirect", + props: { destination: "/new-path" }, + }); + }); + + it("should return null for unknown sub-type", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "unknown-type", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).toBeNull(); + }); + + it("should handle meta with postponed field", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-meta-postponed": "postponed-data", + "x-opennext-cache-header-content-type": "text/html", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "route", + body: "body", + meta: { + postponed: "postponed-data", + headers: { "content-type": "text/html" }, + }, + }); + }); + }); + + describe("base metadata", () => { + it("should include lastModified when x-opennext-cache-last-modified is present", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1234567890", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.lastModified).toBe(1234567890); + }); + + it("should include shouldBypassTagCache when header is true", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-should-bypass": "true", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.shouldBypassTagCache).toBe(true); + }); + + it("should not include shouldBypassTagCache when header is not 'true'", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-should-bypass": "false", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.shouldBypassTagCache).toBeUndefined(); + }); + + it("should handle missing lastModified gracefully", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.lastModified).toBeUndefined(); + }); + + it("should handle invalid lastModified number gracefully", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "not-a-number", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.lastModified).toBeUndefined(); + }); + }); +}); From 753a21a906e3e9f8971b401d71c8ea2b28b33447 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 1 May 2026 16:29:56 +0200 Subject: [PATCH 07/17] Merged #1122 and #1142 from aws --- .../src/overrides/tagCache/dynamodb-lite.ts | 39 ++++++ .../overrides/tagCache/dynamodb-nextMode.ts | 130 +++++++++++++----- .../aws/src/overrides/tagCache/dynamodb.ts | 32 +++++ .../overrides/tag-cache/tag-cache-filter.ts | 14 +- packages/core/src/adapters/cache-adapter.ts | 65 +++++++-- packages/core/src/adapters/cache.ts | 4 +- .../core/src/adapters/composable-cache.ts | 23 ++++ packages/core/src/build/helper.ts | 2 + .../core/src/core/routing/cacheInterceptor.ts | 41 ++++-- packages/core/src/overrides/cache/fetch.ts | 4 +- packages/core/src/overrides/tagCache/dummy.ts | 3 + .../src/overrides/tagCache/fs-dev-nextMode.ts | 49 +++++-- .../core/src/overrides/tagCache/fs-dev.ts | 27 +++- packages/core/src/types/cache.ts | 4 + packages/core/src/types/global.ts | 6 + packages/core/src/types/overrides.ts | 14 +- packages/core/src/utils/cache.ts | 34 ++++- packages/core/src/utils/requestCache.ts | 35 +++++ .../tests/adapters/cache-adapter.test.ts | 114 +++++++++++++++ .../tests-unit/tests/adapters/cache.test.ts | 4 +- .../tests/adapters/composable-cache.test.ts | 64 +++++++++ .../core/routing/cacheInterceptor.test.ts | 75 ++++++++++ .../tests/overrides/cache/fetch.test.ts | 6 +- 23 files changed, 691 insertions(+), 98 deletions(-) create mode 100644 packages/core/src/utils/requestCache.ts diff --git a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts index 902bc29c..bc95337a 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts @@ -14,6 +14,8 @@ type DynamoDBItem = { tag?: { S: string }; path?: { S: string }; revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; }; type DynamoDBResponse = { @@ -163,6 +165,43 @@ const tagCache: OriginalTagCache = { return lastModified ?? Date.now(); } }, + async isStale(key: string, lastModified?: number) { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + try { + const { CACHE_DYNAMO_TABLE } = process.env; + const response = await awsFetch( + JSON.stringify({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + if (response.status !== 200) { + throw new RecoverableError(`Failed to check stale tags: ${response.status}`); + } + const items = ((await response.json()) as DynamoDBResponse).Items ?? []; + return items.some((entry) => { + if (!entry.stale?.N) return false; + return ( + Number.parseInt(entry.revalidatedAt?.N ?? "0") > (lastModified ?? 0) && + Number.parseInt(entry.stale.N) > (lastModified ?? 0) + ); + }); + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]) { try { const { CACHE_DYNAMO_TABLE } = process.env; diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index 88deb1a8..95f298fc 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -2,8 +2,9 @@ import path from "node:path"; import { debug, error } from "@opennextjs/core/adapters/logger.js"; import { chunk, parseNumberFromEnv } from "@opennextjs/core/adapters/util.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { RecoverableError } from "@opennextjs/core/utils/error.js"; +import { RequestCache } from "@opennextjs/core/utils/requestCache.js"; import { AwsClient } from "aws4fetch"; import { customFetchClient } from "../../utils/fetch.js"; @@ -13,6 +14,8 @@ import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrenc type DynamoDBTagItem = { revalidatedAt: { N: string }; tag: { S: string }; + stale?: { N: string }; + expire?: { N: string }; }; type DynamoDBBatchGetResponse = { @@ -58,14 +61,47 @@ function buildDynamoKey(key: string) { // We use the same key for both path and tag // That's mostly for compatibility reason so that it's easier to use this with existing infra // FIXME: Allow a simpler object without an unnecessary path key -function buildDynamoObject(tag: string, revalidatedAt?: number) { +function buildDynamoObject(tag: string, revalidatedAt?: number, stale?: number, expire?: number) { return { path: { S: buildDynamoKey(tag) }, tag: { S: buildDynamoKey(tag) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } +function fetchTagItems(tags: string[]): Promise { + const { CACHE_DYNAMO_TABLE } = process.env; + + return awsFetch( + JSON.stringify({ + RequestItems: { + [CACHE_DYNAMO_TABLE ?? ""]: { + Keys: tags.map((tag) => ({ + path: { S: buildDynamoKey(tag) }, + tag: { S: buildDynamoKey(tag) }, + })), + }, + }, + }), + "query" + ).then(async (response) => { + if (response.status !== 200) { + throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); + } + const { Responses } = (await response.json()) as DynamoDBBatchGetResponse; + return Responses?.[CACHE_DYNAMO_TABLE ?? ""] ?? []; + }); +} + +const requestCache = new RequestCache(); + +function getCachedTagItems(tags: string[]): Promise { + const cacheKey = [...tags].sort().join(","); + return requestCache.getOrSet(cacheKey, () => fetchTagItems(tags)); +} + // This implementation does not support automatic invalidation of paths by the cdn export default { name: "ddb-nextMode", @@ -83,52 +119,74 @@ export default { "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" ); } - const { CACHE_DYNAMO_TABLE } = process.env; - // It's unlikely that we will have more than 100 items to query - // If that's the case, you should not use this tagCache implementation - const response = await awsFetch( - JSON.stringify({ - RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: { - Keys: tags.map((tag) => ({ - path: { S: buildDynamoKey(tag) }, - tag: { S: buildDynamoKey(tag) }, - })), - }, - }, - }), - "query" - ); - if (response.status !== 200) { - throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); - } - // Now we need to check for every item if lastModified is greater than the revalidatedAt - const { Responses } = (await response.json()) as DynamoDBBatchGetResponse; - if (!Responses) { + const items = await getCachedTagItems(tags); + + const now = Date.now(); + const revalidatedTags = items.filter((item) => { + const revalidatedAt = Number.parseInt(item.revalidatedAt.N); + if (revalidatedAt > (lastModified ?? 0)) { + return true; + } + // If the tag has expired (expire time is in the past), it counts as revalidated + if (item.expire?.N) { + const expireTime = Number.parseInt(item.expire.N); + if (expireTime <= now && expireTime > (lastModified ?? 0)) { + return true; + } + } return false; - } - const revalidatedTags = - Responses?.[CACHE_DYNAMO_TABLE ?? ""]?.filter( - (item) => Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0) - ) ?? []; + }); debug("retrieved tags", revalidatedTags); return revalidatedTags.length > 0; }, - writeTags: async (tags: string[]) => { + isStale: async (tags: string[], lastModified?: number) => { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + if (tags.length === 0) { + return false; + } + if (tags.length > 100) { + throw new RecoverableError( + "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" + ); + } + const items = await getCachedTagItems(tags); + + const hasStaleTag = items.some((item) => { + if (!item?.stale?.N) return false; + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return revalidatedAt > (lastModified ?? 0) && Number.parseInt(item.stale.N) >= (lastModified ?? 0); + }); + debug("isStale result:", hasStaleTag); + return hasStaleTag; + }, + writeTags: async (tags: (string | NextModeTagCacheWriteInput)[]) => { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { return; } + const now = Date.now(); const dataChunks = chunk(tags, MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT).map((Items) => ({ RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => ({ - PutRequest: { - Item: { - ...buildDynamoObject(tag), + [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => { + if (typeof tag === "string") { + return { + PutRequest: { + Item: buildDynamoObject(tag, now), + }, + }; + } + return { + PutRequest: { + Item: buildDynamoObject(tag.tag, now, tag.stale, tag.expire), }, - }, - })), + }; + }), }, })); const toInsert = chunk(dataChunks, getDynamoBatchWriteCommandConcurrency()); diff --git a/packages/aws/src/overrides/tagCache/dynamodb.ts b/packages/aws/src/overrides/tagCache/dynamodb.ts index 09eed484..7e90f007 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb.ts @@ -118,6 +118,38 @@ const tagCache: TagCache = { return lastModified ?? Date.now(); } }, + async isStale(key, lastModified) { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + try { + const command = new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }); + const result = await dynamoClient.send(command); + const items = result.Items ?? []; + return items.some((item) => { + if (!item.stale?.N) return false; + return ( + Number.parseInt(item.revalidatedAt?.N ?? "0") > (lastModified ?? 0) && + Number.parseInt(item.stale.N) > (lastModified ?? 0) + ); + }); + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, async writeTags(tags) { try { if (globalThis.openNextConfig.dangerous?.disableTagCache) { diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts index 05c11970..49efb5e3 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts @@ -1,17 +1,16 @@ -import { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; interface WithFilterOptions { /** * The original tag cache. - * Call to this will receive only the filtered tags. */ tagCache: NextModeTagCache; + /** - * The function to filter tags. - * @param tag The tag to filter. + * Filter function that returns true if the tag should be forwarded to the underlying tag cache. * @returns true if the tag should be forwarded, false otherwise. */ - filterFn: (tag: string) => boolean; + filterFn: (tag: string | NextModeTagCacheWriteInput) => boolean; } /** @@ -60,6 +59,7 @@ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeT * This is used to filter out internal soft tags. * Can be used if `revalidatePath` is not used. */ -export function softTagFilter(tag: string): boolean { - return !tag.startsWith("_N_T_"); +export function softTagFilter(tag: string | { tag: string }): boolean { + const tagStr = typeof tag === "string" ? tag : tag.tag; + return !tagStr.startsWith("_N_T_"); } diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index 6ed44cbf..3ffb1ca9 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -13,7 +13,7 @@ import type { import { createGenericHandler } from "../core/createGenericHandler.js"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; -import { getTagsFromValue, writeTags } from "../utils/cache.js"; +import { getTagsFromValue, isStale, writeTags } from "../utils/cache.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; import { toReadableStream } from "../utils/stream.js"; @@ -136,6 +136,8 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise 0) { const revalidated = await checkTagRevalidation(key, tags, result); if (revalidated) { @@ -152,6 +154,12 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise 0 ? await isStale(key, tags, lastModified) : false; + if (_isStale) { + result.lastModified = 1; + } } return buildCacheGetResponse(result); @@ -221,7 +229,7 @@ async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): tagsToWrite.map((tag) => ({ path: key, tag, - revalidatedAt: 1, + revalidatedAt: Date.now(), })), tagCache ); @@ -255,24 +263,38 @@ async function handleRevalidateTags(body?: Buffer): Promise { return buildErrorResponse("Missing request body", 400); } - let tags: string[]; + let parsed: { tags?: string[]; durations?: { expire?: number } }; try { - const parsed = JSON.parse(body.toString("utf-8")); - tags = Array.isArray(parsed.tags) ? parsed.tags : []; + parsed = JSON.parse(body.toString("utf-8")); } catch { return buildErrorResponse("Invalid JSON body", 400); } + const tags = Array.isArray(parsed.tags) ? parsed.tags : []; if (tags.length === 0) { return buildErrorResponse("Missing 'tags' array in request body", 400); } + const { durations } = parsed; + try { await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { if (globalThis.tagCache.mode === "nextMode") { const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; - await writeTags(tags); + const now = Date.now(); + const tagsToWrite = tags.map((tag) => { + if (durations) { + return { + tag, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { tag, expire: now }; + }); + + await writeTags(tagsToWrite); if (paths.length > 0) { await globalThis.cdnInvalidationHandler.invalidatePaths( paths.map((path) => ({ @@ -291,14 +313,22 @@ async function handleRevalidateTags(body?: Buffer): Promise { return; } + const now = Date.now(); for (const tag of tags) { debug("revalidateTag", tag); const paths = await globalThis.tagCache.getByTag(tag); debug("Items", paths); - const toInsert = paths.map((path) => ({ - path, - tag, - })); + const toInsert = paths.map((path) => { + const baseEntry = { path, tag }; + if (durations) { + return { + ...baseEntry, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { ...baseEntry, expire: now }; + }); if (tag.startsWith(SOFT_TAG_PREFIX)) { for (const path of paths) { @@ -308,10 +338,17 @@ async function handleRevalidateTags(body?: Buffer): Promise { const _paths = await globalThis.tagCache.getByTag(hardTag); debug({ hardTag, _paths }); toInsert.push( - ..._paths.map((path) => ({ - path, - tag: hardTag, - })) + ..._paths.map((path) => { + const baseEntry = { path, tag: hardTag }; + if (durations) { + return { + ...baseEntry, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { ...baseEntry, expire: now }; + }) ); } } diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index 1ea47517..6fe7627f 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -248,7 +248,7 @@ export default class Cache { } } - public async revalidateTag(tags: string | string[]) { + public async revalidateTag(tags: string | string[], durations?: { expire?: number }) { const config = globalThis.openNextConfig.dangerous; if (config?.disableTagCache || config?.disableIncrementalCache) { return; @@ -259,7 +259,7 @@ export default class Cache { } try { - await globalThis.cache.revalidateTags(_tags); + await globalThis.cache.revalidateTags(_tags, durations); } catch (e) { error("Failed to revalidate tag", e); } diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index 820fbd41..cd492982 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -27,8 +27,15 @@ export default { debug("composable cache result", result); + let revalidate = result.value.revalidate; + // If the cache adapter signaled staleness via lastModified=1, trigger SWR + if (result.lastModified === 1) { + revalidate = -1; + } + return { ...result.value, + revalidate, value: toReadableStream(result.value.value), }; } catch (e) { @@ -83,6 +90,22 @@ export default { } }, + /** + * Added in Next.js 16. Updates tags with optional stale/expire durations. + * Mirrors the revalidateTag logic but without CDN invalidation + * since composable cache keys are not URL paths. + */ + async updateTags(tags: string[], durations?: { expire?: number }) { + if (tags.length === 0) { + return; + } + try { + await globalThis.cache.revalidateTags(tags, durations); + } catch (e) { + debug("Failed to update tags", e); + } + }, + // This one is necessary for older versions of next async receiveExpiredTags(...tags: string[]) { // This function does absolutely nothing diff --git a/packages/core/src/build/helper.ts b/packages/core/src/build/helper.ts index 159495b9..766ab69d 100644 --- a/packages/core/src/build/helper.ts +++ b/packages/core/src/build/helper.ts @@ -117,6 +117,7 @@ export function esbuildSync(esbuildOptions: ESBuildOptions, options: BuildOption esbuildOptions.banner?.js || "", `globalThis.openNextDebug = ${debug};`, `globalThis.openNextVersion = "${openNextVersion}";`, + `globalThis.nextVersion = "${options.nextVersion}";`, ].join(""), }, }); @@ -150,6 +151,7 @@ export async function esbuildAsync(esbuildOptions: ESBuildOptions, options: Buil esbuildOptions.banner?.js || "", `globalThis.openNextDebug = ${debug};`, `globalThis.openNextVersion = "${openNextVersion}";`, + `globalThis.nextVersion = "${options.nextVersion}";`, ].join(""), }, }); diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index d54b5781..0e711802 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -34,7 +34,8 @@ async function computeCacheControl( body: string, host: string, revalidate?: number | false, - lastModified?: number + lastModified?: number, + isStaleFromTagCache = false ) { let finalRevalidate = CACHE_ONE_YEAR; @@ -59,19 +60,26 @@ async function computeCacheControl( etag, }; } - if (finalRevalidate !== CACHE_ONE_YEAR) { - const sMaxAge = Math.max(finalRevalidate - age, 1); + + // SSG uses one year cache + const isSSG = finalRevalidate === CACHE_ONE_YEAR; + const remainingTtl = Math.max(finalRevalidate - age, 1); + + const isStaleFromTime = !isSSG && remainingTtl === 1; + const isStale = isStaleFromTime || isStaleFromTagCache; + + if (!isSSG || isStaleFromTagCache) { + const sMaxAge = isStaleFromTagCache ? 1 : remainingTtl; debug("sMaxAge", { finalRevalidate, age, lastModified, revalidate, + isStaleFromTagCache, }); - const isStale = sMaxAge === 1; if (isStale) { let url = NextConfig.trailingSlash ? `${path}/` : path; if (NextConfig.basePath) { - // We need to add the basePath to the url url = `${NextConfig.basePath}${url}`; } await globalThis.queue.send({ @@ -164,7 +172,8 @@ async function generateResult( event: MiddlewareEvent, localizedPath: string, cachedValue: CacheValue<"cache">, - lastModified?: number + lastModified?: number, + isStaleFromTagCache = false ): Promise { debug("Returning result from experimental cache"); let body = ""; @@ -231,7 +240,8 @@ async function generateResult( body, event.headers.host, cachedValue.revalidate, - lastModified + lastModified, + isStaleFromTagCache ); return { type: "core", @@ -346,17 +356,27 @@ export async function cacheInterceptor( return event; } const host = event.headers.host; + //TODO: change returned type to provide staleness as a prop + // Detect staleness signaled by the cache adapter (sets lastModified to 1) + const isStaleFromTagCache = cachedData.lastModified === 1; switch (cachedData?.value?.type) { case "app": case "page": - return generateResult(event, localizedPath, cachedData.value, cachedData.lastModified); + return generateResult( + event, + localizedPath, + cachedData.value, + cachedData.lastModified, + isStaleFromTagCache + ); case "redirect": { const cacheControl = await computeCacheControl( localizedPath, "", host, cachedData.value.revalidate, - cachedData.lastModified + cachedData.lastModified, + isStaleFromTagCache ); return { type: "core", @@ -375,7 +395,8 @@ export async function cacheInterceptor( cachedData.value.body, host, cachedData.value.revalidate, - cachedData.lastModified + cachedData.lastModified, + isStaleFromTagCache ); const isBinary = isBinaryContentType(String(cachedData.value.meta?.headers?.["content-type"])); diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index 0707267c..c3bcf297 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -28,11 +28,11 @@ const fetchCache: Cache = { const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; await fetch(url, { method: "DELETE" }); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { await fetch(`${CACHE_URL}/cache/revalidate-tags`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tags }), + body: JSON.stringify({ tags, durations }), }); }, }; diff --git a/packages/core/src/overrides/tagCache/dummy.ts b/packages/core/src/overrides/tagCache/dummy.ts index ac44b532..8a62dfa8 100644 --- a/packages/core/src/overrides/tagCache/dummy.ts +++ b/packages/core/src/overrides/tagCache/dummy.ts @@ -13,6 +13,9 @@ const dummyTagCache: TagCache = { getLastModified: async (_: string, lastModified) => { return lastModified ?? Date.now(); }, + isStale: async () => { + return false; + }, writeTags: async () => { return; }, diff --git a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts index 49ccb498..08b6ddb9 100644 --- a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts +++ b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts @@ -1,8 +1,14 @@ -import type { NextModeTagCache } from "@/types/overrides"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@/types/overrides"; import { debug } from "../../adapters/logger"; -const tagsMap = new Map(); +type TagData = { + revalidatedAt: number; + stale?: number; + expire?: number; +}; + +const tagsMap = new Map(); export default { name: "fs-dev-nextMode", @@ -15,7 +21,7 @@ export default { let lastRevalidated = 0; tags.forEach((tag) => { - const tagTime = tagsMap.get(tag); + const tagTime = tagsMap.get(tag)?.revalidatedAt; if (tagTime && tagTime > lastRevalidated) { lastRevalidated = tagTime; } @@ -30,22 +36,49 @@ export default { } const hasRevalidatedTag = tags.some((tag) => { - const tagRevalidatedAt = tagsMap.get(tag); - return tagRevalidatedAt ? tagRevalidatedAt > (lastModified ?? 0) : false; + const tagData = tagsMap.get(tag); + return tagData ? tagData.revalidatedAt > (lastModified ?? 0) : false; }); debug("hasBeenRevalidated result:", hasRevalidatedTag); return hasRevalidatedTag; }, - writeTags: async (tags: string[]) => { + isStale: async (tags: string[], lastModified?: number) => { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + + const hasStaleTag = tags.some((tag) => { + const tagData = tagsMap.get(tag); + if (!tagData || typeof tagData.stale !== "number") { + return false; + } + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return tagData.revalidatedAt > (lastModified ?? 0) && tagData.stale >= (lastModified ?? 0); + }); + debug("isStale result:", hasStaleTag); + return hasStaleTag; + }, + writeTags: async (tags: (string | NextModeTagCacheWriteInput)[]) => { if (globalThis.openNextConfig.dangerous?.disableTagCache || tags.length === 0) { return; } - debug("writeTags", { tags: tags }); + debug("writeTags", { tags }); + const now = Date.now(); tags.forEach((tag) => { - tagsMap.set(tag, Date.now()); + if (typeof tag === "string") { + tagsMap.set(tag, { revalidatedAt: now }); + } else { + tagsMap.set(tag.tag, { + revalidatedAt: now, + ...(tag.stale !== undefined ? { stale: tag.stale } : {}), + ...(tag.expire !== undefined ? { expire: tag.expire } : {}), + }); + } }); debug("writeTags completed, written", tags.length, "tags"); diff --git a/packages/core/src/overrides/tagCache/fs-dev.ts b/packages/core/src/overrides/tagCache/fs-dev.ts index 932eb216..1313b620 100644 --- a/packages/core/src/overrides/tagCache/fs-dev.ts +++ b/packages/core/src/overrides/tagCache/fs-dev.ts @@ -1,17 +1,21 @@ import fs from "node:fs"; import path from "node:path"; -import type { TagCache } from "@/types/overrides"; +import type { OriginalTagCacheWriteInput, TagCache } from "@/types/overrides"; import { getMonorepoRelativePath } from "@/utils/normalize-path"; const tagFile = path.join(getMonorepoRelativePath(), "dynamodb-provider/dynamodb-cache.json"); const tagContent = fs.readFileSync(tagFile, "utf-8"); -let tags = JSON.parse(tagContent) as { +type TagEntry = { tag: { S: string }; path: { S: string }; revalidatedAt: { N: string }; -}[]; + stale?: { N: string }; + expire?: { N: string }; +}; + +let tags = JSON.parse(tagContent) as TagEntry[]; const { NEXT_BUILD_ID } = process.env; @@ -40,7 +44,20 @@ const tagCache: TagCache = { ); return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); }, - writeTags: async (newTags) => { + isStale: async (path: string, lastModified?: number) => { + const matchingTags = tags.filter((tagPathMapping) => tagPathMapping.path.S === buildKey(path)); + return matchingTags.some((entry) => { + if (!entry.stale?.N) return false; + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return ( + Number.parseInt(entry.revalidatedAt.N) > (lastModified ?? 0) && + Number.parseInt(entry.stale.N) > (lastModified ?? 0) + ); + }); + }, + writeTags: async (newTags: OriginalTagCacheWriteInput[]) => { const newTagsSet = new Set(newTags.map(({ tag, path }) => `${buildKey(tag)}-${buildKey(path)}`)); const unchangedTags = tags.filter(({ tag, path }) => !newTagsSet.has(`${tag.S}-${path.S}`)); tags = unchangedTags.concat( @@ -48,6 +65,8 @@ const tagCache: TagCache = { tag: { S: buildKey(item.tag) }, path: { S: buildKey(item.path) }, revalidatedAt: { N: `${item.revalidatedAt ?? Date.now()}` }, + ...(item.stale !== undefined ? { stale: { N: `${item.stale}` } } : {}), + ...(item.expire !== undefined ? { expire: { N: `${item.expire}` } } : {}), })) ); }, diff --git a/packages/core/src/types/cache.ts b/packages/core/src/types/cache.ts index db5b63f1..1737d0c4 100644 --- a/packages/core/src/types/cache.ts +++ b/packages/core/src/types/cache.ts @@ -168,6 +168,10 @@ export interface ComposableCacheHandler { * Removed from Next.js 16 */ expireTags(...tags: string[]): Promise; + /** + * Added in Next.js 16. Updates tags with optional stale/expire durations. + */ + updateTags?(tags: string[], durations?: { expire?: number }): Promise; /** * This function is only there for older versions and do nothing */ diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 552bc664..24f6a0e1 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -196,6 +196,12 @@ declare global { */ var openNextVersion: string; + /** + * The version of Next.js used in this build. + * Available in the cache function (defined in the esbuild banner of the cache bundle). + */ + var nextVersion: string; + /** * The cache client used to communicate with the cache handler function. * Only available in main functions. diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index 9c4f980c..3ca5f964 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -153,12 +153,19 @@ Cons : - One page request (i.e. GET request) could require to check a lot of tags (And some of them multiple time when used with the fetch cache) - Almost impossible to do automatic cdn revalidation by itself */ +export interface NextModeTagCacheWriteInput { + tag: string; + stale?: number; + expire?: number; +} + export type NextModeTagCache = BaseTagCache & { mode: "nextMode"; // Necessary for the composable cache getLastRevalidated(tags: string[]): Promise; hasBeenRevalidated(tags: string[], lastModified?: number): Promise; - writeTags(tags: string[]): Promise; + isStale?(tags: string[], lastModified?: number): Promise; + writeTags(tags: (string | NextModeTagCacheWriteInput)[]): Promise; // Optional method to get paths by tags // It is used to automatically invalidate paths in the CDN getPathsByTags?: (tags: string[]) => Promise; @@ -168,6 +175,8 @@ export interface OriginalTagCacheWriteInput { tag: string; path: string; revalidatedAt?: number; + stale?: number; + expire?: number; } /** @@ -194,6 +203,7 @@ export type OriginalTagCache = BaseTagCache & { getByTag(tag: string): Promise; getByPath(path: string): Promise; getLastModified(path: string, lastModified?: number): Promise; + isStale?(path: string, lastModified?: number): Promise; writeTags(tags: OriginalTagCacheWriteInput[]): Promise; }; @@ -264,7 +274,7 @@ export type Cache = BaseOverride & { isFetch?: CacheType ): Promise; delete(key: string): Promise; - revalidateTags(tags: string[]): Promise; + revalidateTags(tags: string[], durations?: { expire?: number }): Promise; }; type CDNPath = { diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index d6c2071a..b92d9995 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -1,6 +1,7 @@ import type { CacheEntryType, CacheValue, + NextModeTagCacheWriteInput, OriginalTagCacheWriteInput, TagCache, WithLastModified, @@ -8,6 +9,22 @@ import type { import { debug } from "../adapters/logger"; +export async function isStale( + key: string, + tags: string[], + lastModified: number, + tagCache: TagCache = globalThis.tagCache +): Promise { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + if (tagCache.mode === "nextMode") { + return tags.length > 0 && (await tagCache.isStale?.(tags, lastModified)) === true; + } + const isCacheStale = await tagCache.isStale?.(key, lastModified); + return isCacheStale === true; +} + export async function hasBeenRevalidated( key: string, tags: string[], @@ -48,18 +65,23 @@ export function getTagsFromValue(value?: CacheValue<"cache">) { } } -function getTagKey(tag: string | OriginalTagCacheWriteInput): string { +type WriteTagInput = string | NextModeTagCacheWriteInput | OriginalTagCacheWriteInput; + +function getTagKey(tag: WriteTagInput): string { if (typeof tag === "string") { return tag; } - return JSON.stringify({ - tag: tag.tag, - path: tag.path, - }); + if ("path" in tag) { + return JSON.stringify({ + tag: tag.tag, + path: tag.path, + }); + } + return JSON.stringify({ tag: tag.tag }); } export async function writeTags( - tags: (string | OriginalTagCacheWriteInput)[], + tags: WriteTagInput[], tagCache: TagCache = globalThis.tagCache ): Promise { const store = globalThis.__openNextAls.getStore(); diff --git a/packages/core/src/utils/requestCache.ts b/packages/core/src/utils/requestCache.ts new file mode 100644 index 00000000..8853136d --- /dev/null +++ b/packages/core/src/utils/requestCache.ts @@ -0,0 +1,35 @@ +/** + * A simple utility to cache values scoped to a request. + * It uses our internal AsyncLocalStorage (globalThis.__openNextAls) to store the cache. + * + * This is useful for deduplicating operations within the same request, + * such as DynamoDB queries for tag cache lookups. + */ +export class RequestCache { + getOrSet(key: K, factory: () => Promise): Promise { + const store = globalThis.__openNextAls.getStore(); + if (!store) { + return factory(); + } + // We use "requestCache" as a property on the store + // and lazily initialize a Map for each cache instance + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const reqCache = (store as any).requestCache as Map, Map>> | undefined; + if (!reqCache) { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + (store as any).requestCache = new Map(); + } + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const cache = (store as any).requestCache as Map, Map>>; + if (!cache.has(this)) { + cache.set(this, new Map()); + } + const innerCache = cache.get(this)!; + if (innerCache.has(key)) { + return innerCache.get(key)!; + } + const promise = factory(); + innerCache.set(key, promise); + return promise; + } +} diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 7e0a1316..b420cfc0 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -22,6 +22,7 @@ const mockTagCache = vi.hoisted(() => ({ getByTag: vi.fn(), getByPath: vi.fn(), getLastModified: vi.fn(), + isStale: vi.fn(), writeTags: vi.fn(), hasBeenRevalidated: vi.fn(), getPathsByTags: undefined as Mock | undefined, @@ -381,6 +382,57 @@ describe("cache-adapter", () => { expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); expect(mockTagCache.hasBeenRevalidated).not.toHaveBeenCalled(); }); + + it("should set lastModified to 1 when tags are stale", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(true); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1"); + }); + + it("should keep original lastModified when tags are not stale", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(false); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + }); + + it("should skip isStale when shouldBypassTagCache is true", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + shouldBypassTagCache: true, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.isStale).not.toHaveBeenCalled(); + }); }); describe("PUT /cache/:key", () => { @@ -655,5 +707,67 @@ describe("cache-adapter", () => { expect(result.statusCode).toBe(500); }); + + it("should accept durations and pass stale/expire - nextMode", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { tag: "tag1", stale: 100000, expire: 100000 + 30 * 1000 }, + ]); + }); + + it("should accept durations and pass stale/expire - original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { path: "/path1", tag: "tag1", stale: 100000, expire: 100000 + 30 * 1000 }, + ]); + }); + + it("should use immediate expiration when no durations provided - nextMode", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([{ tag: "tag1", expire: 100000 }]); + }); + + it("should use immediate expiration when no durations provided - original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([{ path: "/path1", tag: "tag1", expire: 100000 }]); + }); }); }); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index ef6f8e46..1d28ed49 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -535,13 +535,13 @@ describe("CacheHandler", () => { it("Should call cache.revalidateTags with single tag", async () => { await instance.revalidateTag("tag"); - expect(cache.revalidateTags).toHaveBeenCalledWith(["tag"]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag"], undefined); }); it("Should call cache.revalidateTags with array of tags", async () => { await instance.revalidateTag(["tag1", "tag2"]); - expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"], undefined); }); it("Should not call cache.revalidateTags when tags array is empty", async () => { diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index e126bcde..884f75bb 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -85,6 +85,44 @@ describe("Composable cache handler", () => { expect(result).toBeUndefined(); }); + it("should set revalidate=-1 when lastModified is 1 (stale from cache adapter)", async () => { + cache.get.mockResolvedValueOnce({ + value: { + value: "stale-value", + tags: ["tag1"], + stale: 0, + timestamp: 1000, + expire: 2000, + revalidate: 3600, + }, + lastModified: 1, + }); + + const result = await ComposableCache.get("stale-key"); + + expect(result).toBeDefined(); + expect(result?.revalidate).toBe(-1); + }); + + it("should keep original revalidate when lastModified is not 1", async () => { + cache.get.mockResolvedValueOnce({ + value: { + value: "fresh-value", + tags: ["tag1"], + stale: 0, + timestamp: 1000, + expire: 2000, + revalidate: 3600, + }, + lastModified: 1000, + }); + + const result = await ComposableCache.get("fresh-key"); + + expect(result).toBeDefined(); + expect(result?.revalidate).toBe(3600); + }); + it("should return pending write promise if available", async () => { const pendingEntry = Promise.resolve({ value: toReadableStream("pending-value"), @@ -296,4 +334,30 @@ describe("Composable cache handler", () => { expect(content2).toBe("concurrent-2"); }); }); + + describe("updateTags", () => { + it("should call cache.revalidateTags with tags and durations", async () => { + await ComposableCache.updateTags(["tag1", "tag2"], { expire: 30 }); + + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"], { expire: 30 }); + }); + + it("should not call cache.revalidateTags when tags are empty", async () => { + await ComposableCache.updateTags([]); + + expect(cache.revalidateTags).not.toHaveBeenCalled(); + }); + + it("should call cache.revalidateTags without durations when not provided", async () => { + await ComposableCache.updateTags(["tag1"]); + + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1"], undefined); + }); + + it("should not throw on cache error", async () => { + cache.revalidateTags.mockRejectedValueOnce(new Error("cache error")); + + await expect(ComposableCache.updateTags(["tag1"])).resolves.not.toThrow(); + }); + }); }); diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index 0dcfde57..e995f5f3 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -473,4 +473,79 @@ describe("cacheInterceptor", () => { const result = await cacheInterceptor(event); expect(result.statusCode).toBe(200); }); + + describe("isStaleFromTagCache", () => { + it("should serve SSG app content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + }, + lastModified: 1, + }); + + const result = await cacheInterceptor(event); + + expect(result).toEqual( + expect.objectContaining({ + type: "core", + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }) + ); + }); + + it("should serve SSG page content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "page", + html: "Hello, world!", + }, + lastModified: 1, + }); + + const result = await cacheInterceptor(event); + + expect(result.type).toBe("core"); + expect((result as any).headers["cache-control"]).toBe("s-maxage=1, stale-while-revalidate=2592000"); + expect((result as any).headers["x-opennext-cache"]).toBe("STALE"); + }); + + it("should serve SSG route content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "route", + body: "API response", + meta: { + status: 200, + headers: { "content-type": "text/plain" }, + }, + }, + lastModified: 1, + }); + + const result = await cacheInterceptor(event); + + expect(result).toEqual( + expect.objectContaining({ + type: "core", + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }) + ); + }); + }); }); diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts index 5ed63aeb..f6076ec3 100644 --- a/packages/tests-unit/tests/overrides/cache/fetch.test.ts +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -10,11 +10,7 @@ function toHeadersMap(headers: Record): Map { return map; } -function mockFetch(resp: { - headers: Record; - body: string; - status?: number; -}) { +function mockFetch(resp: { headers: Record; body: string; status?: number }) { const response = { ok: true, status: resp.status ?? 200, From dfe22c8b67f8861e97d1426c1e25b4f0f570d7fd Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 1 May 2026 17:01:38 +0200 Subject: [PATCH 08/17] Update caching configuration in OpenNext config files to use local cache Co-authored-by: Copilot --- examples/app-pages-router/open-next.config.ts | 11 +++++++++-- examples/app-router/open-next.config.ts | 12 ++++++++++-- examples/experimental/open-next.config.ts | 12 ++++++++++-- examples/pages-router/open-next.config.ts | 12 ++++++++++-- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/examples/app-pages-router/open-next.config.ts b/examples/app-pages-router/open-next.config.ts index c1124cb4..65a9a7e9 100644 --- a/examples/app-pages-router/open-next.config.ts +++ b/examples/app-pages-router/open-next.config.ts @@ -3,9 +3,8 @@ import type { OpenNextConfig, OverrideOptions } from "@opennextjs/core/types/ope const devOverride = { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", + cache: "local", queue: "direct", - tagCache: "fs-dev-nextMode", } satisfies OverrideOptions; export default { @@ -26,6 +25,14 @@ export default { }, loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "fs-dev-nextMode", + }, // You can override the build command here so that you don't have to rebuild next every time you make a change // buildCommand: "echo 'No build command'", } satisfies OpenNextConfig; diff --git a/examples/app-router/open-next.config.ts b/examples/app-router/open-next.config.ts index e52dd92b..1c826183 100644 --- a/examples/app-router/open-next.config.ts +++ b/examples/app-router/open-next.config.ts @@ -5,9 +5,8 @@ export default { override: { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", + cache: "local", queue: "direct", - tagCache: "fs-dev-nextMode", }, }, @@ -23,6 +22,15 @@ export default { loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "fs-dev-nextMode", + }, + // You can override the build command here so that you don't have to rebuild next every time you make a change //buildCommand: "echo 'No build command'", } satisfies OpenNextConfig; diff --git a/examples/experimental/open-next.config.ts b/examples/experimental/open-next.config.ts index b08f90fe..b3444c42 100644 --- a/examples/experimental/open-next.config.ts +++ b/examples/experimental/open-next.config.ts @@ -5,9 +5,8 @@ export default { override: { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", queue: "direct", - tagCache: "fs-dev-nextMode", + cache: "local", }, }, @@ -19,6 +18,15 @@ export default { loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "fs-dev-nextMode", + }, + // You can override the build command here so that you don't have to rebuild next every time you make a change //buildCommand: "echo 'No build command'", } satisfies OpenNextConfig; diff --git a/examples/pages-router/open-next.config.ts b/examples/pages-router/open-next.config.ts index e3fd064f..67cc2788 100644 --- a/examples/pages-router/open-next.config.ts +++ b/examples/pages-router/open-next.config.ts @@ -3,9 +3,8 @@ export default { override: { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", queue: "direct", - tagCache: "dummy", + cache: "local", }, }, @@ -17,6 +16,15 @@ export default { loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "dummy", + }, + // You can override the build command here so that you don't have to rebuild next every time you make a change //buildCommand: "echo 'No build command'", }; From 5d536a44d71cbcdc34529a75f54b337fd50c2af5 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 2 May 2026 14:55:03 +0200 Subject: [PATCH 09/17] Enhance cache handling by adding support for additional tags in fetch and local cache methods Co-authored-by: Copilot --- packages/core/src/adapters/cache-adapter.ts | 32 ++++++++++++++------- packages/core/src/adapters/cache.ts | 8 ++++-- packages/core/src/overrides/cache/fetch.ts | 8 ++++-- packages/core/src/overrides/cache/local.ts | 11 ++++--- packages/core/src/types/overrides.ts | 3 +- packages/core/src/utils/cache.ts | 1 - 6 files changed, 41 insertions(+), 22 deletions(-) diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index 3ffb1ca9..18fdc707 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -84,9 +84,11 @@ async function defaultHandler( const cacheType: CacheEntryType = query?.type === "fetch" ? "fetch" : "cache"; + const additionalTags = query?.tags ? (query.tags as string).split(",") : []; + switch (method) { case "GET": - return await handleGet(key, cacheType); + return await handleGet(key, cacheType, additionalTags); case "PUT": return await handleSet(key, cacheType, body); case "DELETE": @@ -104,8 +106,12 @@ async function defaultHandler( // Route handlers // ////////////////////// -async function handleGet(key: string, cacheType: CacheEntryType): Promise { - debug("get", { key, cacheType }); +async function handleGet( + key: string, + cacheType: CacheEntryType, + additionalTags: string[] +): Promise { + debug("get", { key, cacheType, additionalTags }); try { const result = await globalThis.incrementalCache.get(key, cacheType); @@ -124,16 +130,16 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise)]; } else if (cacheType === "fetch") { const fetchValue = result.value as CachedFetchValue; - tags = fetchValue.tags ?? fetchValue.data?.tags ?? []; + tags = [...tags, ...(fetchValue.tags ?? []), ...(fetchValue.data?.tags ?? [])]; } else if (cacheType === "composable") { const composableValue = result.value as StoredComposableCacheEntry; - tags = composableValue.tags ?? []; + tags = [...tags, ...(composableValue.tags ?? [])]; } const lastModified = result.lastModified ?? Date.now(); @@ -174,12 +180,12 @@ async function checkTagRevalidation( tags: string[], cacheEntry: WithLastModified> ): Promise { - if (globalThis.openNextConfig?.dangerous?.disableTagCache) { + if (globalThis.openNextConfig?.dangerous?.disableTagCache || tags.length === 0) { return false; } const lastModified = cacheEntry.lastModified ?? Date.now(); if (globalThis.tagCache.mode === "nextMode") { - return tags.length > 0 && (await globalThis.tagCache.hasBeenRevalidated(tags, lastModified)); + return globalThis.tagCache.hasBeenRevalidated(tags, lastModified); } const _lastModified = await globalThis.tagCache.getLastModified(key, lastModified); return _lastModified === -1; @@ -209,16 +215,20 @@ async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): // Write tags for non-composable and non-nextMode tag caches const tagCache = globalThis.tagCache; + // TODO: fix this horrible typing if (tagCache.mode !== "nextMode" && !globalThis.openNextConfig?.dangerous?.disableTagCache) { let derivedTags: string[] = []; if (cacheType === "cache") { - const tags = getTagsFromValue(payload.value as Parameters[0]); + const tags = getTagsFromValue(payload.value as CacheValue<"cache">); derivedTags = tags; } else if (cacheType === "fetch") { - const fetchValue = payload.value as Record; + const fetchValue = payload.value as CacheValue<"fetch">; const data = fetchValue.data as Record | undefined; derivedTags = (fetchValue.tags as string[]) ?? (data?.tags as string[]) ?? []; + } else if (cacheType === "composable") { + const composableValue = payload.value as CacheValue<"composable">; + derivedTags = composableValue.tags ?? []; } if (derivedTags.length > 0) { diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index 6fe7627f..6ad0b0f5 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -26,13 +26,15 @@ export default class Cache { return null; } - return isFetchCache(options) ? this.getFetchCache(key) : this.getIncrementalCache(key); + return isFetchCache(options) + ? this.getFetchCache(key, [...(options?.tags ?? []), ...(options?.softTags ?? [])]) + : this.getIncrementalCache(key); } - async getFetchCache(key: string) { + async getFetchCache(key: string, additionalTags: string[] = []): Promise { debug("get fetch cache", { key }); try { - const result = await globalThis.cache.get(key, "fetch"); + const result = await globalThis.cache.get(key, "fetch", additionalTags); if (!result?.value) return null; diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index c3bcf297..b5d53a01 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -5,8 +5,12 @@ const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; const fetchCache: Cache = { name: "fetch-cache", - get: async (key, cacheType) => { - const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${cacheType ? `?type=${cacheType}` : ""}`; + get: async (key, cacheType, additionalTags) => { + const query: Record = {}; + if (cacheType) query.type = cacheType; + if (additionalTags && additionalTags.length > 0) query.tags = additionalTags.join(","); + const queryString = Object.keys(query).length > 0 ? `?${new URLSearchParams(query).toString()}` : ""; + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${queryString}`; const response = await fetch(url, { method: "GET" }); const bodyText = await response.text(); const headers: Record = {}; diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index 543f36b3..089d50bb 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -19,17 +19,20 @@ async function getHandler() { const localCache: Cache = { name: "local-cache", - get: async (key, cacheType) => { + get: async (key, cacheType, additionalTags) => { const h = (await getHandler())!; const encodedKey = encodeURIComponent(key); const url = `https://on/cache/${encodedKey}`; + const query: Record = {}; + if (cacheType) query.type = cacheType; + if (additionalTags && additionalTags.length > 0) query.tags = additionalTags.join(","); const event: InternalEvent = { type: "core", method: "GET", rawPath: `/cache/${encodedKey}`, url, headers: {}, - query: cacheType ? { type: cacheType } : {}, + query, cookies: {}, remoteAddress: "127.0.0.1", }; @@ -71,7 +74,7 @@ const localCache: Cache = { }; await h(event); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { const h = (await getHandler())!; const url = `https://on/cache/revalidate-tags`; const event: InternalEvent = { @@ -83,7 +86,7 @@ const localCache: Cache = { query: {}, cookies: {}, remoteAddress: "127.0.0.1", - body: Buffer.from(JSON.stringify({ tags })), + body: Buffer.from(JSON.stringify({ tags, durations })), }; await h(event); }, diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index 3ca5f964..ae4d079f 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -266,7 +266,8 @@ export type ProxyExternalRequest = BaseOverride & { export type Cache = BaseOverride & { get( key: string, - cacheType?: CacheType + cacheType?: CacheType, + additionalTags?: string[] ): Promise> | null>; set( key: string, diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index b92d9995..6af52bf3 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -102,7 +102,6 @@ export async function writeTags( if (tagsToWrite.length === 0) { return; } - // Here we know that we have the correct type // oxlint-disable-next-line @typescript-eslint/no-explicit-any - writeTags accepts a union type that typescript cannot infer correctly await tagCache.writeTags(tagsToWrite as any); From 8e404841f9402f8e74ad9070ea0f0d5ff7e4d35e Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 2 May 2026 15:24:10 +0200 Subject: [PATCH 10/17] fix composable cache Co-authored-by: Copilot --- packages/core/src/adapters/cache-adapter.ts | 3 ++- packages/core/src/adapters/composable-cache.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index 18fdc707..7aeae153 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -82,7 +82,8 @@ async function defaultHandler( return buildErrorResponse("Missing cache key", 400); } - const cacheType: CacheEntryType = query?.type === "fetch" ? "fetch" : "cache"; + const cacheType: CacheEntryType = + query?.type === "fetch" ? "fetch" : query?.type === "composable" ? "composable" : "cache"; const additionalTags = query?.tags ? (query.tags as string).split(",") : []; diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index cd492982..efa2c5f7 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -100,7 +100,9 @@ export default { return; } try { - await globalThis.cache.revalidateTags(tags, durations); + await globalThis.cache.revalidateTags(tags, { + expire: durations?.expire ? Date.now() + durations.expire * 1000 : undefined, + }); } catch (e) { debug("Failed to update tags", e); } From 8a9bffffb54cff347f10e66096d95b5c013c9421 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 2 May 2026 16:37:23 +0200 Subject: [PATCH 11/17] Fix RequestCache port Co-authored-by: Copilot --- .../src/overrides/tagCache/dynamodb-lite.ts | 128 ++++++++++---- .../overrides/tagCache/dynamodb-nextMode.ts | 159 ++++++++++------- .../aws/src/overrides/tagCache/dynamodb.ts | 163 ++++++++++++------ packages/core/src/types/global.ts | 3 + packages/core/src/utils/promise.ts | 18 +- packages/core/src/utils/requestCache.ts | 55 +++--- 6 files changed, 341 insertions(+), 185 deletions(-) diff --git a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts index bc95337a..417afc38 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts @@ -58,11 +58,19 @@ function buildDynamoKey(key: string) { return path.posix.join(NEXT_BUILD_ID ?? "", key); } -function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) { +function buildDynamoObject( + path: string, + tags: string, + revalidatedAt?: number, + stale?: number, + expire?: number +) { return { path: { S: buildDynamoKey(path) }, tag: { S: buildDynamoKey(tags) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } @@ -74,6 +82,11 @@ const tagCache: OriginalTagCache = { return []; } const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath"); + if (cache?.has(path)) { + return cache.get(path)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -95,7 +108,9 @@ const tagCache: OriginalTagCache = { const tags = Items?.map((item) => item.tag?.S ?? "") ?? []; debug("tags for path", path, tags); // We need to remove the buildId from the path - return tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + const resultTags = tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + cache?.set(path, resultTags); + return resultTags; } catch (e) { error("Failed to get tags by path", e); return []; @@ -107,6 +122,11 @@ const tagCache: OriginalTagCache = { return []; } const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag"); + if (cache?.has(tag)) { + return cache.get(tag)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -123,10 +143,9 @@ const tagCache: OriginalTagCache = { throw new RecoverableError(`Failed to get by tag: ${result.status}`); } const { Items } = (await result.json()) as DynamoDBResponse; - return ( - // We need to remove the buildId from the path - Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [] - ); + const paths = Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []; + cache?.set(tag, paths); + return paths; } catch (e) { error("Failed to get by tag", e); return []; @@ -138,6 +157,12 @@ const tagCache: OriginalTagCache = { return lastModified ?? Date.now(); } const { CACHE_DYNAMO_TABLE } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getLastModified"); + const cacheKey = `${key}:${lastModified ?? 0}`; + if (cache?.has(cacheKey)) { + return cache.get(cacheKey)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -158,51 +183,80 @@ const tagCache: OriginalTagCache = { } const revalidatedTags = ((await result.json()) as DynamoDBResponse).Items ?? []; debug("revalidatedTags", revalidatedTags); - // If we have revalidated tags we return -1 to force revalidation - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + // Check if any tag has expired + const now = Date.now(); + + const hasExpiredTag = revalidatedTags.some((item) => { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + // Exclude expired tags from the revalidated count — they are handled + // separately via hasExpiredTag above. + const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { + if (item.expire?.N) { + return Number.parseInt(item.expire.N) === Number.parseInt(item.revalidatedAt?.N ?? "0"); + } + return true; + }); + // If we have revalidated tags or expired tags we return -1 to force revalidation + const resultValue = + nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); + cache?.set(cacheKey, resultValue); + return resultValue; } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, async isStale(key: string, lastModified?: number) { - if (globalThis.openNextConfig.dangerous?.disableTagCache) { - return false; - } try { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } const { CACHE_DYNAMO_TABLE } = process.env; - const response = await awsFetch( - JSON.stringify({ - TableName: CACHE_DYNAMO_TABLE, - IndexName: "revalidate", - KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", - ExpressionAttributeNames: { - "#key": "path", - "#revalidatedAt": "revalidatedAt", - }, - ExpressionAttributeValues: { - ":key": { S: buildDynamoKey(key) }, - ":lastModified": { N: String(lastModified ?? 0) }, - }, - }) + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" ); - if (response.status !== 200) { - throw new RecoverableError(`Failed to check stale tags: ${response.status}`); - } - const items = ((await response.json()) as DynamoDBResponse).Items ?? []; - return items.some((entry) => { - if (!entry.stale?.N) return false; - return ( - Number.parseInt(entry.revalidatedAt?.N ?? "0") > (lastModified ?? 0) && - Number.parseInt(entry.stale.N) > (lastModified ?? 0) + const cacheKey = `${key}:${lastModified ?? 0}`; + let items: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + items = itemsCache.get(cacheKey)!; + } else { + // We can reuse the same query as getLastModified since it already checks for revalidatedAt > lastModified as revalidatedAt and stale have the same value + const result = await awsFetch( + JSON.stringify({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) ); - }); + if (result.status !== 200) { + throw new RecoverableError(`Failed to check stale tags: ${result.status}`); + } + items = ((await result.json()) as DynamoDBResponse).Items ?? []; + itemsCache?.set(cacheKey, items); + } + debug("isStale items", key, items); + return items.length > 0; } catch (e) { error("Failed to check stale tags", e); return false; } }, - async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]) { + async writeTags(tags) { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -213,7 +267,7 @@ const tagCache: OriginalTagCache = { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({ PutRequest: { Item: { - ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt), + ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire), }, }, })), diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index 95f298fc..2b72d5c8 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -4,26 +4,22 @@ import { debug, error } from "@opennextjs/core/adapters/logger.js"; import { chunk, parseNumberFromEnv } from "@opennextjs/core/adapters/util.js"; import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { RecoverableError } from "@opennextjs/core/utils/error.js"; -import { RequestCache } from "@opennextjs/core/utils/requestCache.js"; import { AwsClient } from "aws4fetch"; import { customFetchClient } from "../../utils/fetch.js"; import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrency } from "./constants.js"; -type DynamoDBTagItem = { - revalidatedAt: { N: string }; - tag: { S: string }; +let awsClient: AwsClient | null = null; + +type DynamoDBItem = { + tag?: { S: string }; + path?: { S: string }; + revalidatedAt?: { N: string }; stale?: { N: string }; expire?: { N: string }; }; -type DynamoDBBatchGetResponse = { - Responses?: Record; -}; - -let awsClient: AwsClient | null = null; - const getAwsClient = () => { const { CACHE_BUCKET_REGION } = process.env; if (awsClient) { @@ -71,14 +67,44 @@ function buildDynamoObject(tag: string, revalidatedAt?: number, stale?: number, }; } -function fetchTagItems(tags: string[]): Promise { - const { CACHE_DYNAMO_TABLE } = process.env; +// This implementation does not support automatic invalidation of paths by the cdn - return awsFetch( +/** + * Checks the items cache for each tag. Returns tags not yet cached and whether + * a positive result was already found among the cached ones. + */ +function checkItemsCache( + tags: string[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): { uncachedTags: string[]; hasMatch: boolean } { + const uncachedTags: string[] = []; + let hasMatch = false; + for (const tag of tags) { + if (itemsCache?.has(tag)) { + if (compute(itemsCache.get(tag)!)) hasMatch = true; + } else { + uncachedTags.push(tag); + } + } + return { uncachedTags, hasMatch }; +} + +/** + * Fetches uncached tags from DynamoDB via BatchGetItem, populates the items + * cache (storing null for absent tags), and returns whether any tag matched. + */ +async function fetchAndCacheItems( + uncachedTags: string[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): Promise { + const { CACHE_DYNAMO_TABLE } = process.env; + const response = await awsFetch( JSON.stringify({ RequestItems: { [CACHE_DYNAMO_TABLE ?? ""]: { - Keys: tags.map((tag) => ({ + Keys: uncachedTags.map((tag) => ({ path: { S: buildDynamoKey(tag) }, tag: { S: buildDynamoKey(tag) }, })), @@ -86,23 +112,29 @@ function fetchTagItems(tags: string[]): Promise { }, }), "query" - ).then(async (response) => { - if (response.status !== 200) { - throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); - } - const { Responses } = (await response.json()) as DynamoDBBatchGetResponse; - return Responses?.[CACHE_DYNAMO_TABLE ?? ""] ?? []; - }); -} + ); + if (response.status !== 200) { + throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); + } + const { Responses } = await response.json(); + const responseItems: DynamoDBItem[] = Responses?.[CACHE_DYNAMO_TABLE ?? ""] ?? []; -const requestCache = new RequestCache(); + // Build a lookup map: DynamoDB key → item + const responseByKey = new Map(); + for (const item of responseItems) { + responseByKey.set(item.tag?.S ?? "", item); + } -function getCachedTagItems(tags: string[]): Promise { - const cacheKey = [...tags].sort().join(","); - return requestCache.getOrSet(cacheKey, () => fetchTagItems(tags)); + let hasMatch = false; + for (const tag of uncachedTags) { + const item = responseByKey.get(buildDynamoKey(tag)) ?? null; + if (!item) continue; + itemsCache?.set(tag, item); + if (compute(item)) hasMatch = true; + } + return hasMatch; } -// This implementation does not support automatic invalidation of paths by the cdn export default { name: "ddb-nextMode", mode: "nextMode", @@ -119,71 +151,78 @@ export default { "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" ); } - const items = await getCachedTagItems(tags); + + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); const now = Date.now(); - const revalidatedTags = items.filter((item) => { - const revalidatedAt = Number.parseInt(item.revalidatedAt.N); - if (revalidatedAt > (lastModified ?? 0)) { - return true; - } - // If the tag has expired (expire time is in the past), it counts as revalidated + const compute = (item: DynamoDBItem): boolean => { + if (!item) return false; if (item.expire?.N) { - const expireTime = Number.parseInt(item.expire.N); - if (expireTime <= now && expireTime > (lastModified ?? 0)) { - return true; - } + const expiry = Number.parseInt(item.expire.N); + if (expiry <= now && expiry > (lastModified ?? 0)) return true; } - return false; - }); - debug("retrieved tags", revalidatedTags); - return revalidatedTags.length > 0; + return Number.parseInt(item.revalidatedAt?.N ?? "0") > (lastModified ?? 0); + }; + + const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); + if (hasMatch) return true; + if (uncachedTags.length === 0) return false; + + // It's unlikely that we will have more than 100 items to query + // If that's the case, you should not use this tagCache implementation + const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute); + debug("retrieved tags for hasBeenRevalidated", tags); + return result; }, isStale: async (tags: string[], lastModified?: number) => { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return false; } - if (tags.length === 0) { - return false; - } + if (tags.length === 0) return false; if (tags.length > 100) { throw new RecoverableError( "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" ); } - const items = await getCachedTagItems(tags); - const hasStaleTag = items.some((item) => { + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + + const compute = (item: DynamoDBItem): boolean => { if (!item?.stale?.N) return false; const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. // revalidatedAt > lastModified ensures the revalidation that set this stale window happened // after the page was generated, preventing a stale signal from a previous ISR cycle. return revalidatedAt > (lastModified ?? 0) && Number.parseInt(item.stale.N) >= (lastModified ?? 0); - }); - debug("isStale result:", hasStaleTag); - return hasStaleTag; + }; + + const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); + if (hasMatch) return true; + if (uncachedTags.length === 0) return false; + + const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute); + debug("isStale result:", result); + return result; }, - writeTags: async (tags: (string | NextModeTagCacheWriteInput)[]) => { + writeTags: async (tags) => { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { return; } - const now = Date.now(); const dataChunks = chunk(tags, MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT).map((Items) => ({ RequestItems: { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => { - if (typeof tag === "string") { - return { - PutRequest: { - Item: buildDynamoObject(tag, now), - }, - }; - } + const tagStr = typeof tag === "string" ? tag : tag.tag; + const stale = typeof tag === "string" ? undefined : tag.stale; + const expiry = typeof tag === "string" ? undefined : tag.expire; return { PutRequest: { - Item: buildDynamoObject(tag.tag, now, tag.stale, tag.expire), + Item: { + ...buildDynamoObject(tagStr, undefined, stale, expiry), + }, }, }; }), diff --git a/packages/aws/src/overrides/tagCache/dynamodb.ts b/packages/aws/src/overrides/tagCache/dynamodb.ts index 7e90f007..2203dbc0 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb.ts @@ -10,6 +10,14 @@ import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrenc const { CACHE_BUCKET_REGION, CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; +type DynamoDBItem = { + tag?: { S: string }; + path?: { S: string }; + revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; +}; + function parseDynamoClientConfigFromEnv(): DynamoDBClientConfig { return { region: CACHE_BUCKET_REGION, @@ -26,11 +34,19 @@ function buildDynamoKey(key: string) { return path.posix.join(NEXT_BUILD_ID ?? "", key); } -function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) { +function buildDynamoObject( + path: string, + tags: string, + revalidatedAt?: number, + stale?: number, + expire?: number +) { return { path: { S: buildDynamoKey(path) }, tag: { S: buildDynamoKey(tags) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } @@ -41,6 +57,11 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return []; } + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath"); + if (cache?.has(path)) { + return cache.get(path)!; + } const result = await dynamoClient.send( new QueryCommand({ TableName: CACHE_DYNAMO_TABLE, @@ -57,7 +78,9 @@ const tagCache: TagCache = { const tags = result.Items?.map((item) => item.tag.S ?? "") ?? []; debug("tags for path", path, tags); // We need to remove the buildId from the path - return tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + const resultTags = tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + cache?.set(path, resultTags); + return resultTags; } catch (e) { error("Failed to get tags by path", e); return []; @@ -68,6 +91,11 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return []; } + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag"); + if (cache?.has(tag)) { + return cache.get(tag)!; + } const { Items } = await dynamoClient.send( new QueryCommand({ TableName: CACHE_DYNAMO_TABLE, @@ -80,10 +108,10 @@ const tagCache: TagCache = { }, }) ); - return ( - // We need to remove the buildId from the path - Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [] - ); + // We need to remove the buildId from the path + const paths = Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []; + cache?.set(tag, paths); + return paths; } catch (e) { error("Failed to get by tag", e); return []; @@ -94,57 +122,94 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return lastModified ?? Date.now(); } - const result = await dynamoClient.send( - new QueryCommand({ - TableName: CACHE_DYNAMO_TABLE, - IndexName: "revalidate", - KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", - ExpressionAttributeNames: { - "#key": "path", - "#revalidatedAt": "revalidatedAt", - }, - ExpressionAttributeValues: { - ":key": { S: buildDynamoKey(key) }, - ":lastModified": { N: String(lastModified ?? 0) }, - }, - }) + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" ); - const revalidatedTags = result.Items ?? []; + const cacheKey = `${key}:${lastModified ?? 0}`; + let revalidatedTags: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + revalidatedTags = itemsCache.get(cacheKey)!; + } else { + const result = await dynamoClient.send( + new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + revalidatedTags = result.Items ?? []; + itemsCache?.set(cacheKey, revalidatedTags); + } debug("revalidatedTags", revalidatedTags); - // If we have revalidated tags we return -1 to force revalidation - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + // Check if any tag has expired + const now = Date.now(); + const hasExpiredTag = revalidatedTags.some((item) => { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + // Exclude expired tags from the revalidated count — they are handled + // separately via hasExpiredTag above. + const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { + if (item.expire?.N) { + return Number.parseInt(item.expire.N) > now; + } + return true; + }); + + // If we have revalidated tags or expired tags we return -1 to force revalidation + return nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, - async isStale(key, lastModified) { - if (globalThis.openNextConfig.dangerous?.disableTagCache) { - return false; - } + async isStale(key: string, lastModified?: number) { try { - const command = new QueryCommand({ - TableName: CACHE_DYNAMO_TABLE, - IndexName: "revalidate", - KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", - ExpressionAttributeNames: { - "#key": "path", - "#revalidatedAt": "revalidatedAt", - }, - ExpressionAttributeValues: { - ":key": { S: buildDynamoKey(key) }, - ":lastModified": { N: String(lastModified ?? 0) }, - }, - }); - const result = await dynamoClient.send(command); - const items = result.Items ?? []; - return items.some((item) => { - if (!item.stale?.N) return false; - return ( - Number.parseInt(item.revalidatedAt?.N ?? "0") > (lastModified ?? 0) && - Number.parseInt(item.stale.N) > (lastModified ?? 0) + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" + ); + const cacheKey = `${key}:${lastModified ?? 0}`; + let items: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + items = itemsCache.get(cacheKey)!; + } else { + const result = await dynamoClient.send( + new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) ); - }); + items = result.Items ?? []; + itemsCache?.set(cacheKey, items); + } + debug("isStale items", key, items); + return items.length > 0; } catch (e) { error("Failed to check stale tags", e); return false; @@ -160,7 +225,7 @@ const tagCache: TagCache = { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({ PutRequest: { Item: { - ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt), + ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire), }, }, })), diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 24f6a0e1..6027798d 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -12,6 +12,7 @@ import type { } from "@/types/overrides"; import type { DetachedPromiseRunner } from "../utils/promise"; +import type { RequestCache } from "../utils/requestCache"; import type { i18nConfig } from "./next-types.js"; import type { OpenNextConfig, WaitUntil } from "./open-next"; @@ -69,6 +70,8 @@ interface OpenNextRequestContext { waitUntil?: WaitUntil; /** We use this to deduplicate write of the tags*/ writtenTags: Set; + /** Per-request in-memory cache. Overrides can use this to store data scoped to the current request. */ + requestCache: RequestCache; } declare global { diff --git a/packages/core/src/utils/promise.ts b/packages/core/src/utils/promise.ts index 00602ce0..1dbc4df6 100644 --- a/packages/core/src/utils/promise.ts +++ b/packages/core/src/utils/promise.ts @@ -1,6 +1,7 @@ import type { WaitUntil } from "@/types/open-next"; import { debug, error } from "../adapters/logger"; +import { RequestCache } from "./requestCache"; /** * A `Promise.withResolvers` implementation that exposes the `resolve` and @@ -113,14 +114,15 @@ export function runWithOpenNextRequestContext( }, fn: () => Promise ): Promise { - return globalThis.__openNextAls.run( - { - requestId, - pendingPromiseRunner: new DetachedPromiseRunner(), - isISRRevalidation, - waitUntil, - writtenTags: new Set(), - }, + return globalThis.__openNextAls.run( + { + requestId, + pendingPromiseRunner: new DetachedPromiseRunner(), + isISRRevalidation, + waitUntil, + writtenTags: new Set(), + requestCache: new RequestCache(), + }, async () => { provideNextAfterProvider(); let result: T; diff --git a/packages/core/src/utils/requestCache.ts b/packages/core/src/utils/requestCache.ts index 8853136d..20b6ffd9 100644 --- a/packages/core/src/utils/requestCache.ts +++ b/packages/core/src/utils/requestCache.ts @@ -1,35 +1,28 @@ /** - * A simple utility to cache values scoped to a request. - * It uses our internal AsyncLocalStorage (globalThis.__openNextAls) to store the cache. + * A per-request cache that provides named Map instances. + * Overrides can use this to store and share data within the scope of a single request + * without polluting global state. * - * This is useful for deduplicating operations within the same request, - * such as DynamoDB queries for tag cache lookups. + * Retrieve it from the ALS context: + * ```ts + * const store = globalThis.__openNextAls.getStore(); + * const myMap = store?.requestCache.getOrCreate("my-override"); + * ``` */ -export class RequestCache { - getOrSet(key: K, factory: () => Promise): Promise { - const store = globalThis.__openNextAls.getStore(); - if (!store) { - return factory(); - } - // We use "requestCache" as a property on the store - // and lazily initialize a Map for each cache instance - // oxlint-disable-next-line @typescript-eslint/no-explicit-any - const reqCache = (store as any).requestCache as Map, Map>> | undefined; - if (!reqCache) { - // oxlint-disable-next-line @typescript-eslint/no-explicit-any - (store as any).requestCache = new Map(); - } - // oxlint-disable-next-line @typescript-eslint/no-explicit-any - const cache = (store as any).requestCache as Map, Map>>; - if (!cache.has(this)) { - cache.set(this, new Map()); - } - const innerCache = cache.get(this)!; - if (innerCache.has(key)) { - return innerCache.get(key)!; - } - const promise = factory(); - innerCache.set(key, promise); - return promise; - } +export class RequestCache { + private _caches = new Map>(); + + /** + * Returns the Map registered under `key`. + * If no Map exists yet for that key, a new empty Map is created, stored, and returned. + * Repeated calls with the same key always return the **same** Map instance. + */ + getOrCreate(key: string): Map { + let cache = this._caches.get(key) as Map | undefined; + if (!cache) { + cache = new Map(); + this._caches.set(key, cache); + } + return cache; + } } From 32d5fe90033aa70615e0a01a5b2ae65c41861d21 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 16 Aug 2026 11:58:48 +0200 Subject: [PATCH 12/17] feat(cache): introduce service cache handler and integrate with Cloudflare --- .changeset/cloudflare-cache-service.md | 29 +++++ create-cloudflare/next/wrangler.jsonc | 8 ++ .../e2e/app-pages-router/wrangler.jsonc | 5 + .../e2e/app-router/package.json | 2 +- .../e2e/app-router/wrangler.jsonc | 5 + .../e2e/experimental/wrangler.jsonc | 5 + .../e2e/pages-router/wrangler.jsonc | 5 + .../overrides/d1-tag-next/wrangler.e2e.jsonc | 7 ++ .../overrides/kv-tag-next/wrangler.e2e.jsonc | 7 ++ .../overrides/memory-queue/wrangler.jsonc | 5 + .../r2-incremental-cache/wrangler.jsonc | 7 ++ .../wrangler.jsonc | 9 +- .../playground16/wrangler.jsonc | 5 + examples-cloudflare/prisma/wrangler.jsonc | 7 ++ .../cloudflare/src/api/cloudflare-context.ts | 50 ++++++--- packages/cloudflare/src/api/config.ts | 13 ++- .../api/overrides/cache/service-cache.spec.ts | 102 ++++++++++++++++++ .../src/api/overrides/cache/service-cache.ts | 94 ++++++++++++++++ .../cloudflare/src/api/overrides/internal.ts | 19 +++- .../tag-cache/d1-next-tag-cache.spec.ts | 3 +- .../overrides/tag-cache/d1-next-tag-cache.ts | 13 ++- .../tag-cache/do-sharded-tag-cache.ts | 7 +- .../tag-cache/kv-next-tag-cache.spec.ts | 3 +- .../overrides/tag-cache/kv-next-tag-cache.ts | 13 ++- packages/cloudflare/src/cli/adapter.ts | 33 ++++++ .../compile-cache-assets-manifest.ts | 25 ----- .../open-next/compile-cache-entrypoint.ts | 29 +++++ .../src/cli/build/utils/ensure-cf-config.ts | 23 ++-- .../src/cli/commands/populate-cache.spec.ts | 12 +-- .../src/cli/commands/populate-cache.ts | 2 +- .../src/cli/commands/utils/helpers.ts | 32 ++++-- .../src/cli/templates/cache-entrypoint.ts | 27 +++++ packages/cloudflare/src/cli/templates/init.ts | 63 +++++++---- .../cloudflare/src/cli/templates/worker.ts | 2 + .../cloudflare/src/utils/wrangler-config.ts | 48 +++++++++ packages/cloudflare/templates/wrangler.jsonc | 8 ++ packages/core/src/utils/promise.ts | 19 ++-- packages/core/src/utils/requestCache.ts | 28 ++--- 38 files changed, 645 insertions(+), 129 deletions(-) create mode 100644 .changeset/cloudflare-cache-service.md create mode 100644 packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts create mode 100644 packages/cloudflare/src/api/overrides/cache/service-cache.ts delete mode 100644 packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts create mode 100644 packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts create mode 100644 packages/cloudflare/src/cli/templates/cache-entrypoint.ts create mode 100644 packages/cloudflare/src/utils/wrangler-config.ts diff --git a/.changeset/cloudflare-cache-service.md b/.changeset/cloudflare-cache-service.md new file mode 100644 index 00000000..6cc63787 --- /dev/null +++ b/.changeset/cloudflare-cache-service.md @@ -0,0 +1,29 @@ +--- +"@opennextjs/cloudflare": minor +--- + +Move the cache behind a dedicated cache handler function + +The incremental cache and the tag cache no longer run inside the server function. They now run in +the cache handler function, bundled to `.open-next/cache-function` as a fetch handler and served by +the worker through the `OpenNextCache` named entrypoint. The server and the middleware reach it over +a service binding. + +This requires a new self referencing service binding in the wrangler configuration: + +```jsonc +"services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "", + "entrypoint": "OpenNextCache" + } +] +``` + +The cache runs in the same worker by default. Pointing the binding at another worker is enough to +run the cache as a service of its own. + +`defineCloudflareConfig` is otherwise unchanged. Configurations that are not created by +`defineCloudflareConfig` should move `incrementalCache` and `tagCache` from `default.override` to +the new top level `cacheHandler` option, and set `default.override.cache`. diff --git a/create-cloudflare/next/wrangler.jsonc b/create-cloudflare/next/wrangler.jsonc index 1c90bdb4..0819a091 100644 --- a/create-cloudflare/next/wrangler.jsonc +++ b/create-cloudflare/next/wrangler.jsonc @@ -23,6 +23,14 @@ // see https://opennext.js.org/cloudflare/caching "binding": "WORKER_SELF_REFERENCE", "service": "worker_name" + }, + { + // The OpenNext cache runs behind a named entrypoint of this worker. + // The service name must match the worker name. + // see https://opennext.js.org/cloudflare/caching + "binding": "NEXT_CACHE_SERVICE", + "service": "worker_name", + "entrypoint": "OpenNextCache" } ], "observability": { diff --git a/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc b/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc index b636b43a..1ab5ec7f 100644 --- a/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc @@ -18,6 +18,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "app-pages-router" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "app-pages-router", + "entrypoint": "OpenNextCache" } ], "vars": { diff --git a/examples-cloudflare/e2e/app-router/package.json b/examples-cloudflare/e2e/app-router/package.json index d1924fbb..3bab5883 100644 --- a/examples-cloudflare/e2e/app-router/package.json +++ b/examples-cloudflare/e2e/app-router/package.json @@ -11,7 +11,7 @@ "clean": "rm -rf .turbo node_modules .next .open-next", "build:worker:cf": "pnpm opennextjs-cloudflare build", "preview:worker": "pnpm opennextjs-cloudflare preview", - "preview": "pnpm build:worker && pnpm preview:worker", + "preview": "pnpm build:worker:cf && pnpm preview:worker", "e2e:cf": "playwright test -c e2e/playwright.config.ts", "build:worker-turbopack": "pnpm build:worker --openNextConfigPath open-next.turbopack.config.ts", "e2e-turbopack": "playwright test -c e2e/playwright.turbopack.config.ts" diff --git a/examples-cloudflare/e2e/app-router/wrangler.jsonc b/examples-cloudflare/e2e/app-router/wrangler.jsonc index 6ec6cc0d..eaf0fced 100644 --- a/examples-cloudflare/e2e/app-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/app-router/wrangler.jsonc @@ -40,6 +40,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "app-router" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "app-router", + "entrypoint": "OpenNextCache" } ], "vars": { diff --git a/examples-cloudflare/e2e/experimental/wrangler.jsonc b/examples-cloudflare/e2e/experimental/wrangler.jsonc index 4348c424..0ca406a9 100644 --- a/examples-cloudflare/e2e/experimental/wrangler.jsonc +++ b/examples-cloudflare/e2e/experimental/wrangler.jsonc @@ -39,6 +39,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "experimental" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "experimental", + "entrypoint": "OpenNextCache" } ] } diff --git a/examples-cloudflare/e2e/pages-router/wrangler.jsonc b/examples-cloudflare/e2e/pages-router/wrangler.jsonc index 80f9372b..92f56c40 100644 --- a/examples-cloudflare/e2e/pages-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/pages-router/wrangler.jsonc @@ -18,6 +18,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "pages-router" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "pages-router", + "entrypoint": "OpenNextCache" } ], "vars": { diff --git a/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc b/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc index 1fe491ce..5d53c198 100644 --- a/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc +++ b/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS", }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "ssg-app", + "entrypoint": "OpenNextCache", + }, + ], "vars": { "APP_VERSION": "1.2.345", }, diff --git a/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc b/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc index 0f8b5557..bbc50d20 100644 --- a/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc +++ b/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS", }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "ssg-app", + "entrypoint": "OpenNextCache", + }, + ], "vars": { "APP_VERSION": "1.2.345", }, diff --git a/examples-cloudflare/overrides/memory-queue/wrangler.jsonc b/examples-cloudflare/overrides/memory-queue/wrangler.jsonc index ccb375df..1cd31f32 100644 --- a/examples-cloudflare/overrides/memory-queue/wrangler.jsonc +++ b/examples-cloudflare/overrides/memory-queue/wrangler.jsonc @@ -18,6 +18,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "memory-queue" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "memory-queue", + "entrypoint": "OpenNextCache" } ] } diff --git a/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc b/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc index 3fb880df..6f61040f 100644 --- a/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc +++ b/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "r2-incremental-cache", + "entrypoint": "OpenNextCache" + } + ], "env": { "e2e": { "d1_databases": [ diff --git a/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc b/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc index 9adf243a..817baad5 100644 --- a/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc +++ b/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc @@ -7,5 +7,12 @@ "assets": { "directory": ".open-next/assets", "binding": "ASSETS" - } + }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "static-assets-incremental-cache", + "entrypoint": "OpenNextCache" + } + ] } diff --git a/examples-cloudflare/playground16/wrangler.jsonc b/examples-cloudflare/playground16/wrangler.jsonc index e8506d3c..3dfe7a54 100644 --- a/examples-cloudflare/playground16/wrangler.jsonc +++ b/examples-cloudflare/playground16/wrangler.jsonc @@ -21,6 +21,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "playground16" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "playground16", + "entrypoint": "OpenNextCache" } ], "durable_objects": { diff --git a/examples-cloudflare/prisma/wrangler.jsonc b/examples-cloudflare/prisma/wrangler.jsonc index 5c242191..7f9dd8c1 100644 --- a/examples-cloudflare/prisma/wrangler.jsonc +++ b/examples-cloudflare/prisma/wrangler.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "api", + "entrypoint": "OpenNextCache" + } + ], "d1_databases": [ { "binding": "DB", diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index d48e4a50..2c745a8f 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -24,6 +24,12 @@ declare global { // Service binding for the worker itself to be able to call itself from within the worker WORKER_SELF_REFERENCE?: Service; + // Optional service binding to a worker where the OpenNext cache is deployed on its own, + // exposed by its `OpenNextCache` named entrypoint. When unset, the cache runs in this worker. + // Note: it can not reference the worker itself, wrangler can not resolve a named entrypoint + // of the worker being configured. + NEXT_CACHE_SERVICE?: Service; + // KV used for the incremental cache NEXT_INC_CACHE_KV?: KVNamespace; // Prefix used for the KV incremental cache key @@ -337,24 +343,40 @@ async function getCloudflareContextFromWrangler< Context = ExecutionContext, >(options?: GetPlatformProxyOptions): Promise> { // Note: we never want wrangler to be bundled in the Next.js app, that's why the import below looks like it does - const { getPlatformProxy } = await import(/* webpackIgnore: true */ `${"__wrangler".replaceAll("_", "")}`); + const { getPlatformProxy, unstable_readConfig } = await import( + /* webpackIgnore: true */ `${"__wrangler".replaceAll("_", "")}` + ); + + // Same as above: this helper uses node builtins and is only ever needed when running `next dev`. + const { withoutSelfEntrypointServices } = await import( + /* webpackIgnore: true */ `${"../utils/wrangler__config.js".replaceAll("__", "-")}` + ); // This allows the selection of a wrangler environment while running in next dev mode const environment = options?.environment ?? process.env.NEXT_DEV_WRANGLER_ENV; - const { env, cf, ctx } = await getPlatformProxy({ - ...options, - // The `env` passed to the fetch handler does not contain variables from `.env*` files. - // because we invoke wrangler with `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV`=`"false"`. - // Initializing `envFiles` with an empty list is the equivalent for this API call. - envFiles: [], - environment, - }); - return { - env, - cf: cf as unknown as CfProperties, - ctx: ctx as Context, - }; + const { configPath, cleanup } = withoutSelfEntrypointServices( + unstable_readConfig({ env: environment, config: options?.configPath }) + ); + + try { + const { env, cf, ctx } = await getPlatformProxy({ + ...options, + configPath, + // The `env` passed to the fetch handler does not contain variables from `.env*` files. + // because we invoke wrangler with `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV`=`"false"`. + // Initializing `envFiles` with an empty list is the equivalent for this API call. + envFiles: [], + environment, + }); + return { + env, + cf: cf as unknown as CfProperties, + ctx: ctx as Context, + }; + } finally { + cleanup(); + } } // In production the cloudflare context is initialized by the worker so it is always available. diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index 32d9be73..04279127 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -13,6 +13,7 @@ import type { } from "@opennextjs/core/types/overrides.js"; import assetResolver from "./overrides/asset-resolver/index.js"; +import serviceCache from "./overrides/cache/service-cache.js"; export type Override = "dummy" | T | LazyLoadedOverride; @@ -65,13 +66,18 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe wrapper: "cloudflare-node", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: resolveIncrementalCache(incrementalCache), - tagCache: resolveTagCache(tagCache), + cache: () => serviceCache, queue: resolveQueue(queue), cdnInvalidation: resolveCdnInvalidation(cachePurge), }, routePreloadingBehavior, }, + // The cache runs in the same worker, behind the `OpenNextCache` named entrypoint. + cacheHandler: { + incrementalCache: resolveIncrementalCache(incrementalCache), + tagCache: resolveTagCache(tagCache), + cdnInvalidation: resolveCdnInvalidation(cachePurge), + }, // node:crypto is used to compute cache keys edgeExternals: ["node:crypto"], cloudflare: { @@ -83,8 +89,7 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe wrapper: "cloudflare-edge", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: resolveIncrementalCache(incrementalCache), - tagCache: resolveTagCache(tagCache), + cache: () => serviceCache, queue: resolveQueue(queue), }, assetResolver: () => assetResolver, diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts new file mode 100644 index 00000000..2ad47c58 --- /dev/null +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import serviceCache, { BINDING_NAME } from "./service-cache.js"; + +const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>(); +const env: Record = {}; + +vi.mock("../../cloudflare-context.js", () => ({ + getCloudflareContext: () => ({ env }), +})); + +function lastRequest() { + const [url, init] = fetchMock.mock.calls.at(-1)!; + return { url: new URL(url), method: init?.method ?? "GET", body: init?.body }; +} + +describe("serviceCache", () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response("", { headers: { "x-opennext-cache-found": "false" } })); + env[BINDING_NAME] = { fetch: fetchMock }; + }); + + it("throws when the service is not bound", async () => { + delete env[BINDING_NAME]; + + await expect(serviceCache.get("key")).rejects.toThrow(BINDING_NAME); + }); + + describe("get", () => { + it("requests the key, the cache type and the additional tags", async () => { + await serviceCache.get("key/with/slashes", "fetch", ["tag1", "tag2"]); + + const { url, method } = lastRequest(); + expect(method).toBe("GET"); + expect(url.pathname).toBe(`/cache/${encodeURIComponent("key/with/slashes")}`); + expect(url.searchParams.get("type")).toBe("fetch"); + expect(url.searchParams.get("tags")).toBe("tag1,tag2"); + }); + + it("omits the tags when there is none", async () => { + await serviceCache.get("key", "cache", []); + + expect(lastRequest().url.searchParams.has("tags")).toBe(false); + }); + + it("returns null on a cache miss", async () => { + await expect(serviceCache.get("key")).resolves.toBeNull(); + }); + + it("parses a cache hit", async () => { + fetchMock.mockResolvedValue( + new Response("body", { + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1234", + }, + }) + ); + + await expect(serviceCache.get("key")).resolves.toEqual({ + lastModified: 1234, + value: expect.objectContaining({ type: "route", body: "body" }), + }); + }); + }); + + describe("set", () => { + // The cache type is part of the key for the incremental caches, it has to be forwarded + // or entries would be written where they are not read from. + it("sends the value and the cache type", async () => { + await serviceCache.set("key", { kind: "FETCH", data: { headers: {}, body: "b", url: "u" } }, "fetch"); + + const { url, method, body } = lastRequest(); + expect(method).toBe("PUT"); + expect(url.pathname).toBe("/cache/key"); + expect(url.searchParams.get("type")).toBe("fetch"); + expect(JSON.parse(body as string)).toEqual({ + value: { kind: "FETCH", data: { headers: {}, body: "b", url: "u" } }, + }); + }); + }); + + it("deletes a key", async () => { + await serviceCache.delete("key"); + + const { url, method } = lastRequest(); + expect(method).toBe("DELETE"); + expect(url.pathname).toBe("/cache/key"); + }); + + it("revalidates tags", async () => { + await serviceCache.revalidateTags(["tag1", "tag2"], { expire: 10 }); + + const { url, method, body } = lastRequest(); + expect(method).toBe("POST"); + expect(url.pathname).toBe("/cache/revalidate-tags"); + expect(JSON.parse(body as string)).toEqual({ tags: ["tag1", "tag2"], durations: { expire: 10 } }); + }); +}); diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts new file mode 100644 index 00000000..02b3174e --- /dev/null +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -0,0 +1,94 @@ +import type { Cache, CacheEntryType } from "@opennextjs/core/types/overrides.js"; +import { parseCacheGetResponse } from "@opennextjs/core/utils/cache-get.js"; + +import { getCloudflareContext } from "../../cloudflare-context.js"; + +export const NAME = "cf-service-cache"; + +export const BINDING_NAME = "NEXT_CACHE_SERVICE"; + +/** + * The origin is irrelevant: the requests are sent to the service binding, they never hit the network. + */ +const CACHE_ORIGIN = "https://cache.opennext"; + +/** + * Returns the cache handler bound to `NEXT_CACHE_SERVICE`. + * + * The binding points at the worker itself by default: the cache handler runs in the same worker, + * behind the `OpenNextCache` named entrypoint. It can be pointed at another worker to run the + * cache as a service of its own. + */ +function getCacheService(): Service { + const service = getCloudflareContext().env[BINDING_NAME]; + + if (!service) { + throw new Error( + `No \`${BINDING_NAME}\` service binding for the OpenNext cache.\n\n` + + `Add the following to your wrangler configuration:\n\n` + + ` "services": [\n` + + ` { "binding": "${BINDING_NAME}", "service": "", "entrypoint": "OpenNextCache" }\n` + + ` ]\n` + ); + } + + return service; +} + +function getCacheUrl(key: string, cacheType?: CacheEntryType, additionalTags?: string[]) { + const url = new URL(`/cache/${encodeURIComponent(key)}`, CACHE_ORIGIN); + + if (cacheType) { + url.searchParams.set("type", cacheType); + } + if (additionalTags && additionalTags.length > 0) { + url.searchParams.set("tags", additionalTags.join(",")); + } + + return url.href; +} + +/** + * Cache client for the cache handler function. + * + * It talks to the `OpenNextCache` entrypoint over the service binding, using the HTTP API of + * the cache handler function. + */ +const serviceCache = { + name: NAME, + + get: async (key, cacheType, additionalTags) => { + const response = await getCacheService().fetch(getCacheUrl(key, cacheType, additionalTags)); + + const body = await response.text(); + const headers: Record = {}; + response.headers.forEach((value, name) => { + headers[name] = value; + }); + + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + return parseCacheGetResponse(headers, body) as any; + }, + + set: async (key, value, cacheType) => { + await getCacheService().fetch(getCacheUrl(key, cacheType), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + }, + + delete: async (key) => { + await getCacheService().fetch(getCacheUrl(key), { method: "DELETE" }); + }, + + revalidateTags: async (tags, durations) => { + await getCacheService().fetch(new URL("/cache/revalidate-tags", CACHE_ORIGIN).href, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags, durations }), + }); + }, +} satisfies Cache; + +export default serviceCache; diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index a4f78b12..9b6c6d70 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -1,7 +1,11 @@ import { createHash } from "node:crypto"; import { error } from "@opennextjs/core/adapters/logger.js"; -import type { CacheEntryType, CacheValue } from "@opennextjs/core/types/overrides.js"; +import type { + CacheEntryType, + CacheValue, + NextModeTagCacheWriteInput, +} from "@opennextjs/core/types/overrides.js"; import { getCloudflareContext } from "../cloudflare-context.js"; @@ -32,9 +36,20 @@ export function computeCacheKey(key: string, options: KeyOptions) { return `${prefix}/${buildId}/${hash}.${cacheType}`.replace(/\/+/g, "/"); } +/** + * `writeTags` accepts either plain tag names or objects carrying the `stale`/`expire` durations. + * The Cloudflare tag caches do not support durations, they only need the names. + */ +export function toTagNames(tags: (string | NextModeTagCacheWriteInput)[]): string[] { + return tags.map((tag) => (typeof tag === "string" ? tag : tag.tag)); +} + export function isPurgeCacheEnabled(): boolean { // The `?` is required at `openNextConfig?` or the Open Next build fails because of a type error - const cdnInvalidation = globalThis.openNextConfig?.default?.override?.cdnInvalidation; + // The cache handler function only has `cacheHandler` populated, the other functions only have `default`. + const cdnInvalidation = + globalThis.openNextConfig?.cacheHandler?.cdnInvalidation ?? + globalThis.openNextConfig?.default?.override?.cdnInvalidation; return cdnInvalidation !== undefined && cdnInvalidation !== "dummy"; } diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts index 87aae85f..9bb43721 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts @@ -18,7 +18,8 @@ vi.mock("../../cloudflare-context.js", () => ({ getCloudflareContext: vi.fn(), })); -vi.mock("../internal.js", () => ({ +vi.mock("../internal.js", async (importOriginal) => ({ + ...(await importOriginal()), debugCache: vi.fn(), FALLBACK_BUILD_ID: "fallback-build-id", purgeCacheByTags: vi.fn(), diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts index 018ef92e..ad3987f2 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts @@ -1,8 +1,14 @@ import { error } from "@opennextjs/core/adapters/logger.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; -import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; +import { + debugCache, + FALLBACK_BUILD_ID, + isPurgeCacheEnabled, + purgeCacheByTags, + toTagNames, +} from "../internal.js"; export const NAME = "d1-next-mode-tag-cache"; @@ -63,7 +69,8 @@ export class D1NextModeTagCache implements NextModeTagCache { } } - async writeTags(tags: string[]): Promise { + async writeTags(tagsToWrite: (string | NextModeTagCacheWriteInput)[]): Promise { + const tags = toTagNames(tagsToWrite); const { isDisabled, db } = this.getConfig(); if (isDisabled || tags.length === 0) return Promise.resolve(); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts index cf9462af..1c625e76 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts @@ -1,12 +1,12 @@ import { debug, error } from "@opennextjs/core/adapters/logger.js"; import { generateShardId } from "@opennextjs/core/core/routing/queue.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { IgnorableError } from "@opennextjs/core/utils/error.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; import type { OpenNextConfig } from "../../config.js"; import { DOShardedTagCache } from "../../durable-objects/sharded-tag-cache.js"; -import { debugCache, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; +import { debugCache, isPurgeCacheEnabled, purgeCacheByTags, toTagNames } from "../internal.js"; export const DEFAULT_WRITE_RETRIES = 3; export const DEFAULT_NUM_SHARDS = 4; @@ -232,7 +232,8 @@ class ShardedDOTagCache implements NextModeTagCache { * @param tags * @returns */ - public async writeTags(tags: string[]): Promise { + public async writeTags(tagsToWrite: (string | NextModeTagCacheWriteInput)[]): Promise { + const tags = toTagNames(tagsToWrite); const { isDisabled } = this.getConfig(); if (isDisabled) return; diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts index 7a01d1c6..7fd8836e 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts @@ -15,7 +15,8 @@ vi.mock("../../cloudflare-context.js", () => ({ getCloudflareContext: vi.fn(), })); -vi.mock("../internal.js", () => ({ +vi.mock("../internal.js", async (importOriginal) => ({ + ...(await importOriginal()), debugCache: vi.fn(), FALLBACK_BUILD_ID: "fallback-build-id", purgeCacheByTags: vi.fn(), diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts index 6c4a79ad..7bd2a116 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts @@ -1,8 +1,14 @@ import { error } from "@opennextjs/core/adapters/logger.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; -import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; +import { + debugCache, + FALLBACK_BUILD_ID, + isPurgeCacheEnabled, + purgeCacheByTags, + toTagNames, +} from "../internal.js"; export const NAME = "kv-next-mode-tag-cache"; @@ -65,7 +71,8 @@ export class KVNextModeTagCache implements NextModeTagCache { return revalidated; } - async writeTags(tags: string[]): Promise { + async writeTags(tagsToWrite: (string | NextModeTagCacheWriteInput)[]): Promise { + const tags = toTagNames(tagsToWrite); const kv = this.getKv(); if (!kv || tags.length === 0) { return Promise.resolve(); diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index bfb1e073..baabc2cb 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { buildAdapter } from "@opennextjs/core/build/adapter.js"; +import { createCacheBundle } from "@opennextjs/core/build/createCacheBundle.js"; import type { BuildOptions } from "@opennextjs/core/build/helper.js"; import * as buildHelper from "@opennextjs/core/build/helper.js"; import type { ContentUpdater } from "@opennextjs/core/plugins/content-updater.js"; @@ -14,6 +15,7 @@ import type { OpenNextConfig } from "@opennextjs/core/types/open-next.js"; import { normalizePath } from "@opennextjs/core/utils/normalize-path.js"; import { bundleServer } from "./build/bundle-server.js"; +import { compileCacheEntrypoint } from "./build/open-next/compile-cache-entrypoint.js"; import { compileEnvFiles } from "./build/open-next/compile-env-files.js"; import { compileImages } from "./build/open-next/compile-images.js"; import { compileInit } from "./build/open-next/compile-init.js"; @@ -31,6 +33,9 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => skipImageOptimization: true, skipWarmer: true, skipGenerateOutput: true, + // The cache function is bundled by `beforeServerBundle` instead: it has to be emitted + // before the worker is bundled, and unconditionally as the worker always imports it. + skipCache: true, middlewareOptions: { forceOnlyBuildOnce: true }, beforeServerBundle: async (buildOpts, _config) => { // Import edge-compiled config for skew protection @@ -45,6 +50,7 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => await compileInit(buildOpts, {} as any); await compileImages(buildOpts); await compileSkewProtection(buildOpts, openNextConfig); + await buildCacheFunction(buildOpts); }, serverBundle: { useEdgeConfig: true, @@ -76,3 +82,30 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => }, }; }); + +/** + * Bundles the cache handler function and the named entrypoint exposing it. + * + * The cache function runs in the workerd runtime, as part of the worker, so it uses the + * edge flavour of the config - as the server bundle does. + */ +async function buildCacheFunction(buildOpts: BuildOptions) { + // The cache handler is served by the `OpenNextCache` named entrypoint, it is bundled as a + // regular fetch handler. + await createCacheBundle(buildOpts, { + wrapper: "@opennextjs/core/overrides/wrappers/cloudflare-edge.js", + converter: "@opennextjs/core/overrides/converters/edge.js", + }); + + // `createCacheBundle` copies the node config, replace it with the edge one when available. + const useEdgeConfig = fs.existsSync(path.join(buildOpts.buildDir, "open-next.config.edge.mjs")); + if (useEdgeConfig) { + buildHelper.copyOpenNextConfig( + buildOpts.buildDir, + path.join(buildOpts.outputDir, "cache-function"), + true + ); + } + + await compileCacheEntrypoint(buildOpts); +} diff --git a/packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts b/packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts deleted file mode 100644 index a7d2899d..00000000 --- a/packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; - -import type { BuildOptions } from "@opennextjs/core/build/helper.js"; -import type { TagCacheMetaFile } from "@opennextjs/core/types/cache.js"; - -/** - * Generates SQL statements that can be used to initialize the cache assets manifest in an SQL data store. - */ -export function compileCacheAssetsManifestSqlFile(options: BuildOptions, metaFiles: TagCacheMetaFile[]) { - const outputPath = path.join(options.outputDir, "cloudflare/cache-assets-manifest.sql"); - - mkdirSync(path.dirname(outputPath), { recursive: true }); - writeFileSync( - outputPath, - `CREATE TABLE IF NOT EXISTS tags (tag TEXT NOT NULL, path TEXT NOT NULL, UNIQUE(tag, path) ON CONFLICT REPLACE); - CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, UNIQUE(tag) ON CONFLICT REPLACE);\n` - ); - - const values = metaFiles.map(({ tag, path }) => `(${JSON.stringify(tag.S)}, ${JSON.stringify(path.S)})`); - - if (values.length) { - appendFileSync(outputPath, `INSERT INTO tags (tag, path) VALUES ${values.join(", ")};`); - } -} diff --git a/packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts b/packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts new file mode 100644 index 00000000..c22ee773 --- /dev/null +++ b/packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { BuildOptions } from "@opennextjs/core/build/helper.js"; +import { build } from "esbuild"; + +/** + * Compiles the `OpenNextCache` named entrypoint. + * + * `./init.js` and `../cache-function/index.mjs` are kept external: they are emitted next to the + * entrypoint in the output directory and resolved when wrangler bundles the worker. Inlining + * `./init.js` would duplicate the `AsyncLocalStorage` holding the Cloudflare context. + */ +export async function compileCacheEntrypoint(options: BuildOptions) { + const currentDir = path.join(path.dirname(fileURLToPath(import.meta.url))); + const templatesDir = path.join(currentDir, "../../templates"); + const entrypointPath = path.join(templatesDir, "cache-entrypoint.js"); + + await build({ + entryPoints: [entrypointPath], + outdir: path.join(options.outputDir, "cloudflare"), + bundle: true, + minify: false, + format: "esm", + target: "esnext", + platform: "node", + external: ["cloudflare:workers", "./init.js", "../cache-function/index.mjs"], + }); +} diff --git a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts index fb461018..de8a34df 100644 --- a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts +++ b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts @@ -17,12 +17,12 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { dftUseCloudflareWrapper: config.default?.override?.wrapper === "cloudflare-node", dftUseEdgeConverter: config.default?.override?.converter === "edge", dftUseFetchProxy: config.default?.override?.proxyExternalRequest === "fetch", - dftMaybeUseCache: - config.default?.override?.incrementalCache === "dummy" || - typeof config.default?.override?.incrementalCache === "function", - dftMaybeUseTagCache: - config.default?.override?.tagCache === "dummy" || - typeof config.default?.override?.incrementalCache === "function", + dftUseCacheClient: typeof config.default?.override?.cache === "function", + chMaybeUseIncrementalCache: + config.cacheHandler?.incrementalCache === "dummy" || + typeof config.cacheHandler?.incrementalCache === "function", + chMaybeUseTagCache: + config.cacheHandler?.tagCache === "dummy" || typeof config.cacheHandler?.tagCache === "function", dftMaybeUseQueue: config.default?.override?.queue === "dummy" || config.default?.override?.queue === "direct" || @@ -32,6 +32,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { mwUseCloudflareWrapper: mwConfig?.override?.wrapper === "cloudflare-edge", mwUseEdgeConverter: mwConfig?.override?.converter === "edge", mwUseFetchProxy: mwConfig?.override?.proxyExternalRequest === "fetch", + mwUseCacheClient: typeof mwConfig?.override?.cache === "function", hasCryptoExternal: config.edgeExternals?.includes("node:crypto"), }; @@ -48,11 +49,14 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { wrapper: "cloudflare-node", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: "dummy" | function, - tagCache: "dummy" | function, + cache: function, queue: "dummy" | "direct" | function, }, }, + cacheHandler: { + incrementalCache: "dummy" | function, + tagCache: "dummy" | function, + }, edgeExternals: ["node:crypto"], middleware: { external: true, @@ -60,8 +64,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { wrapper: "cloudflare-edge", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: "dummy" | function, - tagCache: "dummy" | function, + cache: function, queue: "dummy" | "direct" | function, }, }, diff --git a/packages/cloudflare/src/cli/commands/populate-cache.spec.ts b/packages/cloudflare/src/cli/commands/populate-cache.spec.ts index 9127dd1b..abf4e0c6 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.spec.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.spec.ts @@ -113,10 +113,8 @@ describe("populateCache", () => { outputDir: "/test/output", } as BuildOptions, { - default: { - override: { - incrementalCache: "cf-r2-incremental-cache", - }, + cacheHandler: { + incrementalCache: "cf-r2-incremental-cache", }, } as any, // oxlint-disable-line @typescript-eslint/no-explicit-any { @@ -149,10 +147,8 @@ describe("populateCache", () => { outputDir: "/test/output", } as BuildOptions, { - default: { - override: { - incrementalCache: "cf-r2-incremental-cache", - }, + cacheHandler: { + incrementalCache: "cf-r2-incremental-cache", }, } as any, // oxlint-disable-line @typescript-eslint/no-explicit-any { diff --git a/packages/cloudflare/src/cli/commands/populate-cache.ts b/packages/cloudflare/src/cli/commands/populate-cache.ts index c60aa20c..fbe6692a 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.ts @@ -90,7 +90,7 @@ export async function populateCache( populateCacheOptions: PopulateCacheOptions, envVars: WorkerEnvVar ) { - const { incrementalCache, tagCache } = config.default.override ?? {}; + const { incrementalCache, tagCache } = config.cacheHandler ?? {}; if (!fs.existsSync(buildOpts.outputDir)) { logger.error("Unable to populate cache: Open Next build not found"); diff --git a/packages/cloudflare/src/cli/commands/utils/helpers.ts b/packages/cloudflare/src/cli/commands/utils/helpers.ts index fe3859da..76cb8388 100644 --- a/packages/cloudflare/src/cli/commands/utils/helpers.ts +++ b/packages/cloudflare/src/cli/commands/utils/helpers.ts @@ -1,6 +1,7 @@ import { type BuildOptions } from "@opennextjs/core/build/helper.js"; -import { getPlatformProxy, type GetPlatformProxyOptions } from "wrangler"; +import { getPlatformProxy, type GetPlatformProxyOptions, unstable_readConfig } from "wrangler"; +import { withoutSelfEntrypointServices } from "../../../utils/wrangler-config.js"; import { extractProjectEnvVars } from "../../utils/extract-project-env-vars.js"; export type WorkerEnvVar = Record; @@ -8,18 +9,27 @@ export type WorkerEnvVar = Record; export async function getEnvFromPlatformProxy(options: GetPlatformProxyOptions, buildOpts: BuildOptions) { const envVars = process.env; - const proxy = await getPlatformProxy({ - ...options, - envFiles: [], - }); + const { configPath, cleanup } = withoutSelfEntrypointServices( + unstable_readConfig({ env: options.environment, config: options.configPath }) + ); - Object.entries(proxy.env).forEach(([key, value]) => { - if (typeof value === "string") { - envVars[key as keyof CloudflareEnv] = value; - } - }); + try { + const proxy = await getPlatformProxy({ + ...options, + configPath, + envFiles: [], + }); + + Object.entries(proxy.env).forEach(([key, value]) => { + if (typeof value === "string") { + envVars[key as keyof CloudflareEnv] = value; + } + }); - await proxy.dispose(); + await proxy.dispose(); + } finally { + cleanup(); + } let mode: "production" | "development" | "test" = "production"; if (envVars.NEXTJS_ENV === "development") { diff --git a/packages/cloudflare/src/cli/templates/cache-entrypoint.ts b/packages/cloudflare/src/cli/templates/cache-entrypoint.ts new file mode 100644 index 00000000..bbb999c9 --- /dev/null +++ b/packages/cloudflare/src/cli/templates/cache-entrypoint.ts @@ -0,0 +1,27 @@ +/** + * The OpenNext cache handler, exposed as a named entrypoint of the worker. + * + * The cache handler function is bundled by `createCacheBundle` as a regular fetch handler. The + * server and the middleware reach it through the `NEXT_CACHE_SERVICE` binding, which points at + * this worker by default so that the cache runs in the same worker, and can point at another + * worker instead. + * + * See https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#named-entrypoints + */ + +import { WorkerEntrypoint } from "cloudflare:workers"; + +// @ts-expect-error: resolved by wrangler build +import { handler } from "../cache-function/index.mjs"; + +import { runWithCloudflareContext } from "./init.js"; + +type CacheHandler = (request: Request, env: CloudflareEnv, ctx: ExecutionContext) => Promise; + +export class OpenNextCache extends WorkerEntrypoint { + override fetch(request: Request): Promise { + return runWithCloudflareContext(this.env, this.ctx, () => + (handler as CacheHandler)(request, this.env, this.ctx) + ); + } +} diff --git a/packages/cloudflare/src/cli/templates/init.ts b/packages/cloudflare/src/cli/templates/init.ts index 5cf9c315..b2d745e4 100644 --- a/packages/cloudflare/src/cli/templates/init.ts +++ b/packages/cloudflare/src/cli/templates/init.ts @@ -23,33 +23,56 @@ Object.defineProperty(globalThis, Symbol.for("__cloudflare-context__"), { /** * Executes the handler with the Cloudflare context. */ -export async function runWithCloudflareRequestContext( +export async function runWithCloudflareRequestContext( request: Request, env: CloudflareEnv, ctx: ExecutionContext, - handler: () => Promise -): Promise { - init(request, env); + handler: () => Promise +): Promise { + init(env, new URL(request.url)); return cloudflareContextALS.run({ env, ctx, cf: request.cf }, handler); } +/** + * Executes the handler with the Cloudflare context, outside of a request. + * + * Used by the named entrypoints (i.e. the cache handler) which are invoked via RPC + * and therefore have no incoming `Request` to derive the origin from. + */ +export async function runWithCloudflareContext( + env: CloudflareEnv, + ctx: ExecutionContext, + handler: () => Promise +): Promise { + init(env); + + return cloudflareContextALS.run({ env, ctx, cf: undefined }, handler); +} + let initialized = false; +let originInitialized = false; /** * Initializes the runtime on the first call, * no-op on subsequent invocations. + * + * The origin is only known when a `Request` is available, so it is populated on the first + * call made from the fetch handler - which might not be the first call overall. */ -function init(request: Request, env: CloudflareEnv) { - if (initialized) { - return; +function init(env: CloudflareEnv, url?: URL) { + if (!initialized) { + initialized = true; + + initRuntime(); + populateProcessEnv(env); } - initialized = true; - const url = new URL(request.url); + if (url && !originInitialized) { + originInitialized = true; - initRuntime(); - populateProcessEnv(url, env); + populateOriginEnv(url); + } } function initRuntime() { @@ -109,9 +132,8 @@ function initRuntime() { * Populate process.env with: * - the environment variables and secrets from the cloudflare platform * - the variables from Next .env* files - * - the origin resolver information */ -function populateProcessEnv(url: URL, env: CloudflareEnv) { +function populateProcessEnv(env: CloudflareEnv) { for (const [key, value] of Object.entries(env)) { if (typeof value === "string") { process.env[key] = value; @@ -125,6 +147,16 @@ function populateProcessEnv(url: URL, env: CloudflareEnv) { } } + // `__DEPLOYMENT_ID__` is a string (passed via ESBuild). + if (__DEPLOYMENT_ID__) { + process.env.DEPLOYMENT_ID = __DEPLOYMENT_ID__; + } +} + +/** + * Populate process.env with the origin resolver information. + */ +function populateOriginEnv(url: URL) { // Set the default Origin for the origin resolver. // This is only needed for an external middleware bundle process.env.OPEN_NEXT_ORIGIN = JSON.stringify({ @@ -140,11 +172,6 @@ function populateProcessEnv(url: URL, env: CloudflareEnv) { * https://github.com/vercel/next.js/blob/6b1e48080e896e0d44a05fe009cb79d2d3f91774/packages/next/src/server/app-render/action-handler.ts#L307-L316 */ process.env.__NEXT_PRIVATE_ORIGIN = url.origin; - - // `__DEPLOYMENT_ID__` is a string (passed via ESBuild). - if (__DEPLOYMENT_ID__) { - process.env.DEPLOYMENT_ID = __DEPLOYMENT_ID__; - } } declare global { diff --git a/packages/cloudflare/src/cli/templates/worker.ts b/packages/cloudflare/src/cli/templates/worker.ts index 4465dbf6..6c43e353 100644 --- a/packages/cloudflare/src/cli/templates/worker.ts +++ b/packages/cloudflare/src/cli/templates/worker.ts @@ -14,6 +14,8 @@ export { DOQueueHandler } from "./.build/durable-objects/queue.js"; export { DOShardedTagCache } from "./.build/durable-objects/sharded-tag-cache.js"; //@ts-expect-error: Will be resolved by wrangler build export { BucketCachePurge } from "./.build/durable-objects/bucket-cache-purge.js"; +//@ts-expect-error: Will be resolved by wrangler build +export { OpenNextCache } from "./cloudflare/cache-entrypoint.js"; export default { async fetch(request, env, ctx) { diff --git a/packages/cloudflare/src/utils/wrangler-config.ts b/packages/cloudflare/src/utils/wrangler-config.ts new file mode 100644 index 00000000..44a020af --- /dev/null +++ b/packages/cloudflare/src/utils/wrangler-config.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; +import path from "node:path"; + +type ServiceBinding = { + binding: string; + service: string; + entrypoint?: string; +}; + +/** The subset of the resolved wrangler configuration used here. */ +type ResolvedConfig = { + configPath?: string; + name?: string; + services?: ServiceBinding[]; +}; + +/** + * `getPlatformProxy` starts the worker without its script, so it is not able to resolve a service + * binding referencing a named entrypoint of the worker itself - which is how the OpenNext cache is + * wired - and fails to start. + * + * Those bindings are only used at runtime by the generated worker, dropping them has no effect on + * what `getPlatformProxy` is used for, so the configuration is rewritten without them. + * + * @returns the configuration path to pass to `getPlatformProxy` and a cleanup function to call + * once the proxy has been disposed of. + */ +export function withoutSelfEntrypointServices(config: ResolvedConfig): { + configPath: string | undefined; + cleanup: () => void; +} { + const services = config.services ?? []; + const keptServices = services.filter((service) => !(service.entrypoint && service.service === config.name)); + + if (!config.configPath || keptServices.length === services.length) { + return { configPath: config.configPath, cleanup: () => {} }; + } + + // `unsafe` is dropped as wrangler warns about it being experimental, even when it is empty. + const { unsafe: _unsafe, ...rest } = config as ResolvedConfig & { unsafe?: unknown }; + + // The file has to sit next to the original one: relative paths are resolved from its directory. + const configPath = path.join(path.dirname(config.configPath), `.wrangler.opennext.${process.pid}.json`); + + fs.writeFileSync(configPath, JSON.stringify({ ...rest, services: keptServices })); + + return { configPath, cleanup: () => fs.rmSync(configPath, { force: true }) }; +} diff --git a/packages/cloudflare/templates/wrangler.jsonc b/packages/cloudflare/templates/wrangler.jsonc index 7cf1ce87..702c0aea 100644 --- a/packages/cloudflare/templates/wrangler.jsonc +++ b/packages/cloudflare/templates/wrangler.jsonc @@ -14,6 +14,14 @@ // see https://opennext.js.org/cloudflare/caching "binding": "WORKER_SELF_REFERENCE", "service": "" + }, + { + // The OpenNext cache runs behind a named entrypoint of this worker. + // The service name must match the worker name. + // see https://opennext.js.org/cloudflare/caching + "binding": "NEXT_CACHE_SERVICE", + "service": "", + "entrypoint": "OpenNextCache" } ], "r2_buckets": [ diff --git a/packages/core/src/utils/promise.ts b/packages/core/src/utils/promise.ts index 1dbc4df6..e3a2e158 100644 --- a/packages/core/src/utils/promise.ts +++ b/packages/core/src/utils/promise.ts @@ -1,6 +1,7 @@ import type { WaitUntil } from "@/types/open-next"; import { debug, error } from "../adapters/logger"; + import { RequestCache } from "./requestCache"; /** @@ -114,15 +115,15 @@ export function runWithOpenNextRequestContext( }, fn: () => Promise ): Promise { - return globalThis.__openNextAls.run( - { - requestId, - pendingPromiseRunner: new DetachedPromiseRunner(), - isISRRevalidation, - waitUntil, - writtenTags: new Set(), - requestCache: new RequestCache(), - }, + return globalThis.__openNextAls.run( + { + requestId, + pendingPromiseRunner: new DetachedPromiseRunner(), + isISRRevalidation, + waitUntil, + writtenTags: new Set(), + requestCache: new RequestCache(), + }, async () => { provideNextAfterProvider(); let result: T; diff --git a/packages/core/src/utils/requestCache.ts b/packages/core/src/utils/requestCache.ts index 20b6ffd9..58fe1d1c 100644 --- a/packages/core/src/utils/requestCache.ts +++ b/packages/core/src/utils/requestCache.ts @@ -10,19 +10,19 @@ * ``` */ export class RequestCache { - private _caches = new Map>(); + private _caches = new Map>(); - /** - * Returns the Map registered under `key`. - * If no Map exists yet for that key, a new empty Map is created, stored, and returned. - * Repeated calls with the same key always return the **same** Map instance. - */ - getOrCreate(key: string): Map { - let cache = this._caches.get(key) as Map | undefined; - if (!cache) { - cache = new Map(); - this._caches.set(key, cache); - } - return cache; - } + /** + * Returns the Map registered under `key`. + * If no Map exists yet for that key, a new empty Map is created, stored, and returned. + * Repeated calls with the same key always return the **same** Map instance. + */ + getOrCreate(key: string): Map { + let cache = this._caches.get(key) as Map | undefined; + if (!cache) { + cache = new Map(); + this._caches.set(key, cache); + } + return cache; + } } From 4f15d1567de80c7db17a74bb331e0f575974edec Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 16 Aug 2026 12:11:29 +0200 Subject: [PATCH 13/17] fix(cache): ensure cache type is forwarded in set methods for fetch and local caches --- .../core/src/adapters/composable-cache.ts | 6 +++--- packages/core/src/build/adapter.spec.ts | 20 +++++++++++++++++++ packages/core/src/overrides/cache/fetch.ts | 7 +++++-- packages/core/src/overrides/cache/local.ts | 8 ++++++-- .../tests/overrides/cache/fetch.test.ts | 8 ++++++++ .../tests/overrides/cache/local.test.ts | 11 ++++++++++ 6 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index efa2c5f7..f3377b3e 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -100,9 +100,9 @@ export default { return; } try { - await globalThis.cache.revalidateTags(tags, { - expire: durations?.expire ? Date.now() + durations.expire * 1000 : undefined, - }); + // `durations.expire` is a delay in seconds, it is turned into a timestamp by the cache + // handler function - it should not be converted here as well. + await globalThis.cache.revalidateTags(tags, durations); } catch (e) { debug("Failed to update tags", e); } diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index dfdb54f1..b678039a 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -64,6 +64,10 @@ vi.mock("./createImageOptimizationBundle.js", () => ({ createImageOptimizationBundle: vi.fn(), })); +vi.mock("./createCacheBundle.js", () => ({ + createCacheBundle: vi.fn(), +})); + vi.mock("./createWarmerBundle.js", () => ({ createWarmerBundle: vi.fn(), })); @@ -94,6 +98,7 @@ import { compileCache } from "./compileCache.js"; import { compileOpenNextConfig } from "./compileConfig.js"; import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; import { createStaticAssets, createCacheAssets } from "./createAssets.js"; +import { createCacheBundle } from "./createCacheBundle.js"; import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; @@ -254,6 +259,21 @@ describe("buildAdapter", () => { expect(createRevalidationBundle).not.toHaveBeenCalled(); }); + test("onBuildComplete skips createCacheBundle when skipCache is true", async () => { + const adapter = buildAdapter(() => ({ + serverBundle, + skipCache: true, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createCacheBundle).not.toHaveBeenCalled(); + }); + test("onBuildComplete calls influence.beforeServerBundle BEFORE createMiddleware", async () => { const callOrder: string[] = []; diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index b5d53a01..f708e0d6 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -20,8 +20,11 @@ const fetchCache: Cache = { // oxlint-disable-next-line @typescript-eslint/no-explicit-any return parseCacheGetResponse(headers, bodyText) as any; }, - set: async (key, value, _cacheType) => { - const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; + set: async (key, value, cacheType) => { + // The cache type has to be forwarded: incremental caches may key entries on it, + // writing without it would store the entry where `get` does not look for it. + const queryString = cacheType ? `?type=${cacheType}` : ""; + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${queryString}`; await fetch(url, { method: "PUT", headers: { "Content-Type": "application/json" }, diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index 089d50bb..d501dc91 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -41,17 +41,21 @@ const localCache: Cache = { // oxlint-disable-next-line @typescript-eslint/no-explicit-any return parseCacheGetResponse(result.headers, bodyText) as any; }, - set: async (key, value, _cacheType) => { + set: async (key, value, cacheType) => { const h = (await getHandler())!; const encodedKey = encodeURIComponent(key); const url = `https://on/cache/${encodedKey}`; + // The cache type has to be forwarded: incremental caches may key entries on it, + // writing without it would store the entry where `get` does not look for it. + const query: Record = {}; + if (cacheType) query.type = cacheType; const event: InternalEvent = { type: "core", method: "PUT", rawPath: `/cache/${encodedKey}`, url, headers: { "Content-Type": "application/json" }, - query: {}, + query, cookies: {}, remoteAddress: "127.0.0.1", body: Buffer.from(JSON.stringify({ value })), diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts index f6076ec3..0a18ce8b 100644 --- a/packages/tests-unit/tests/overrides/cache/fetch.test.ts +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -167,6 +167,14 @@ describe("fetch cache", () => { expect(global.fetch).toHaveBeenCalledWith("/cache/special%2Fkey", expect.any(Object)); }); + + // Incremental caches may key entries on the cache type, writing without it would store + // the entry where `get` does not look for it. + it("should forward the cache type", async () => { + await fetchCache.set("key", {}, "composable"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key?type=composable", expect.any(Object)); + }); }); describe("delete", () => { diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts index c458c949..9bc5b23a 100644 --- a/packages/tests-unit/tests/overrides/cache/local.test.ts +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -167,6 +167,17 @@ describe("local cache", () => { const event = mockHandler.mock.calls[0][0]; expect(event.rawPath).toBe("/cache/special%2Fkey"); }); + + // Incremental caches may key entries on the cache type, writing without it would store + // the entry where `get` does not look for it. + it("should forward the cache type", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.set("key", {}, "composable"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.query).toEqual({ type: "composable" }); + }); }); describe("delete", () => { From 1acf179bb0dc445bc8a50e4dd643ee3fdd274896 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 16 Aug 2026 13:03:23 +0200 Subject: [PATCH 14/17] feat(cache): enhance Cache-Control handling and revalidation logic for cache entries --- .../api/overrides/cache/service-cache.spec.ts | 3 +- packages/core/src/adapters/cache-adapter.ts | 66 +++++--- .../core/src/core/routing/cacheInterceptor.ts | 2 +- packages/core/src/utils/cache-control.ts | 100 ++++++++++++ packages/core/src/utils/cache-get.ts | 15 +- .../tests/adapters/cache-adapter.test.ts | 154 ++++++++++++++++++ .../tests/utils/cache-control.test.ts | 131 +++++++++++++++ .../tests-unit/tests/utils/cache-get.test.ts | 16 ++ 8 files changed, 462 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/utils/cache-control.ts create mode 100644 packages/tests-unit/tests/utils/cache-control.test.ts diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts index 2ad47c58..05b140a6 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -56,13 +56,14 @@ describe("serviceCache", () => { "x-opennext-cache-type": "cache", "x-opennext-cache-sub-type": "route", "x-opennext-cache-last-modified": "1234", + "x-opennext-cache-revalidate": "60", }, }) ); await expect(serviceCache.get("key")).resolves.toEqual({ lastModified: 1234, - value: expect.objectContaining({ type: "route", body: "body" }), + value: expect.objectContaining({ type: "route", body: "body", revalidate: 60 }), }); }); }); diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index 7aeae153..d572a681 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -4,7 +4,6 @@ import type { StoredComposableCacheEntry } from "@/types/cache"; import type { InternalEvent, InternalResult } from "@/types/open-next"; import type { CacheEntryType, - CachedFile, CachedFetchValue, CacheValue, OpenNextHandlerOptions, @@ -13,6 +12,7 @@ import type { import { createGenericHandler } from "../core/createGenericHandler.js"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; +import { computeEntryCacheControl } from "../utils/cache-control.js"; import { getTagsFromValue, isStale, writeTags } from "../utils/cache.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; import { toReadableStream } from "../utils/stream.js"; @@ -130,19 +130,23 @@ async function handleGet( }; } - if (result.value && !result.shouldBypassTagCache) { - let tags: string[] = [...additionalTags]; + // The tags are also used to make the response purgeable, so they are derived for every hit, + // including the ones bypassing the tag cache. + let tags: string[] = [...additionalTags]; + + if (cacheType === "cache") { + tags = [...tags, ...getTagsFromValue(result.value as CacheValue<"cache">)]; + } else if (cacheType === "fetch") { + const fetchValue = result.value as CachedFetchValue; + tags = [...tags, ...(fetchValue.tags ?? []), ...(fetchValue.data?.tags ?? [])]; + } else if (cacheType === "composable") { + const composableValue = result.value as StoredComposableCacheEntry; + tags = [...tags, ...(composableValue.tags ?? [])]; + } - if (cacheType === "cache") { - tags = [...tags, ...getTagsFromValue(result.value as CacheValue<"cache">)]; - } else if (cacheType === "fetch") { - const fetchValue = result.value as CachedFetchValue; - tags = [...tags, ...(fetchValue.tags ?? []), ...(fetchValue.data?.tags ?? [])]; - } else if (cacheType === "composable") { - const composableValue = result.value as StoredComposableCacheEntry; - tags = [...tags, ...(composableValue.tags ?? [])]; - } + let isEntryStale = false; + if (!result.shouldBypassTagCache) { const lastModified = result.lastModified ?? Date.now(); if (tags.length > 0) { @@ -163,13 +167,15 @@ async function handleGet( } // Check if the cache entry is stale (valid but needs background revalidation) - const _isStale = tags.length > 0 ? await isStale(key, tags, lastModified) : false; - if (_isStale) { + isEntryStale = tags.length > 0 ? await isStale(key, tags, lastModified) : false; + if (isEntryStale) { result.lastModified = 1; } } - return buildCacheGetResponse(result); + // We default to the entry key when no tag is found, so that page router based entries can also + // be purged this way. + return buildCacheGetResponse(result, isEntryStale, tags.length > 0 ? tags : [key]); } catch (e) { error("Failed to get cache entry", e); return buildErrorResponse("Failed to get cache entry", 500); @@ -399,14 +405,24 @@ async function handleRevalidateTags(body?: Buffer): Promise { // Cache GET response builder // ///////////////////////////// -function buildCacheGetResponse(result: WithLastModified>): InternalResult { +function buildCacheGetResponse( + result: WithLastModified>, + isStaleFromTagCache: boolean, + tags: string[] +): InternalResult { const value = result.value!; const headers: Record = { "x-opennext-cache-found": "true", - "Cache-Control": "no-store", + "Cache-Control": computeEntryCacheControl(value, result.lastModified, isStaleFromTagCache), }; + // The `Cache-Control` above lets an HTTP cache store this response, it can only be invalidated + // through a purge keyed on these tags. See `computeEntryCacheControl`. + if (tags.length > 0) { + headers["cache-tag"] = tags.join(","); + } + if (result.lastModified !== undefined) { headers["x-opennext-cache-last-modified"] = String(result.lastModified); } @@ -415,24 +431,28 @@ function buildCacheGetResponse(result: WithLastModified, headers); } if ("type" in value) { - return buildCachedFileResponse(value as CachedFile, headers); + return buildCachedFileResponse(value as CacheValue<"cache">, headers); } return buildComposableResponse(value as StoredComposableCacheEntry, headers); } function buildFetchResponse( - value: CachedFetchValue, + value: CacheValue<"fetch">, headers: Record ): InternalResult { headers["x-opennext-cache-type"] = "fetch"; headers["x-opennext-cache-fetch-kind"] = "FETCH"; headers["x-opennext-cache-fetch-data-url"] = value.data.url; + if (value.revalidate !== undefined) { + headers["x-opennext-cache-revalidate"] = String(value.revalidate); + } + if (value.data.status !== undefined) { headers["x-opennext-cache-fetch-data-status"] = String(value.data.status); } @@ -458,12 +478,16 @@ function buildFetchResponse( } function buildCachedFileResponse( - value: CachedFile, + value: CacheValue<"cache">, headers: Record ): InternalResult { headers["x-opennext-cache-type"] = "cache"; headers["x-opennext-cache-sub-type"] = value.type; + if (value.revalidate !== undefined) { + headers["x-opennext-cache-revalidate"] = String(value.revalidate); + } + if (value.meta?.status !== undefined) { headers["x-opennext-cache-meta-status"] = String(value.meta.status); } diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index 0e711802..c38d4950 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -4,6 +4,7 @@ import { NextConfig, PrerenderManifest } from "@/config/index"; import type { InternalEvent, InternalResult, MiddlewareEvent, PartialResult } from "@/types/open-next"; import type { CacheValue } from "@/types/overrides"; import { isBinaryContentType } from "@/utils/binary"; +import { CACHE_ONE_YEAR } from "@/utils/cache-control"; import { emptyReadableStream, toReadableStream } from "@/utils/stream"; import { debug, error } from "../../adapters/logger"; @@ -11,7 +12,6 @@ import { debug, error } from "../../adapters/logger"; import { localizePath } from "./i18n"; import { generateMessageGroupId } from "./queue"; -const CACHE_ONE_YEAR = 60 * 60 * 24 * 365; const CACHE_ONE_MONTH = 60 * 60 * 24 * 30; /* diff --git a/packages/core/src/utils/cache-control.ts b/packages/core/src/utils/cache-control.ts new file mode 100644 index 00000000..b3856336 --- /dev/null +++ b/packages/core/src/utils/cache-control.ts @@ -0,0 +1,100 @@ +import type { StoredComposableCacheEntry } from "@/types/cache"; +import type { CacheEntryType, CacheValue } from "@/types/overrides"; + +import { error } from "../adapters/logger"; + +export const CACHE_ONE_YEAR = 60 * 60 * 24 * 365; + +const NO_STORE = "no-store"; + +/** + * Composable cache entries may carry `Infinity` (i.e. `cacheLife("max")`), and an entry that was + * just written has a negative age when `Date.now()` drifts, so every duration is clamped. + */ +function clampSeconds(seconds: number): number { + if (!Number.isFinite(seconds)) { + return CACHE_ONE_YEAR; + } + return Math.max(0, Math.min(Math.floor(seconds), CACHE_ONE_YEAR)); +} + +function buildCacheControl(sMaxAge: number, staleWhileRevalidate: number): string { + return `s-maxage=${clampSeconds(sMaxAge)}, stale-while-revalidate=${clampSeconds(staleWhileRevalidate)}`; +} + +/** + * Computes the `Cache-Control` of a cache handler `GET` hit, so that an HTTP cache sitting in front + * of the cache handler function can serve reads without hitting the underlying store. + * + * A stale or expired entry is never stored: the next read has to reach the cache handler function so + * that the staleness is signaled to the server (through `lastModified = 1`). + * + * **This is only correct when the cached responses can be purged**, either with a + * `cdnInvalidationHandler` or through another purge mechanism keyed on the `cache-tag` header that + * `buildCacheGetResponse` emits. Tag revalidation cannot invalidate an intermediate cache on its own, + * so without purging `revalidateTag`/`revalidatePath` would be masked for as long as the entry is + * stored - up to a year for SSG entries. + */ +export function computeEntryCacheControl( + value: CacheValue, + lastModified: number | undefined, + isStaleFromTagCache: boolean +): string { + if (isStaleFromTagCache) { + return NO_STORE; + } + + // Same discrimination as `buildCacheGetResponse`: fetch entries have a `kind`, cached files have + // a `type`, composable entries have neither. + const isFetch = "kind" in value && value.kind === "FETCH"; + const isCachedFile = "type" in value; + + if (!isFetch && !isCachedFile) { + return computeComposableCacheControl(value as StoredComposableCacheEntry); + } + + return computeRevalidateCacheControl(value.revalidate, lastModified); +} + +function computeComposableCacheControl(value: StoredComposableCacheEntry): string { + const age = (Date.now() - value.timestamp) / 1000; + + if (age >= value.expire || age >= value.revalidate) { + return NO_STORE; + } + + // Composable entries are the only ones carrying an explicit `expire`, so they are also the only + // ones for which we can derive a real stale-while-revalidate window. + return buildCacheControl(value.revalidate - age, value.expire - value.revalidate); +} + +function computeRevalidateCacheControl( + revalidate: number | false | undefined, + lastModified: number | undefined +): string { + if (revalidate === 0) { + return NO_STORE; + } + + if (revalidate === undefined) { + // `revalidate` is written by the cache handler for every entry, we should always have one here. + error("Missing `revalidate` on a cache entry, assuming it is a static (SSG) entry"); + } + + if (revalidate === undefined || revalidate === false) { + return buildCacheControl(CACHE_ONE_YEAR, 0); + } + + const age = (Date.now() - (lastModified ?? Date.now())) / 1000; + const remainingTtl = revalidate - age; + + if (remainingTtl <= 0) { + return NO_STORE; + } + + // `stale-while-revalidate` is intentionally `0` for fetch and cached file entries: a response + // served during a stale-while-revalidate window still carries its original + // `x-opennext-cache-last-modified`, which would hide the `lastModified = 1` staleness signal that + // `cacheInterceptor` and the composable cache rely on to trigger a background revalidation. + return buildCacheControl(remainingTtl, 0); +} diff --git a/packages/core/src/utils/cache-get.ts b/packages/core/src/utils/cache-get.ts index 572a9a42..de858478 100644 --- a/packages/core/src/utils/cache-get.ts +++ b/packages/core/src/utils/cache-get.ts @@ -22,6 +22,17 @@ function getHeaderNumber(headers: HeadersMap, name: string): number | undefined return Number.isNaN(n) ? undefined : n; } +/** + * `revalidate` is either a number of seconds or `false` for entries that never revalidate (SSG). + */ +function getHeaderRevalidate(headers: HeadersMap): number | false | undefined { + const v = getHeaderValue(headers, "x-opennext-cache-revalidate"); + if (v === undefined) return undefined; + if (v === "false") return false; + const n = Number(v); + return Number.isNaN(n) ? undefined : n; +} + function collectPrefixedHeaders(headers: HeadersMap, prefix: string): Record { const result: Record = {}; for (const [key, value] of Object.entries(headers)) { @@ -99,7 +110,7 @@ function reconstructFetch(headers: HeadersMap, bodyText: string, base: Base) { const dataTags = dataTagsStr ? JSON.parse(dataTagsStr) : undefined; const fetchTagsStr = getHeaderValue(headers, "x-opennext-cache-fetch-tags"); const fetchTags = fetchTagsStr ? JSON.parse(fetchTagsStr) : undefined; - const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate"); + const revalidate = getHeaderRevalidate(headers); const dataHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-") as Record; @@ -123,7 +134,7 @@ function reconstructCachedFile(headers: HeadersMap, bodyText: string, base: Base const subType = getHeaderValue(headers, "x-opennext-cache-sub-type"); const metaStatus = getHeaderNumber(headers, "x-opennext-cache-meta-status"); const metaPostponed = getHeaderValue(headers, "x-opennext-cache-meta-postponed"); - const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate"); + const revalidate = getHeaderRevalidate(headers); const metaHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-"); const hasMetaHeaders = Object.keys(metaHeaders).length > 0; diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index b420cfc0..e56d70dc 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { handler } from "@opennextjs/core/adapters/cache-adapter"; import type { InternalEvent, InternalResult, OpenNextConfig } from "@opennextjs/core/types/open-next"; +import { CACHE_ONE_YEAR } from "@opennextjs/core/utils/cache-control"; import { fromReadableStream } from "@opennextjs/core/utils/stream"; import { type Mock, vi, describe, expect, it, beforeEach } from "vitest"; @@ -266,6 +267,139 @@ describe("cache-adapter", () => { }); }); + describe("Cache-Control and cache-tag in GET", () => { + it("should not store a miss", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + + const result = await runHandler(createEvent()); + + expect(result.headers["Cache-Control"]).toBe("no-store"); + }); + + it("should not store a tag revalidated entry", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(-1); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["Cache-Control"]).toBe("no-store"); + }); + + it("should compute the remaining ttl of a cached file entry", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "route-body", revalidate: 120 }, + lastModified: Date.now(), + }); + + const result = await runHandler(createEvent()); + + expect(result.headers["Cache-Control"]).toBe("s-maxage=120, stale-while-revalidate=0"); + expect(result.headers["x-opennext-cache-revalidate"]).toBe("120"); + }); + + it("should forward a `false` revalidate and cache the entry for a year", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "app", html: "", rsc: "rsc-data", revalidate: false }, + lastModified: Date.now(), + }); + + const result = await runHandler(createEvent()); + + expect(result.headers["Cache-Control"]).toBe(`s-maxage=${CACHE_ONE_YEAR}, stale-while-revalidate=0`); + expect(result.headers["x-opennext-cache-revalidate"]).toBe("false"); + }); + + it("should not store a cached file entry past its revalidate window", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "route-body", revalidate: 60 }, + lastModified: Date.now() - 120_000, + }); + + const result = await runHandler(createEvent()); + + expect(result.headers["Cache-Control"]).toBe("no-store"); + }); + + it("should compute the remaining ttl of a fetch entry", async () => { + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(false); + mockIncrementalCache.get.mockResolvedValue({ + value: { + kind: "FETCH", + data: { headers: {}, body: "fetch-body", url: "https://example.com" }, + revalidate: 300, + tags: ["fetch-tag"], + }, + lastModified: Date.now(), + }); + + const result = await runHandler(createEvent({ query: { type: "fetch" } })); + + expect(result.headers["Cache-Control"]).toBe("s-maxage=300, stale-while-revalidate=0"); + expect(result.headers["x-opennext-cache-revalidate"]).toBe("300"); + expect(result.headers["cache-tag"]).toBe("fetch-tag"); + }); + + it("should derive the stale window of a composable entry", async () => { + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(false); + mockIncrementalCache.get.mockResolvedValue({ + value: { + value: "composable-body", + tags: ["composable-tag"], + timestamp: Date.now(), + revalidate: 60, + expire: 300, + stale: 5, + }, + lastModified: Date.now(), + }); + + const result = await runHandler(createEvent({ query: { type: "composable" } })); + + expect(result.headers["Cache-Control"]).toBe("s-maxage=60, stale-while-revalidate=240"); + expect(result.headers["cache-tag"]).toBe("composable-tag"); + }); + + it("should not store an expired composable entry", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + value: "composable-body", + tags: [], + timestamp: Date.now() - 400_000, + revalidate: 600, + expire: 300, + stale: 5, + }, + lastModified: Date.now() - 400_000, + }); + + const result = await runHandler(createEvent({ query: { type: "composable" } })); + + expect(result.headers["Cache-Control"]).toBe("no-store"); + }); + + it("should fall back to the cache key when the entry has no tag", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "route-body", revalidate: 60 }, + lastModified: Date.now(), + }); + + const result = await runHandler(createEvent()); + + expect(result.headers["cache-tag"]).toBe("test-key"); + }); + }); + describe("tag revalidation in GET", () => { it("should return cached value when there are no tags", async () => { mockTagCache.mode = "original"; @@ -400,6 +534,7 @@ describe("cache-adapter", () => { expect(result.statusCode).toBe(200); expect(result.headers["x-opennext-cache-last-modified"]).toBe("1"); + expect(result.headers["Cache-Control"]).toBe("no-store"); }); it("should keep original lastModified when tags are not stale", async () => { @@ -419,6 +554,7 @@ describe("cache-adapter", () => { expect(result.statusCode).toBe(200); expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + expect(result.headers["cache-tag"]).toBe("tag1"); }); it("should skip isStale when shouldBypassTagCache is true", async () => { @@ -433,6 +569,24 @@ describe("cache-adapter", () => { expect(result.statusCode).toBe(200); expect(mockTagCache.isStale).not.toHaveBeenCalled(); }); + + it("should still emit the tags when shouldBypassTagCache is true", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }, + lastModified: 1000, + shouldBypassTagCache: true, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.isStale).not.toHaveBeenCalled(); + expect(result.headers["cache-tag"]).toBe("tag1,tag2"); + }); }); describe("PUT /cache/:key", () => { diff --git a/packages/tests-unit/tests/utils/cache-control.test.ts b/packages/tests-unit/tests/utils/cache-control.test.ts new file mode 100644 index 00000000..994c4bd5 --- /dev/null +++ b/packages/tests-unit/tests/utils/cache-control.test.ts @@ -0,0 +1,131 @@ +import type { StoredComposableCacheEntry } from "@opennextjs/core/types/cache"; +import type { CacheEntryType, CacheValue } from "@opennextjs/core/types/overrides"; +import { CACHE_ONE_YEAR, computeEntryCacheControl } from "@opennextjs/core/utils/cache-control"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const NOW = 1_700_000_000_000; + +function composable(overrides: Partial = {}): CacheValue<"composable"> { + return { + value: "composable-body", + tags: [], + timestamp: NOW, + revalidate: 60, + expire: 300, + stale: 5, + ...overrides, + }; +} + +function fetchEntry(revalidate?: number | false): CacheValue<"fetch"> { + return { + kind: "FETCH", + data: { headers: {}, body: "fetch-body", url: "https://example.com" }, + ...(revalidate !== undefined ? { revalidate } : {}), + }; +} + +function cachedFile(revalidate?: number | false): CacheValue<"cache"> { + return { + type: "route", + body: "route-body", + ...(revalidate !== undefined ? { revalidate } : {}), + }; +} + +function compute( + value: CacheValue, + lastModified?: number, + isStaleFromTagCache = false +): string { + return computeEntryCacheControl(value, lastModified, isStaleFromTagCache); +} + +describe("computeEntryCacheControl", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + errorSpy.mockRestore(); + }); + + it("should not store an entry that is stale from the tag cache", () => { + expect(compute(cachedFile(60), NOW, true)).toBe("no-store"); + expect(compute(composable(), NOW, true)).toBe("no-store"); + expect(compute(fetchEntry(60), NOW, true)).toBe("no-store"); + }); + + describe("composable entries", () => { + it("should compute the remaining revalidate window and the stale window", () => { + vi.setSystemTime(NOW + 20_000); + + expect(compute(composable())).toBe("s-maxage=40, stale-while-revalidate=240"); + }); + + it("should not store a stale entry", () => { + vi.setSystemTime(NOW + 60_000); + + expect(compute(composable())).toBe("no-store"); + }); + + it("should not store an expired entry", () => { + vi.setSystemTime(NOW + 300_000); + + expect(compute(composable({ revalidate: 600 }))).toBe("no-store"); + }); + + it("should clamp infinite durations to a year", () => { + expect( + compute(composable({ revalidate: Number.POSITIVE_INFINITY, expire: Number.POSITIVE_INFINITY })) + ).toBe(`s-maxage=${CACHE_ONE_YEAR}, stale-while-revalidate=${CACHE_ONE_YEAR}`); + }); + }); + + describe("fetch entries", () => { + it("should compute the remaining ttl", () => { + vi.setSystemTime(NOW + 10_000); + + expect(compute(fetchEntry(60), NOW)).toBe("s-maxage=50, stale-while-revalidate=0"); + }); + + it("should not store an entry past its revalidate window", () => { + vi.setSystemTime(NOW + 60_000); + + expect(compute(fetchEntry(60), NOW)).toBe("no-store"); + }); + }); + + describe("cached file entries", () => { + it("should compute the remaining ttl", () => { + vi.setSystemTime(NOW + 30_000); + + expect(compute(cachedFile(120), NOW)).toBe("s-maxage=90, stale-while-revalidate=0"); + }); + + it("should cache SSG entries for a year", () => { + expect(compute(cachedFile(false), NOW)).toBe(`s-maxage=${CACHE_ONE_YEAR}, stale-while-revalidate=0`); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it("should assume SSG and log an error when revalidate is missing", () => { + expect(compute(cachedFile(), NOW)).toBe(`s-maxage=${CACHE_ONE_YEAR}, stale-while-revalidate=0`); + expect(errorSpy).toHaveBeenCalledWith( + "Missing `revalidate` on a cache entry, assuming it is a static (SSG) entry" + ); + }); + + it("should not store an entry with a revalidate of 0", () => { + expect(compute(cachedFile(0), NOW)).toBe("no-store"); + }); + + it("should treat a missing lastModified as a fresh entry", () => { + expect(compute(cachedFile(60))).toBe("s-maxage=60, stale-while-revalidate=0"); + }); + }); +}); diff --git a/packages/tests-unit/tests/utils/cache-get.test.ts b/packages/tests-unit/tests/utils/cache-get.test.ts index d15c65f2..75560e2c 100644 --- a/packages/tests-unit/tests/utils/cache-get.test.ts +++ b/packages/tests-unit/tests/utils/cache-get.test.ts @@ -186,6 +186,22 @@ describe("parseCacheGetResponse", () => { }); }); + it("should reconstruct a `false` revalidate", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-revalidate": "false", + }; + const result = parseCacheGetResponse(headers, "route body"); + + expect(result!.value).toEqual({ + type: "route", + body: "route body", + revalidate: false, + }); + }); + it("should reconstruct a page cache entry", () => { const headers = { "x-opennext-cache-found": "true", From d784d4d48344b9be663c15ad9a06cf1732b7ed06 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 16 Aug 2026 13:08:49 +0200 Subject: [PATCH 15/17] fix flaky test --- .../tests-unit/tests/adapters/cache-adapter.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index e56d70dc..71c9fd47 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -4,7 +4,7 @@ import { handler } from "@opennextjs/core/adapters/cache-adapter"; import type { InternalEvent, InternalResult, OpenNextConfig } from "@opennextjs/core/types/open-next"; import { CACHE_ONE_YEAR } from "@opennextjs/core/utils/cache-control"; import { fromReadableStream } from "@opennextjs/core/utils/stream"; -import { type Mock, vi, describe, expect, it, beforeEach } from "vitest"; +import { type Mock, vi, describe, expect, it, afterEach, beforeEach } from "vitest"; const mockResolveIncrementalCache = vi.hoisted(() => vi.fn()); const mockResolveTagCache = vi.hoisted(() => vi.fn()); @@ -268,6 +268,16 @@ describe("cache-adapter", () => { }); describe("Cache-Control and cache-tag in GET", () => { + // The computed ttls are relative to `Date.now()`, freeze it so that they are deterministic. + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(1_700_000_000_000); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("should not store a miss", async () => { mockIncrementalCache.get.mockResolvedValue(null); From 83566e223cb5455d8b4fdeb517ce00f048c31dd6 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 16 Aug 2026 14:08:10 +0200 Subject: [PATCH 16/17] chore: update wrangler dependency to version 4.107.0 --- create-cloudflare/next/package.json | 2 +- pnpm-lock.yaml | 751 +++++++++++++++++++++++++--- pnpm-workspace.yaml | 2 +- 3 files changed, 678 insertions(+), 77 deletions(-) diff --git a/create-cloudflare/next/package.json b/create-cloudflare/next/package.json index 082d5397..92e08653 100644 --- a/create-cloudflare/next/package.json +++ b/create-cloudflare/next/package.json @@ -26,6 +26,6 @@ "oxlint": "^1.42.0", "tailwindcss": "^4", "typescript": "catalog:", - "wrangler": "^4.59.3" + "wrangler": "^4.107.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee6bd626..44aeb145 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,8 +80,8 @@ catalogs: specifier: ^2.1.1 version: 2.1.3 wrangler: - specifier: ^4.59.2 - version: 4.60.0 + specifier: ^4.107.0 + version: 4.123.0 yargs: specifier: ^18.0.0 version: 18.0.0 @@ -144,7 +144,7 @@ importers: dependencies: '@opennextjs/cloudflare': specifier: ^1.17.1 - version: 1.18.0(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0)) + version: 1.18.0(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.123.0) next: specifier: 16.1.4 version: 16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) @@ -177,8 +177,8 @@ importers: specifier: 'catalog:' version: 6.0.3 wrangler: - specifier: ^4.59.3 - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + specifier: ^4.107.0 + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/e2e/app-pages-router: dependencies: @@ -224,7 +224,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/e2e/app-router: dependencies: @@ -270,7 +270,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/e2e/experimental: dependencies: @@ -304,7 +304,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/e2e/pages-router: dependencies: @@ -350,7 +350,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/e2e/shared: dependencies: @@ -403,7 +403,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/overrides/kv-tag-next: dependencies: @@ -437,7 +437,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/overrides/memory-queue: dependencies: @@ -471,7 +471,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/overrides/r2-incremental-cache: dependencies: @@ -505,7 +505,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/overrides/static-assets-incremental-cache: dependencies: @@ -539,7 +539,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/playground16: dependencies: @@ -576,7 +576,7 @@ importers: version: 4.1.18 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/prisma: dependencies: @@ -616,7 +616,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) examples/app-pages-router: dependencies: @@ -925,7 +925,7 @@ importers: version: 0.8.6 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.123.0(@cloudflare/workers-types@4.20260123.0) yargs: specifier: 'catalog:' version: 18.0.0 @@ -1887,45 +1887,45 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cloudflare/kv-asset-handler@0.4.2': - resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} - engines: {node: '>=18.0.0'} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} - '@cloudflare/unenv-preset@2.11.0': - resolution: {integrity: sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg==} + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} peerDependencies: unenv: 2.0.0-rc.24 - workerd: ^1.20260115.0 + workerd: '>1.20260305.0 <2.0.0-0' peerDependenciesMeta: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260120.0': - resolution: {integrity: sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q==} + '@cloudflare/workerd-darwin-64@1.20260811.1': + resolution: {integrity: sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260120.0': - resolution: {integrity: sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA==} + '@cloudflare/workerd-darwin-arm64@1.20260811.1': + resolution: {integrity: sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260120.0': - resolution: {integrity: sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ==} + '@cloudflare/workerd-linux-64@1.20260811.1': + resolution: {integrity: sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260120.0': - resolution: {integrity: sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA==} + '@cloudflare/workerd-linux-arm64@1.20260811.1': + resolution: {integrity: sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260120.0': - resolution: {integrity: sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA==} + '@cloudflare/workerd-windows-64@1.20260811.1': + resolution: {integrity: sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -1958,6 +1958,9 @@ packages: resolution: {integrity: sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==} engines: {node: '>=16'} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -1991,6 +1994,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.13': resolution: {integrity: sha512-j7NhycJUoUAG5kAzGf4fPWfd17N6SM3o1X6MlXVqfHvs2buFraCJzos9vbeWjLxOyBKHyPOnuCuipbhvbYtTAg==} engines: {node: '>=12'} @@ -2015,6 +2024,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.13': resolution: {integrity: sha512-KwqFhxRFMKZINHzCqf8eKxE0XqWlAVPRxwy6rc7CbVFxzUWB2sA/s3hbMZeemPdhN3fKBkqOaFhTbS8xJXYIWQ==} engines: {node: '>=12'} @@ -2039,6 +2054,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.13': resolution: {integrity: sha512-M2eZkRxR6WnWfVELHmv6MUoHbOqnzoTVSIxgtsyhm/NsgmL+uTmag/VVzdXvmahak1I6sOb1K/2movco5ikDJg==} engines: {node: '>=12'} @@ -2063,6 +2084,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.13': resolution: {integrity: sha512-f5goG30YgR1GU+fxtaBRdSW3SBG9pZW834Mmhxa6terzcboz7P2R0k4lDxlkP7NYRIIdBbWp+VgwQbmMH4yV7w==} engines: {node: '>=12'} @@ -2087,6 +2114,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.13': resolution: {integrity: sha512-RIrxoKH5Eo+yE5BtaAIMZaiKutPhZjw+j0OCh8WdvKEKJQteacq0myZvBDLU+hOzQOZWJeDnuQ2xgSScKf1Ovw==} engines: {node: '>=12'} @@ -2111,6 +2144,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.13': resolution: {integrity: sha512-AfRPhHWmj9jGyLgW/2FkYERKmYR+IjYxf2rtSLmhOrPGFh0KCETFzSjx/JX/HJnvIqHt/DRQD/KAaVsUKoI3Xg==} engines: {node: '>=12'} @@ -2135,6 +2174,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.13': resolution: {integrity: sha512-pGzWWZJBInhIgdEwzn8VHUBang8UvFKsvjDkeJ2oyY5gZtAM6BaxK0QLCuZY+qoj/nx/lIaItH425rm/hloETA==} engines: {node: '>=12'} @@ -2159,6 +2204,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.13': resolution: {integrity: sha512-hCzZbVJEHV7QM77fHPv2qgBcWxgglGFGCxk6KfQx6PsVIdi1u09X7IvgE9QKqm38OpkzaAkPnnPqwRsltvLkIQ==} engines: {node: '>=12'} @@ -2183,6 +2234,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.13': resolution: {integrity: sha512-4iMxLRMCxGyk7lEvkkvrxw4aJeC93YIIrfbBlUJ062kilUUnAiMb81eEkVvCVoh3ON283ans7+OQkuy1uHW+Hw==} engines: {node: '>=12'} @@ -2207,6 +2264,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.13': resolution: {integrity: sha512-I3OKGbynl3AAIO6onXNrup/ttToE6Rv2XYfFgLK/wnr2J+1g+7k4asLrE+n7VMhaqX+BUnyWkCu27rl+62Adug==} engines: {node: '>=12'} @@ -2231,6 +2294,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.13': resolution: {integrity: sha512-8pcKDApAsKc6WW51ZEVidSGwGbebYw2qKnO1VyD8xd6JN0RN6EUXfhXmDk9Vc4/U3Y4AoFTexQewQDJGsBXBpg==} engines: {node: '>=12'} @@ -2255,6 +2324,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.13': resolution: {integrity: sha512-6GU+J1PLiVqWx8yoCK4Z0GnfKyCGIH5L2KQipxOtbNPBs+qNDcMJr9euxnyJ6FkRPyMwaSkjejzPSISD9hb+gg==} engines: {node: '>=12'} @@ -2279,6 +2354,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.13': resolution: {integrity: sha512-pfn/OGZ8tyR8YCV7MlLl5hAit2cmS+j/ZZg9DdH0uxdCoJpV7+5DbuXrR+es4ayRVKIcfS9TTMCs60vqQDmh+w==} engines: {node: '>=12'} @@ -2303,6 +2384,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.13': resolution: {integrity: sha512-aIbhU3LPg0lOSCfVeGHbmGYIqOtW6+yzO+Nfv57YblEK01oj0mFMtvDJlOaeAZ6z0FZ9D13oahi5aIl9JFphGg==} engines: {node: '>=12'} @@ -2327,6 +2414,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.13': resolution: {integrity: sha512-Pct1QwF2sp+5LVi4Iu5Y+6JsGaV2Z2vm4O9Dd7XZ5tKYxEHjFtb140fiMcl5HM1iuv6xXO8O1Vrb1iJxHlv8UA==} engines: {node: '>=12'} @@ -2351,6 +2444,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.13': resolution: {integrity: sha512-zTrIP0KzYP7O0+3ZnmzvUKgGtUvf4+piY8PIO3V8/GfmVd3ZyHJGz7Ht0np3P1wz+I8qJ4rjwJKqqEAbIEPngA==} engines: {node: '>=12'} @@ -2375,6 +2474,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.4': resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} engines: {node: '>=18'} @@ -2387,6 +2492,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.13': resolution: {integrity: sha512-I6zs10TZeaHDYoGxENuksxE1sxqZpCp+agYeW039yqFwh3MgVvdmXL5NMveImOC6AtpLvE4xG5ujVic4NWFIDQ==} engines: {node: '>=12'} @@ -2411,6 +2522,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.4': resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} engines: {node: '>=18'} @@ -2423,6 +2540,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.13': resolution: {integrity: sha512-W5C5nczhrt1y1xPG5bV+0M12p2vetOGlvs43LH8SopQ3z2AseIROu09VgRqydx5qFN7y9qCbpgHLx0kb0TcW7g==} engines: {node: '>=12'} @@ -2447,12 +2570,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.0': resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.13': resolution: {integrity: sha512-X/xzuw4Hzpo/yq3YsfBbIsipNgmsm8mE/QeWbdGdTTeZ77fjxI2K0KP3AlhZ6gU3zKTw1bKoZTuKLnqcJ537qw==} engines: {node: '>=12'} @@ -2477,6 +2612,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.13': resolution: {integrity: sha512-4CGYdRQT/ILd+yLLE5i4VApMPfGE0RPc/wFQhlluDQCK09+b4JDbxzzjpgQqTPrdnP7r5KUtGVGZYclYiPuHrw==} engines: {node: '>=12'} @@ -2501,6 +2642,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.13': resolution: {integrity: sha512-D+wKZaRhQI+MUGMH+DbEr4owC2D7XnF+uyGiZk38QbgzLcofFqIOwFs7ELmIeU45CQgfHNy9Q+LKW3cE8g37Kg==} engines: {node: '>=12'} @@ -2525,6 +2672,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.13': resolution: {integrity: sha512-iVl6lehAfJS+VmpF3exKpNQ8b0eucf5VWfzR8S7xFve64NBNz2jPUgx1X93/kfnkfgP737O+i1k54SVQS7uVZA==} engines: {node: '>=12'} @@ -2549,6 +2702,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} @@ -2600,76 +2759,155 @@ packages: resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.34.5': resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2677,6 +2915,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2684,6 +2929,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2691,6 +2943,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2698,6 +2957,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2705,6 +2971,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2712,6 +2985,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2719,6 +2999,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2726,29 +3013,63 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -5129,6 +5450,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -6073,10 +6399,9 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - miniflare@4.20260120.0: - resolution: {integrity: sha512-XXZyE2pDKMtP5OLuv0LPHEAzIYhov4jrYjcqrhhqtxGGtXneWOHvXIPo+eV8sqwqWd3R7j4DlEKcyb+87BR49Q==} - engines: {node: '>=18.0.0'} - hasBin: true + miniflare@5.20260811.1-alpha: + resolution: {integrity: sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA==} + engines: {node: '>=22.0.0'} minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -6878,6 +7203,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -6905,6 +7235,10 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -7347,8 +7681,8 @@ packages: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} engines: {node: '>=14.0'} - undici@7.18.2: - resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.24: @@ -7568,17 +7902,17 @@ packages: worker-timers@7.1.8: resolution: {integrity: sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==} - workerd@1.20260120.0: - resolution: {integrity: sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw==} + workerd@1.20260811.1: + resolution: {integrity: sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw==} engines: {node: '>=16'} hasBin: true - wrangler@4.60.0: - resolution: {integrity: sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g==} - engines: {node: '>=20.0.0'} + wrangler@4.123.0: + resolution: {integrity: sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q==} + engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260120.0 + '@cloudflare/workers-types': ^5.20260811.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -7622,6 +7956,18 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -10147,27 +10493,27 @@ snapshots: human-id: 4.1.2 prettier: 2.8.8 - '@cloudflare/kv-asset-handler@0.4.2': {} + '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.11.0(unenv@2.0.0-rc.24)(workerd@1.20260120.0)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260811.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260120.0 + workerd: 1.20260811.1 - '@cloudflare/workerd-darwin-64@1.20260120.0': + '@cloudflare/workerd-darwin-64@1.20260811.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260120.0': + '@cloudflare/workerd-darwin-arm64@1.20260811.1': optional: true - '@cloudflare/workerd-linux-64@1.20260120.0': + '@cloudflare/workerd-linux-64@1.20260811.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260120.0': + '@cloudflare/workerd-linux-arm64@1.20260811.1': optional: true - '@cloudflare/workerd-windows-64@1.20260120.0': + '@cloudflare/workerd-windows-64@1.20260811.1': optional: true '@cloudflare/workers-types@4.20250214.0': {} @@ -10202,6 +10548,11 @@ snapshots: '@edge-runtime/primitives': 4.1.0 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -10233,6 +10584,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.0': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.18.13': optional: true @@ -10245,6 +10599,9 @@ snapshots: '@esbuild/android-arm64@0.27.0': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.18.13': optional: true @@ -10257,6 +10614,9 @@ snapshots: '@esbuild/android-arm@0.27.0': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.18.13': optional: true @@ -10269,6 +10629,9 @@ snapshots: '@esbuild/android-x64@0.27.0': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.18.13': optional: true @@ -10281,6 +10644,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.0': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.18.13': optional: true @@ -10293,6 +10659,9 @@ snapshots: '@esbuild/darwin-x64@0.27.0': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.18.13': optional: true @@ -10305,6 +10674,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.0': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.18.13': optional: true @@ -10317,6 +10689,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.0': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.18.13': optional: true @@ -10329,6 +10704,9 @@ snapshots: '@esbuild/linux-arm64@0.27.0': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.18.13': optional: true @@ -10341,6 +10719,9 @@ snapshots: '@esbuild/linux-arm@0.27.0': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.18.13': optional: true @@ -10353,6 +10734,9 @@ snapshots: '@esbuild/linux-ia32@0.27.0': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.18.13': optional: true @@ -10365,6 +10749,9 @@ snapshots: '@esbuild/linux-loong64@0.27.0': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.18.13': optional: true @@ -10377,6 +10764,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.0': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.18.13': optional: true @@ -10389,6 +10779,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.0': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.18.13': optional: true @@ -10401,6 +10794,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.0': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.18.13': optional: true @@ -10413,6 +10809,9 @@ snapshots: '@esbuild/linux-s390x@0.27.0': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.18.13': optional: true @@ -10425,12 +10824,18 @@ snapshots: '@esbuild/linux-x64@0.27.0': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.25.4': optional: true '@esbuild/netbsd-arm64@0.27.0': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.18.13': optional: true @@ -10443,12 +10848,18 @@ snapshots: '@esbuild/netbsd-x64@0.27.0': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.25.4': optional: true '@esbuild/openbsd-arm64@0.27.0': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.18.13': optional: true @@ -10461,9 +10872,15 @@ snapshots: '@esbuild/openbsd-x64@0.27.0': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.27.0': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.18.13': optional: true @@ -10476,6 +10893,9 @@ snapshots: '@esbuild/sunos-x64@0.27.0': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.18.13': optional: true @@ -10488,6 +10908,9 @@ snapshots: '@esbuild/win32-arm64@0.27.0': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.18.13': optional: true @@ -10500,6 +10923,9 @@ snapshots: '@esbuild/win32-ia32@0.27.0': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.18.13': optional: true @@ -10512,6 +10938,9 @@ snapshots: '@esbuild/win32-x64@0.27.0': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@fastify/busboy@2.1.1': {} '@graphql-tools/executor@0.0.18(graphql@16.9.0)': @@ -10583,102 +11012,209 @@ snapshots: - bufferutil - utf-8-validate - '@img/colour@1.0.0': {} + '@img/colour@1.0.0': + optional: true + + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.8.1 optional: true + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.2': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.2': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.2': + optional: true + '@inquirer/external-editor@1.0.3(@types/node@24.13.2)': dependencies: chardet: 2.1.1 @@ -10935,7 +11471,7 @@ snapshots: - aws-crt - supports-color - '@opennextjs/cloudflare@1.18.0(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0))': + '@opennextjs/cloudflare@1.18.0(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.123.0)': dependencies: '@ast-grep/napi': 0.40.5 '@dotenvx/dotenvx': 1.31.0 @@ -10946,7 +11482,7 @@ snapshots: glob: 12.0.0 next: 16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) ts-tqdm: 0.8.6 - wrangler: 4.60.0(@cloudflare/workers-types@4.20260123.0) + wrangler: 4.123.0(@cloudflare/workers-types@4.20260123.0) yargs: 18.0.0 transitivePeerDependencies: - aws-crt @@ -13421,6 +13957,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.0 '@esbuild/win32-x64': 0.27.0 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -14424,15 +14989,14 @@ snapshots: mimic-response@3.1.0: optional: true - miniflare@4.20260120.0: + miniflare@5.20260811.1-alpha: dependencies: '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 - undici: 7.18.2 - workerd: 1.20260120.0 - ws: 8.18.0 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260811.1 + ws: 8.21.0 youch: 4.1.0-beta.10 - zod: 3.25.76 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -15330,6 +15894,8 @@ snapshots: semver@7.7.3: {} + semver@7.8.5: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -15423,6 +15989,39 @@ snapshots: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + optional: true + + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 shebang-command@2.0.0: dependencies: @@ -16020,7 +16619,7 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 - undici@7.18.2: {} + undici@7.29.0: {} unenv@2.0.0-rc.24: dependencies: @@ -16247,24 +16846,24 @@ snapshots: worker-timers-broker: 6.1.8 worker-timers-worker: 7.0.71 - workerd@1.20260120.0: + workerd@1.20260811.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260120.0 - '@cloudflare/workerd-darwin-arm64': 1.20260120.0 - '@cloudflare/workerd-linux-64': 1.20260120.0 - '@cloudflare/workerd-linux-arm64': 1.20260120.0 - '@cloudflare/workerd-windows-64': 1.20260120.0 + '@cloudflare/workerd-darwin-64': 1.20260811.1 + '@cloudflare/workerd-darwin-arm64': 1.20260811.1 + '@cloudflare/workerd-linux-64': 1.20260811.1 + '@cloudflare/workerd-linux-arm64': 1.20260811.1 + '@cloudflare/workerd-windows-64': 1.20260811.1 - wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0): + wrangler@4.123.0(@cloudflare/workers-types@4.20260123.0): dependencies: - '@cloudflare/kv-asset-handler': 0.4.2 - '@cloudflare/unenv-preset': 2.11.0(unenv@2.0.0-rc.24)(workerd@1.20260120.0) + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260811.1) blake3-wasm: 2.1.5 - esbuild: 0.27.0 - miniflare: 4.20260120.0 + esbuild: 0.28.1 + miniflare: 5.20260811.1-alpha path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260120.0 + workerd: 1.20260811.1 optionalDependencies: '@cloudflare/workers-types': 4.20260123.0 fsevents: 2.3.3 @@ -16296,6 +16895,8 @@ snapshots: ws@8.18.0: {} + ws@8.21.0: {} + xml-name-validator@4.0.0: {} xml2js@0.6.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 41ba1627..85823804 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,7 +30,7 @@ catalog: tsx: ^4.19.2 typescript: ^6.0.3 vitest: ^2.1.1 - wrangler: ^4.59.2 + wrangler: ^4.107.0 yargs: ^18.0.0 catalogs: From 01bae162c5e6f9927f686ce2eefd60a7dd4d4a82 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 16 Aug 2026 14:08:53 +0200 Subject: [PATCH 17/17] feat(cache): configure Workers caching for OpenNext entrypoints --- .changeset/cloudflare-cache-service.md | 12 ++++++++++++ create-cloudflare/next/wrangler.jsonc | 7 +++++++ .../e2e/app-pages-router/wrangler.jsonc | 7 +++++++ examples-cloudflare/e2e/app-router/wrangler.jsonc | 7 +++++++ examples-cloudflare/e2e/experimental/wrangler.jsonc | 7 +++++++ examples-cloudflare/e2e/pages-router/wrangler.jsonc | 7 +++++++ .../overrides/d1-tag-next/wrangler.e2e.jsonc | 7 +++++++ .../overrides/kv-tag-next/wrangler.e2e.jsonc | 7 +++++++ .../overrides/memory-queue/wrangler.jsonc | 7 +++++++ .../overrides/r2-incremental-cache/wrangler.jsonc | 7 +++++++ .../static-assets-incremental-cache/wrangler.jsonc | 7 +++++++ examples-cloudflare/playground16/wrangler.jsonc | 7 +++++++ examples-cloudflare/prisma/wrangler.jsonc | 7 +++++++ packages/cloudflare/templates/wrangler.jsonc | 7 +++++++ 14 files changed, 103 insertions(+) diff --git a/.changeset/cloudflare-cache-service.md b/.changeset/cloudflare-cache-service.md index 6cc63787..bc740950 100644 --- a/.changeset/cloudflare-cache-service.md +++ b/.changeset/cloudflare-cache-service.md @@ -24,6 +24,18 @@ This requires a new self referencing service binding in the wrangler configurati The cache runs in the same worker by default. Pointing the binding at another worker is enough to run the cache as a service of its own. +Workers Caching should also be configured per entrypoint so that only the cache entrypoint is served +from the Workers cache, never the Next.js server. This requires wrangler `4.107.0` or greater: + +```jsonc +"exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } +} +``` + `defineCloudflareConfig` is otherwise unchanged. Configurations that are not created by `defineCloudflareConfig` should move `incrementalCache` and `tagCache` from `default.override` to the new top level `cacheHandler` option, and set `default.override.cache`. diff --git a/create-cloudflare/next/wrangler.jsonc b/create-cloudflare/next/wrangler.jsonc index 0819a091..61c3bce1 100644 --- a/create-cloudflare/next/wrangler.jsonc +++ b/create-cloudflare/next/wrangler.jsonc @@ -17,6 +17,13 @@ // see https://opennext.js.org/cloudflare/howtos/image "binding": "IMAGES" }, + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { // Self-reference service binding, the service name must match the worker name diff --git a/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc b/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc index 1ab5ec7f..67c1542f 100644 --- a/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc @@ -14,6 +14,13 @@ "bucket_name": "cache" } ], + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { "binding": "WORKER_SELF_REFERENCE", diff --git a/examples-cloudflare/e2e/app-router/wrangler.jsonc b/examples-cloudflare/e2e/app-router/wrangler.jsonc index eaf0fced..e2923887 100644 --- a/examples-cloudflare/e2e/app-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/app-router/wrangler.jsonc @@ -36,6 +36,13 @@ "bucket_name": "cache" } ], + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { "binding": "WORKER_SELF_REFERENCE", diff --git a/examples-cloudflare/e2e/experimental/wrangler.jsonc b/examples-cloudflare/e2e/experimental/wrangler.jsonc index 0ca406a9..3cc7ed56 100644 --- a/examples-cloudflare/e2e/experimental/wrangler.jsonc +++ b/examples-cloudflare/e2e/experimental/wrangler.jsonc @@ -35,6 +35,13 @@ "bucket_name": "cache" } ], + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { "binding": "WORKER_SELF_REFERENCE", diff --git a/examples-cloudflare/e2e/pages-router/wrangler.jsonc b/examples-cloudflare/e2e/pages-router/wrangler.jsonc index 92f56c40..7a0fab35 100644 --- a/examples-cloudflare/e2e/pages-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/pages-router/wrangler.jsonc @@ -14,6 +14,13 @@ "bucket_name": "cache" } ], + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { "binding": "WORKER_SELF_REFERENCE", diff --git a/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc b/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc index 5d53c198..7631a93d 100644 --- a/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc +++ b/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS", }, + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } }, + }, "services": [ { "binding": "NEXT_CACHE_SERVICE", diff --git a/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc b/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc index bbc50d20..62d6cb94 100644 --- a/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc +++ b/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS", }, + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } }, + }, "services": [ { "binding": "NEXT_CACHE_SERVICE", diff --git a/examples-cloudflare/overrides/memory-queue/wrangler.jsonc b/examples-cloudflare/overrides/memory-queue/wrangler.jsonc index 1cd31f32..4f29c1b8 100644 --- a/examples-cloudflare/overrides/memory-queue/wrangler.jsonc +++ b/examples-cloudflare/overrides/memory-queue/wrangler.jsonc @@ -14,6 +14,13 @@ "id": "" } ], + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { "binding": "WORKER_SELF_REFERENCE", diff --git a/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc b/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc index 6f61040f..2edc996d 100644 --- a/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc +++ b/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { "binding": "NEXT_CACHE_SERVICE", diff --git a/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc b/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc index 817baad5..f4074213 100644 --- a/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc +++ b/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { "binding": "NEXT_CACHE_SERVICE", diff --git a/examples-cloudflare/playground16/wrangler.jsonc b/examples-cloudflare/playground16/wrangler.jsonc index 3dfe7a54..0c8829a7 100644 --- a/examples-cloudflare/playground16/wrangler.jsonc +++ b/examples-cloudflare/playground16/wrangler.jsonc @@ -17,6 +17,13 @@ "vars": { "hello": "Hello World from the cloudflare context!" }, + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { "binding": "WORKER_SELF_REFERENCE", diff --git a/examples-cloudflare/prisma/wrangler.jsonc b/examples-cloudflare/prisma/wrangler.jsonc index 7f9dd8c1..de44ec8a 100644 --- a/examples-cloudflare/prisma/wrangler.jsonc +++ b/examples-cloudflare/prisma/wrangler.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { "binding": "NEXT_CACHE_SERVICE", diff --git a/packages/cloudflare/templates/wrangler.jsonc b/packages/cloudflare/templates/wrangler.jsonc index 702c0aea..dd861ed2 100644 --- a/packages/cloudflare/templates/wrangler.jsonc +++ b/packages/cloudflare/templates/wrangler.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "exports": { + // The Next.js server must not be served from the Workers cache. + "default": { "type": "worker", "cache": { "enabled": false } }, + // The OpenNext cache entrypoint returns cacheable responses, cache them. + // see https://opennext.js.org/cloudflare/caching + "OpenNextCache": { "type": "worker", "cache": { "enabled": true } } + }, "services": [ { // Self-reference service binding, the service name must match the worker name