@fortedigital/nextjs-cache-handler
Version:
Next.js cache handlers
613 lines (605 loc) • 20.1 kB
JavaScript
// 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
};