UNPKG

@fortedigital/nextjs-cache-handler

Version:
613 lines (605 loc) 20.1 kB
// src/handlers/cache-handler.ts import { promises as fsPromises } from "fs"; import path from "path"; // src/helpers/createValidatedAgeEstimationFunction.ts import assert from "assert/strict"; // src/helpers/const.ts var MAX_INT32 = 2 ** 31 - 1; // src/helpers/createValidatedAgeEstimationFunction.ts function getInitialExpireAge(staleAge) { return staleAge * 1.5; } function createValidatedAgeEstimationFunction(callback = getInitialExpireAge) { return function estimateExpireAge(staleAge) { const rawExpireAge = callback(staleAge); const expireAge = Math.min(Math.floor(rawExpireAge), MAX_INT32); assert( Number.isInteger(expireAge) && expireAge > 0, `The expire age must be a positive integer but got a ${expireAge}.` ); return expireAge; }; } // src/helpers/getTagsFromHeaders.ts function getTagsFromHeaders(headers) { const tagsHeader = headers["x-next-cache-tags"]; if (Array.isArray(tagsHeader)) { return tagsHeader; } if (typeof tagsHeader === "string") { return tagsHeader.split(","); } return []; } // src/helpers/resolveRevalidateValue.ts function resolveRevalidateValue(incrementalCacheValue, ctx) { const cachedFetchValue = incrementalCacheValue; const cachedPageValue = incrementalCacheValue; const responseCacheCtx = ctx; let revalidate; if (cachedFetchValue.kind === "FETCH") { revalidate = cachedFetchValue.revalidate; } else if (cachedPageValue.kind === "APP_PAGE") { revalidate = responseCacheCtx.cacheControl?.revalidate; } return revalidate ?? ctx.revalidate; } // src/handlers/cache-handler.ts var PRERENDER_MANIFEST_VERSION = 4; async function removeEntryFromHandlers(handlers, key, debug) { if (debug) { console.info( "[CacheHandler] [method: %s] [key: %s] %s", "delete", key, "Started deleting entry from Handlers." ); } const operationsResults = await Promise.allSettled( handlers.map((handler) => handler.delete?.(key)) ); if (!debug) { return; } operationsResults.forEach((handlerResult, index) => { if (handlerResult.status === "rejected") { console.warn( "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", handlers[index]?.name ?? `unknown-${index}`, "delete", key, `Error: ${handlerResult.reason}` ); } else { console.info( "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", handlers[index]?.name ?? `unknown-${index}`, "delete", key, "Successfully deleted value." ); } }); } var CacheHandler = class _CacheHandler { /** * Provides a descriptive name for the CacheHandler class. * * The name includes the number of handlers and whether file system caching is used. * If the cache handler is not configured yet, it will return a string indicating so. * * This property is primarily intended for debugging purposes * and its visibility is controlled by the `NEXT_PRIVATE_DEBUG_CACHE` environment variable. * * @returns A string describing the cache handler configuration. * * @example * ```js * // cache-handler.mjs * CacheHandler.onCreation(async () => { * const redisHandler = await createRedisHandler({ * client, * }); * * const localHandler = createLruHandler(); * * return { * handlers: [redisHandler, localHandler], * }; * }); * * // after the Next.js called the onCreation hook * console.log(CacheHandler.name); * // Output: "cache-handler with 2 Handlers" * ``` */ static get name() { if (_CacheHandler.#cacheListLength === void 0) { return "cache-handler is not configured yet"; } return `cache-handler with ${_CacheHandler.#cacheListLength} Handler${_CacheHandler.#cacheListLength > 1 ? "s" : ""}`; } static #context; static #mergedHandler; static #cacheListLength; static #debug = typeof process.env.NEXT_PRIVATE_DEBUG_CACHE !== "undefined"; // Default stale age is 1 year in seconds static #defaultStaleAge = 60 * 60 * 24 * 365; static #estimateExpireAge; static #fallbackFalseRoutes = /* @__PURE__ */ new Set(); static #onCreationHook; static #serverDistDir; static async #readPagesRouterPage(cacheKey) { let cacheHandlerValue = null; let pageHtmlHandle = null; if (_CacheHandler.#debug) { console.info( "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", "file system", "get", cacheKey, "Started retrieving value." ); } try { const pageHtmlPath = path.join( _CacheHandler.#serverDistDir, "pages", `${cacheKey}.html` ); const pageDataPath = path.join( _CacheHandler.#serverDistDir, "pages", `${cacheKey}.json` ); pageHtmlHandle = await fsPromises.open(pageHtmlPath, "r"); const [pageHtmlFile, { mtimeMs }, pageData] = await Promise.all([ pageHtmlHandle.readFile("utf-8"), pageHtmlHandle.stat(), fsPromises.readFile(pageDataPath, "utf-8").then((data) => JSON.parse(data)) ]); if (_CacheHandler.#debug) { console.info( "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", "file system", "get", cacheKey, "Successfully retrieved value." ); } const value = { kind: "PAGES", html: pageHtmlFile, pageData, postponed: void 0, headers: void 0, status: void 0, rscData: void 0, segmentData: void 0 }; cacheHandlerValue = { lastModified: mtimeMs, lifespan: null, tags: [], value }; } catch (error) { cacheHandlerValue = null; if (_CacheHandler.#debug) { console.warn( "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", "file system", "get", cacheKey, `Error: ${error}` ); } } finally { await pageHtmlHandle?.close(); } return cacheHandlerValue; } static async #writePagesRouterPage(cacheKey, pageData) { try { const pageHtmlPath = path.join( _CacheHandler.#serverDistDir, "pages", `${cacheKey}.html` ); const pageDataPath = path.join( _CacheHandler.#serverDistDir, "pages", `${cacheKey}.json` ); await fsPromises.mkdir(path.dirname(pageHtmlPath), { recursive: true }); await Promise.all([ fsPromises.writeFile(pageHtmlPath, pageData.html), fsPromises.writeFile(pageDataPath, JSON.stringify(pageData.pageData)) ]); if (_CacheHandler.#debug) { console.info( "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", "file system", "set", cacheKey, "Successfully set value." ); } } catch (error) { if (_CacheHandler.#debug) { console.warn( "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", "file system", "set", cacheKey, `Error: ${error}` ); } } } /** * Returns the cache control parameters based on the last modified timestamp and revalidate option. * * @param lastModified - The last modified timestamp in milliseconds. * * @param revalidate - The revalidate option, representing the maximum age of stale data in seconds. * * @returns The cache control parameters including expire age, expire at, last modified at, stale age, stale at and revalidate. * * @remarks * - `lastModifiedAt` is the Unix timestamp (in seconds) for when the cache entry was last modified. * - `staleAge` is the time in seconds which equals to the `revalidate` option from Next.js pages. * If page has no `revalidate` option, it will be set to 1 year. * - `expireAge` is the time in seconds for when the cache entry becomes expired. * - `staleAt` is the Unix timestamp (in seconds) for when the cache entry becomes stale. * - `expireAt` is the Unix timestamp (in seconds) for when the cache entry must be removed from the cache. * - `revalidate` is the value from Next.js revalidate option. * May be false if the page has no revalidate option or the revalidate option is set to false. */ static #getLifespanParameters(lastModified, revalidate) { const lastModifiedAt = Math.floor(lastModified / 1e3); const staleAge = revalidate || _CacheHandler.#defaultStaleAge; const staleAt = lastModifiedAt + staleAge; const expireAge = _CacheHandler.#estimateExpireAge(staleAge); const expireAt = lastModifiedAt + expireAge; return { expireAge, expireAt, lastModifiedAt, revalidate, staleAge, staleAt }; } /** * Registers a hook to be called during the creation of an CacheHandler instance. * This method allows for custom cache configurations to be applied at the time of cache instantiation. * * The provided {@link OnCreationHook} function can perform initialization tasks, modify cache settings, * or integrate additional logic into the cache creation process. This function can either return a {@link CacheHandlerConfig} * object directly for synchronous operations, or a `Promise` that resolves to a {@link CacheHandlerConfig} for asynchronous operations. * * Usage of this method is typically for advanced scenarios where default caching behavior needs to be altered * or extended based on specific application requirements or environmental conditions. * * @param onCreationHook - The {@link OnCreationHook} function to be called during cache creation. * */ static onCreation(onCreationHook) { _CacheHandler.#onCreationHook = onCreationHook; } static async #configureCacheHandler() { if (_CacheHandler.#mergedHandler) { if (_CacheHandler.#debug) { console.info( "[CacheHandler] %s", "Using existing CacheHandler configuration." ); } return; } if (_CacheHandler.#debug) { console.info( "[CacheHandler] %s", "Creating new CacheHandler configuration." ); } const { serverDistDir, dev } = _CacheHandler.#context; let buildId; try { buildId = await fsPromises.readFile( path.join(serverDistDir, "..", "BUILD_ID"), "utf-8" ); } catch (_error) { buildId = void 0; } const config = _CacheHandler.#onCreationHook({ serverDistDir, dev, buildId }); if (_CacheHandler.#debug) { console.info( "[CacheHandler] %s", "Cache configuration retrieved from onCreation hook." ); } const { handlers, ttl = {} } = await config; const { defaultStaleAge, estimateExpireAge } = ttl; if (typeof defaultStaleAge === "number") { _CacheHandler.#defaultStaleAge = Math.floor(defaultStaleAge); } _CacheHandler.#estimateExpireAge = createValidatedAgeEstimationFunction(estimateExpireAge); _CacheHandler.#serverDistDir = serverDistDir; if (dev) { console.warn( "[CacheHandler] %s", "Next.js does not use the cache in development mode. Use production mode to enable caching." ); } try { const prerenderManifestData = await fsPromises.readFile( path.join(serverDistDir, "..", "prerender-manifest.json"), "utf-8" ); const prerenderManifest = JSON.parse( prerenderManifestData ); if (prerenderManifest.version !== PRERENDER_MANIFEST_VERSION) { throw new Error( `Invalid prerender manifest version. Expected version ${PRERENDER_MANIFEST_VERSION}. Please check if the Next.js version is compatible with the CacheHandler version.` ); } for (const [route, { srcRoute, dataRoute }] of Object.entries( prerenderManifest.routes )) { const isPagesRouter = dataRoute?.endsWith(".json"); if (isPagesRouter && prerenderManifest.dynamicRoutes[srcRoute || ""]?.fallback === false) { _CacheHandler.#fallbackFalseRoutes.add(route); } } } catch (_error) { if (_CacheHandler.#debug) { console.warn( "[CacheHandler] [%s] %s %s", "instrumentation.cache", "Failed to read prerender manifest. Pages from the Pages Router with `fallback: false` will return 404 errors.", `Error: ${_error}` ); } } const handlersList = handlers.filter((handler) => !!handler); _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() / 1e3)) { if (_CacheHandler.#debug) { console.info( "[CacheHandler] [handler: %s] [method: %s] [key: %s] %s", handler.name, "get", key, "Entry expired." ); } cacheHandlerValue = null; 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." ); } }); } }; if (_CacheHandler.#debug) { console.info( "[CacheHandler] [handlers: [%s]] %s", handlersList.map((handler) => handler.name).join(", "), "Successfully created CacheHandler configuration." ); } } /** * Creates a new CacheHandler instance. Constructor is intended for internal use only. */ constructor(context) { _CacheHandler.#context = context; if (_CacheHandler.#debug) { console.info( "[CacheHandler] %s", "Instance created with provided context." ); } } async get(cacheKey, ctx = { softTags: [] }) { await _CacheHandler.#configureCacheHandler(); const { softTags = [] } = ctx; if (_CacheHandler.#debug) { console.info( "[CacheHandler] [method: %s] [key: %s] %s", "get", cacheKey, "Started retrieving value in order." ); } let cachedData = await _CacheHandler.#mergedHandler.get(cacheKey, { implicitTags: softTags ?? [] }); if (cachedData?.value?.kind === "APP_ROUTE") { cachedData.value.body = Buffer.from( cachedData.value.body.toString(), "base64" ); } if (!cachedData && _CacheHandler.#fallbackFalseRoutes.has(cacheKey)) { cachedData = await _CacheHandler.#readPagesRouterPage(cacheKey); if (cachedData) { await _CacheHandler.#mergedHandler.set(cacheKey, cachedData); } } return cachedData ?? null; } async set(cacheKey, incrementalCacheValue, ctx) { await _CacheHandler.#configureCacheHandler(); if (_CacheHandler.#debug) { console.info( "[CacheHandler] [method: %s] [key: %s] %s", "set", cacheKey, "Started setting value in parallel." ); } const { tags = [], internal_lastModified } = ctx ?? {}; const revalidate = resolveRevalidateValue(incrementalCacheValue, ctx); const lastModified = Math.round(internal_lastModified ?? Date.now()); const hasFallbackFalse = _CacheHandler.#fallbackFalseRoutes.has(cacheKey); const lifespan = hasFallbackFalse ? null : _CacheHandler.#getLifespanParameters(lastModified, revalidate); if (lifespan !== null && Date.now() > lifespan.expireAt * 1e3) { return; } let cacheHandlerValueTags = tags; let value = incrementalCacheValue; switch (value?.kind) { case "APP_PAGE": { cacheHandlerValueTags = getTagsFromHeaders(value.headers ?? {}); break; } case "APP_ROUTE": { value = { // replace the body with a base64 encoded string to save space body: value.body.toString("base64"), headers: value.headers, kind: value.kind, status: value.status }; break; } default: { break; } } const cacheHandlerValue = { lastModified, lifespan, tags: Object.freeze(cacheHandlerValueTags), value }; await _CacheHandler.#mergedHandler.set(cacheKey, cacheHandlerValue); if (hasFallbackFalse && cacheHandlerValue.value?.kind === "APP_PAGE") { await _CacheHandler.#writePagesRouterPage( cacheKey, cacheHandlerValue.value ); } } async revalidateTag(tag) { await _CacheHandler.#configureCacheHandler(); const tags = typeof tag === "string" ? [tag] : tag; if (_CacheHandler.#debug) { console.info( "[CacheHandler] [method: %s] [tags: [%s]] %s", "revalidateTag", tags.join(", "), "Started revalidating tag in parallel." ); } for (const tag2 of tags) { await _CacheHandler.#mergedHandler.revalidateTag(tag2); } } resetRequestCache() { } }; export { CacheHandler };