From be165ee3a7bdd948c37ae889b890f6d500702833 Mon Sep 17 00:00:00 2001 From: H01001000 Date: Wed, 19 Nov 2025 00:04:44 -0800 Subject: [PATCH] feat: first cache-handlers attempt --- .../src/handlers/cache-handler.ts | 265 +++++++++--------- .../src/handlers/cache-handler.types.ts | 31 +- .../src/handlers/cache-handlers.ts | 136 +++++++++ .../src/handlers/redis-strings.ts | 14 +- .../src/handlers/redis-strings.types.ts | 11 + .../src/helpers/buffer.ts | 37 ++- 6 files changed, 347 insertions(+), 147 deletions(-) create mode 100644 packages/nextjs-cache-handler/src/handlers/cache-handlers.ts diff --git a/packages/nextjs-cache-handler/src/handlers/cache-handler.ts b/packages/nextjs-cache-handler/src/handlers/cache-handler.ts index 8c9b189..626d902 100644 --- a/packages/nextjs-cache-handler/src/handlers/cache-handler.ts +++ b/packages/nextjs-cache-handler/src/handlers/cache-handler.ts @@ -13,6 +13,8 @@ import { Handler, OnCreationHook, Revalidate, + CacheHandlerMeta, + CacheHandlersValue, } from "./cache-handler.types"; import { PrerenderManifest } from "next/dist/build"; import { @@ -37,11 +39,9 @@ const PRERENDER_MANIFEST_VERSION = 4; * * @returns A Promise that resolves when all handlers have finished deleting the entry. */ -async function removeEntryFromHandlers( - handlers: Handler[], - key: string, - debug: boolean, -): Promise { +async function removeEntryFromHandlers< + T extends CacheHandlerValue | CacheHandlersValue = CacheHandlerValue, +>(handlers: Handler[], key: string, debug: boolean): Promise { if (debug) { console.info( "[CacheHandler] [method: %s] [key: %s] %s", @@ -80,6 +80,136 @@ async function removeEntryFromHandlers( }); } +export function createMergedHandler< + T extends CacheHandlerValue | CacheHandlersValue = CacheHandlerValue, +>(handlersList: Handler[]): Omit, "name"> { + const debug = typeof process.env.NEXT_PRIVATE_DEBUG_CACHE !== "undefined"; + + return { + async get(key, meta) { + for (const handler of handlersList) { + if (debug) { + console.info( + "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", + handler.name, + "get", + key, + "Started retrieving value.", + ); + } + + try { + let cacheHandlerValue = await handler.get(key, meta); + + if ( + cacheHandlerValue?.lifespan && + cacheHandlerValue.lifespan.expireAt < Math.floor(Date.now() / 1000) + ) { + if (debug) { + console.info( + "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", + handler.name, + "get", + key, + "Entry expired.", + ); + } + + cacheHandlerValue = null; + + // remove the entry from all handlers in background + removeEntryFromHandlers(handlersList, key, debug); + } + + if (cacheHandlerValue && debug) { + console.info( + "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", + handler.name, + "get", + key, + "Successfully retrieved value.", + ); + } + + return cacheHandlerValue; + } catch (error) { + if (debug) { + console.warn( + "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", + handler.name, + "get", + key, + `Error: ${error}`, + ); + } + } + } + + return null; + }, + async set(key, cacheHandlerValue) { + const operationsResults = await Promise.allSettled( + handlersList.map((handler) => + handler.set(key, { ...cacheHandlerValue }), + ), + ); + + if (!debug) { + return; + } + + operationsResults.forEach((handlerResult, index) => { + if (handlerResult.status === "rejected") { + console.warn( + "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", + handlersList[index]?.name ?? `unknown-${index}`, + "set", + key, + `Error: ${handlerResult.reason}`, + ); + } else { + console.info( + "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", + handlersList[index]?.name ?? `unknown-${index}`, + "set", + key, + "Successfully set value.", + ); + } + }); + }, + async revalidateTag(tag) { + const operationsResults = await Promise.allSettled( + handlersList.map((handler) => handler.revalidateTag(tag)), + ); + + if (!debug) { + return; + } + + operationsResults.forEach((handlerResult, index) => { + if (handlerResult.status === "rejected") { + console.warn( + "[CacheHandler] [handler: %s] [method: %s] [tag: %s] %s", + handlersList[index]?.name ?? `unknown-${index}`, + "revalidateTag", + tag, + `Error: ${handlerResult.reason}`, + ); + } else { + console.info( + "[CacheHandler] [handler: %s] [method: %s] [tag: %s] %s", + handlersList[index]?.name ?? `unknown-${index}`, + "revalidateTag", + tag, + "Successfully revalidated tag.", + ); + } + }); + }, + }; +} + export class CacheHandler implements NextCacheHandler { /** * Provides a descriptive name for the CacheHandler class. @@ -496,130 +626,7 @@ export class CacheHandler implements NextCacheHandler { CacheHandler.#cacheListLength = handlersList.length; - CacheHandler.#mergedHandler = { - async get(key, meta) { - for (const handler of handlersList) { - if (CacheHandler.#debug) { - console.info( - "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", - handler.name, - "get", - key, - "Started retrieving value.", - ); - } - - try { - let cacheHandlerValue = await handler.get(key, meta); - - if ( - cacheHandlerValue?.lifespan && - cacheHandlerValue.lifespan.expireAt < - Math.floor(Date.now() / 1000) - ) { - if (CacheHandler.#debug) { - console.info( - "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", - handler.name, - "get", - key, - "Entry expired.", - ); - } - - cacheHandlerValue = null; - - // remove the entry from all handlers in background - removeEntryFromHandlers(handlersList, key, CacheHandler.#debug); - } - - if (cacheHandlerValue && CacheHandler.#debug) { - console.info( - "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", - handler.name, - "get", - key, - "Successfully retrieved value.", - ); - } - - return cacheHandlerValue; - } catch (error) { - if (CacheHandler.#debug) { - console.warn( - "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", - handler.name, - "get", - key, - `Error: ${error}`, - ); - } - } - } - - return null; - }, - async set(key, cacheHandlerValue) { - const operationsResults = await Promise.allSettled( - handlersList.map((handler) => - handler.set(key, { ...cacheHandlerValue }), - ), - ); - - if (!CacheHandler.#debug) { - return; - } - - operationsResults.forEach((handlerResult, index) => { - if (handlerResult.status === "rejected") { - console.warn( - "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", - handlersList[index]?.name ?? `unknown-${index}`, - "set", - key, - `Error: ${handlerResult.reason}`, - ); - } else { - console.info( - "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", - handlersList[index]?.name ?? `unknown-${index}`, - "set", - key, - "Successfully set value.", - ); - } - }); - }, - async revalidateTag(tag) { - const operationsResults = await Promise.allSettled( - handlersList.map((handler) => handler.revalidateTag(tag)), - ); - - if (!CacheHandler.#debug) { - return; - } - - operationsResults.forEach((handlerResult, index) => { - if (handlerResult.status === "rejected") { - console.warn( - "[CacheHandler] [handler: %s] [method: %s] [tag: %s] %s", - handlersList[index]?.name ?? `unknown-${index}`, - "revalidateTag", - tag, - `Error: ${handlerResult.reason}`, - ); - } else { - console.info( - "[CacheHandler] [handler: %s] [method: %s] [tag: %s] %s", - handlersList[index]?.name ?? `unknown-${index}`, - "revalidateTag", - tag, - "Successfully revalidated tag.", - ); - } - }); - }, - }; + CacheHandler.#mergedHandler = createMergedHandler(handlersList); if (CacheHandler.#debug) { console.info( diff --git a/packages/nextjs-cache-handler/src/handlers/cache-handler.types.ts b/packages/nextjs-cache-handler/src/handlers/cache-handler.types.ts index 54d5263..bbe3cd7 100644 --- a/packages/nextjs-cache-handler/src/handlers/cache-handler.types.ts +++ b/packages/nextjs-cache-handler/src/handlers/cache-handler.types.ts @@ -1,3 +1,4 @@ +import { CacheEntry } from "next/dist/server/lib/cache-handlers/types.js"; import type { CacheHandler as NextCacheHandler, CacheHandlerValue as NextCacheHandlerValue, @@ -59,7 +60,9 @@ export type HandlerGetMeta = { /** * Represents a cache Handler. */ -export type Handler = { +export type Handler< + T extends CacheHandlerValue | CacheHandlersValue = CacheHandlerValue, +> = { /** * A descriptive name for the cache Handler. */ @@ -114,16 +117,13 @@ export type Handler = { * } * ``` */ - get: ( - key: string, - meta: HandlerGetMeta, - ) => Promise; + get: (key: string, meta: HandlerGetMeta) => Promise; /** * Sets or updates a value in the cache store. * * @param key - The unique string identifier for the cache entry. * - * @param value - The value to be stored in the cache. See {@link CacheHandlerValue}. + * @param value - The value to be stored in the cache. See {@link T}. * * @returns A Promise that resolves when the value has been successfully set in the cache. * @@ -137,7 +137,7 @@ export type Handler = { * * Use the absolute time (`expireAt`) to set and expiration time for the cache entry in your cache store to be in sync with the file system cache. */ - set: (key: string, value: CacheHandlerValue) => Promise; + set: (key: string, value: T) => Promise; /** * Deletes all cache entries that are associated with the specified tag. * See [fetch `options.next.tags` and `revalidateTag` ↗](https://nextjs.org/docs/app/building-your-application/caching#fetch-optionsnexttags-and-revalidatetag) @@ -183,12 +183,14 @@ export type TTLParameters = { /** * Configuration options for the {@link CacheHandler}. */ -export type CacheHandlerConfig = { +export type CacheHandlerConfig< + T extends CacheHandlerValue | CacheHandlersValue = CacheHandlerValue, +> = { /** * An array of cache instances that conform to the Handler interface. * Multiple caches can be used to implement various caching strategies or layers. */ - handlers: (Handler | undefined | null)[]; + handlers: (Handler | undefined | null)[]; /** * Time-to-live (TTL) options for the cache entries. */ @@ -329,7 +331,7 @@ export type FileSystemCacheContext = ConstructorParameters< typeof FileSystemCache >[0]; -export type CacheHandlerValue = NextCacheHandlerValue & { +export type CacheHandlerMeta = { /** * Timestamp in milliseconds when the cache entry was last modified. */ @@ -346,3 +348,12 @@ export type CacheHandlerValue = NextCacheHandlerValue & { */ lifespan: LifespanParameters | null; }; + +export type CacheHandlerValue = NextCacheHandlerValue & CacheHandlerMeta; + +export type BufferedCacheEntry = Omit & { + value: Buffer; +}; +export type CacheHandlersValue = { + value: BufferedCacheEntry; +} & CacheHandlerMeta; diff --git a/packages/nextjs-cache-handler/src/handlers/cache-handlers.ts b/packages/nextjs-cache-handler/src/handlers/cache-handlers.ts new file mode 100644 index 0000000..cd1354f --- /dev/null +++ b/packages/nextjs-cache-handler/src/handlers/cache-handlers.ts @@ -0,0 +1,136 @@ +import { createMergedHandler } from "./cache-handler.js"; +import type { + CacheHandlerConfig, + CacheHandlersValue, + Handler, + LifespanParameters, +} from "./cache-handler.types.js"; +import type { CacheHandler as CacheHandlerType } from "next/dist/server/lib/cache-handlers/types"; + +export async function createCacheHandlers( + config: CacheHandlerConfig, +): Promise { + const handlersList: Handler[] = config.handlers.filter( + (handler) => !!handler, + ); + const memoryCache = createMergedHandler(handlersList); + + const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE + ? console.debug.bind(console, "CustomCacheHandler:") + : undefined; + + function getLifespanParameters( + revalidate: number, + timestamp: number, + expire: number, + ): LifespanParameters { + const lastModifiedAt = Math.floor(timestamp / 1000); + const staleAge = revalidate; + const staleAt = lastModifiedAt + staleAge; + const expireAge = expire; + const expireAt = lastModifiedAt + expireAge; + + return { + expireAge, + expireAt, + lastModifiedAt, + revalidate, + staleAge, + staleAt, + }; + } + + return { + async get(cacheKey, softTags) { + const cachedData = await memoryCache.get(cacheKey, { + implicitTags: softTags, + }); + + if (!cachedData) { + debug?.("get", cacheKey, "not found"); + return undefined; + } + + const entry = cachedData.value; + + debug?.("get", cacheKey, "found", { + ...cachedData, + value: { + ...entry, + value: "[ReadableStream]", + }, + }); + + return { + ...entry, + value: new ReadableStream({ + start(controller) { + controller.enqueue(entry.value); + controller.close(); + }, + }), + }; + }, + + async set(cacheKey, pendingEntry) { + debug?.("set", cacheKey, "start"); + + const entry = await pendingEntry; + + // See: https://github.com/vercel/next.js/blob/aba8a9fb890c0a236fe86d38cb946c7df01c3d2f/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx?plain=1#L368-L382 + const reader = entry.value.getReader(); + const chunks = []; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const data = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); + + const lifespan = getLifespanParameters( + entry.revalidate, + entry.timestamp, + entry.expire, + ); + + // If expireAt is in the past, do not cache + if (lifespan !== null && Date.now() > lifespan.expireAt * 1000) { + return; + } + + const cacheHandlerValueTags = entry.tags; + + const cacheHandlerValue: CacheHandlersValue = { + lastModified: entry.timestamp, + lifespan, + tags: Object.freeze(cacheHandlerValueTags), + value: { + ...entry, + value: data, + }, + }; + + await memoryCache.set(cacheKey, cacheHandlerValue); + }, + + async refreshTags() { + // Nothing to do for an in-memory cache handler. + }, + + async getExpiration() { + return Infinity; + }, + + async updateTags(tags, durations) { + await Promise.all( + tags.map(async (tag) => memoryCache.revalidateTag(tag)), + ); + }, + }; +} diff --git a/packages/nextjs-cache-handler/src/handlers/redis-strings.ts b/packages/nextjs-cache-handler/src/handlers/redis-strings.ts index 2355d6f..a389969 100644 --- a/packages/nextjs-cache-handler/src/handlers/redis-strings.ts +++ b/packages/nextjs-cache-handler/src/handlers/redis-strings.ts @@ -1,6 +1,10 @@ import { REVALIDATED_TAGS_KEY } from "../constants"; import { isImplicitTag } from "../helpers/isImplicitTag"; -import { CacheHandlerValue, Handler } from "./cache-handler.types"; +import { + CacheHandlersValue, + CacheHandlerValue, + Handler, +} from "./cache-handler.types"; import { CreateRedisStringsHandlerOptions } from "./redis-strings.types"; import { convertStringsToBuffers, @@ -25,7 +29,9 @@ import { withAbortSignalProxy } from "../helpers/withAbortSignalProxy"; * - The `set` method stores a value in the cache, using the configured expiration strategy. * - The `revalidateTag` and `delete` methods handle cache revalidation and deletion. */ -export default function createHandler({ +export default function createHandler< + T extends CacheHandlerValue | CacheHandlersValue = CacheHandlerValue, +>({ client: innerClient, keyPrefix = "", sharedTagsKey = "__sharedTags__", @@ -35,7 +41,7 @@ export default function createHandler({ revalidateTagQuerySize = 10_000, }: CreateRedisStringsHandlerOptions< RedisClientType | RedisClusterCacheAdapter ->): Handler { +>): Handler { const client = withAbortSignalProxy(innerClient); const revalidatedTagsKey = keyPrefix + REVALIDATED_TAGS_KEY; @@ -176,7 +182,7 @@ export default function createHandler({ return null; } - const cacheValue = JSON.parse(result) as CacheHandlerValue | null; + const cacheValue = JSON.parse(result) as T | null; if (!cacheValue) { return null; diff --git a/packages/nextjs-cache-handler/src/handlers/redis-strings.types.ts b/packages/nextjs-cache-handler/src/handlers/redis-strings.types.ts index 3ba219a..544e3fd 100644 --- a/packages/nextjs-cache-handler/src/handlers/redis-strings.types.ts +++ b/packages/nextjs-cache-handler/src/handlers/redis-strings.types.ts @@ -1,5 +1,6 @@ import type { RedisClientType } from "@redis/client"; import { RedisClusterCacheAdapter } from "../helpers/redisClusterAdapter"; +import { Timestamp } from "next/dist/server/lib/cache-handlers/types.js"; export type RedisCompliantCachedRouteValue = { // See: https://github.com/vercel/next.js/blob/f5444a16ec2ef7b82d30048890b613aa3865c1f1/packages/next/src/server/response-cache/types.ts#L97 @@ -14,6 +15,16 @@ export type RedisCompliantCachedAppPageValue = { segmentData: Record | undefined; }; +export type RedisCompliantCacheEntry = { + // See: https://github.com/vercel/next.js/blob/954354f2ab24002bd310940438e7bf0116e646ab/packages/next/src/server/lib/cache-handlers/types.ts#L12 + value: string; + tags: string[]; + stale: number; + timestamp: Timestamp; + expire: number; + revalidate: number; +}; + export type CreateRedisStringsHandlerOptions< T = RedisClientType | RedisClusterCacheAdapter, > = { diff --git a/packages/nextjs-cache-handler/src/helpers/buffer.ts b/packages/nextjs-cache-handler/src/helpers/buffer.ts index fdaf0ba..6e9cb58 100644 --- a/packages/nextjs-cache-handler/src/helpers/buffer.ts +++ b/packages/nextjs-cache-handler/src/helpers/buffer.ts @@ -3,18 +3,35 @@ import { CachedRouteValue, IncrementalCachedAppPageValue, } from "next/dist/server/response-cache"; -import { CacheHandlerValue } from "../handlers/cache-handler.types"; +import { + BufferedCacheEntry, + CacheHandlersValue, + CacheHandlerValue, +} from "../handlers/cache-handler.types"; import { RedisCompliantCachedRouteValue, RedisCompliantCachedAppPageValue, + RedisCompliantCacheEntry, } from "../handlers/redis-strings.types"; -export function parseBuffersToStrings(cacheHandlerValue: CacheHandlerValue) { +export function parseBuffersToStrings( + cacheHandlerValue: CacheHandlerValue | CacheHandlersValue, +) { if (!cacheHandlerValue?.value) { return; } - const value: IncrementalCacheValue | null = cacheHandlerValue.value; + const value: IncrementalCacheValue | BufferedCacheEntry | null = + cacheHandlerValue.value; + + if (value && !("kind" in value)) { + const cacheEntryData = value as unknown as RedisCompliantCacheEntry; + const cacheEntryValue = value as unknown as BufferedCacheEntry; + + cacheEntryData.value = cacheEntryValue.value.toString("base64"); + + return; + } const kind = value?.kind; @@ -50,8 +67,20 @@ export function parseBuffersToStrings(cacheHandlerValue: CacheHandlerValue) { } } -export function convertStringsToBuffers(cacheValue: CacheHandlerValue) { +export function convertStringsToBuffers( + cacheValue: CacheHandlerValue | CacheHandlersValue, +) { const value = cacheValue.value; + + if (value && !("kind" in value)) { + const cacheEntryData = value as unknown as RedisCompliantCacheEntry; + const cacheEntryValue = value as unknown as BufferedCacheEntry; + + cacheEntryValue.value = Buffer.from(cacheEntryData.value, "base64"); + + return; + } + const kind = value?.kind; if (kind === "APP_ROUTE") {