@chrono-cache/next
Version:
A custom cache handler for Next.js, designed to address a common challenge: the need for large and costly distributed cache solutions (e.g., Redis) in horizontally scaled applications hosted outside of Vercel
476 lines (463 loc) • 14.2 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// ../../node_modules/.pnpm/next@15.0.3_react-dom@19.0.0-rc-66855b96-20241106_react@18.3.1/node_modules/next/dist/server/response-cache/types.js
var require_types = __commonJS({
"../../node_modules/.pnpm/next@15.0.3_react-dom@19.0.0-rc-66855b96-20241106_react@18.3.1/node_modules/next/dist/server/response-cache/types.js"(exports2) {
"use strict";
Object.defineProperty(exports2, "__esModule", {
value: true
});
function _export(target, all) {
for (var name in all) Object.defineProperty(target, name, {
enumerable: true,
get: all[name]
});
}
_export(exports2, {
CachedRouteKind: function() {
return CachedRouteKind3;
},
IncrementalCacheKind: function() {
return IncrementalCacheKind2;
}
});
var CachedRouteKind3;
(function(CachedRouteKind4) {
CachedRouteKind4["APP_PAGE"] = "APP_PAGE";
CachedRouteKind4["APP_ROUTE"] = "APP_ROUTE";
CachedRouteKind4["PAGES"] = "PAGES";
CachedRouteKind4["FETCH"] = "FETCH";
CachedRouteKind4["REDIRECT"] = "REDIRECT";
CachedRouteKind4["IMAGE"] = "IMAGE";
})(CachedRouteKind3 || (CachedRouteKind3 = {}));
var IncrementalCacheKind2;
(function(IncrementalCacheKind3) {
IncrementalCacheKind3["APP_PAGE"] = "APP_PAGE";
IncrementalCacheKind3["APP_ROUTE"] = "APP_ROUTE";
IncrementalCacheKind3["PAGES"] = "PAGES";
IncrementalCacheKind3["FETCH"] = "FETCH";
IncrementalCacheKind3["IMAGE"] = "IMAGE";
})(IncrementalCacheKind2 || (IncrementalCacheKind2 = {}));
}
});
// source/index.ts
var index_exports = {};
__export(index_exports, {
CacheHandler: () => CustomCacheHandler,
default: () => index_default
});
module.exports = __toCommonJS(index_exports);
// source/modules/CustomCacheHandler.ts
var import_node_path3 = __toESM(require("path"), 1);
// ../core/source/utils/console.ts
var consoleColors = {
text: {
red: (val) => `\x1B[31m${val}\x1B[0m`,
green: (val) => `\x1B[32m${val}\x1B[0m`,
yellow: (val) => `\x1B[33m${val}\x1B[0m`,
blue: (val) => `\x1B[34m${val}\x1B[0m`
},
background: {
red: (val) => `\x1B[41m${val}\x1B[0m`,
green: (val) => `\x1B[42m${val}\x1B[0m`,
yellow: (val) => `\x1B[43m${val}\x1B[0m`,
blue: (val) => `\x1B[44m${val}\x1B[0m`
}
};
// ../core/source/cache-modules/LRUCache.ts
var LRUCache = class {
memoryCache = /* @__PURE__ */ new Map();
cacheSizes = /* @__PURE__ */ new Map();
serializeValue;
maxSize = 10 * 1024 * 1024;
totalSize = 0;
ttl = 6e4;
debug = false;
constructor(options) {
this.debug = options.debug ?? false;
this.serializeValue = options.serializeValue;
if (options.maxSize) this.maxSize = options.maxSize;
if (options.ttl) {
this.ttl = options.ttl;
}
}
/**
* Get memory cache value
*/
get(key) {
const memoryValue = this.memoryCache.get(key);
if (!memoryValue || memoryValue.expiresAt <= Date.now()) {
if (this.debug)
console.log(
consoleColors.background.green("[CACHE:MEMORY]"),
consoleColors.text.yellow("[SKIP]")
);
return null;
}
if (this.debug) {
console.log(
consoleColors.background.green("[CACHE:MEMORY]"),
consoleColors.text.green("[HIT]")
);
}
this.touch(key);
return memoryValue;
}
/**
* Set memory value
*/
set(key, value, tags = []) {
const size = this.calculateSize(value);
if (size > this.maxSize) {
console.log(
consoleColors.background.green("[CACHE:MEMORY]"),
consoleColors.text.red("Single item size exceeds max size")
);
return;
}
if (this.has(key)) {
this.totalSize -= this.cacheSizes.get(key) || 0;
}
this.cacheSizes.set(key, size);
this.memoryCache.set(key, {
value,
tags,
lastModified: Date.now(),
expiresAt: Date.now() + this.ttl
});
this.totalSize += size;
this.touch(key);
}
has(key) {
if (!key) return false;
this.touch(key);
return Boolean(this.memoryCache.get(key));
}
touch(key) {
const value = this.memoryCache.get(key);
if (value) {
this.memoryCache.delete(key);
this.memoryCache.set(key, value);
this.evictIfNecessary();
}
}
evictIfNecessary() {
while (this.totalSize > this.maxSize && this.memoryCache.size > 0) {
this.evictLeastRecentlyUsed();
}
}
evictLeastRecentlyUsed() {
const lruKey = this.getOldestCacheKey();
if (lruKey !== void 0) {
const lruSize = this.cacheSizes.get(lruKey) || 0;
this.totalSize -= lruSize;
this.memoryCache.delete(lruKey);
this.cacheSizes.delete(lruKey);
}
}
getOldestCacheKey() {
return this.memoryCache.keys().next().value;
}
calculateSize(value) {
return Buffer.byteLength(this.serializeValue(value), "utf8");
}
/**
* Revalidate using tags array
*/
revalidateTags(tags) {
this.memoryCache.forEach((value, key) => {
if (tags.some((tag) => value.tags?.includes(tag))) {
this.memoryCache.delete(key);
}
});
}
};
// ../core/source/cache-modules/FileCache/index.ts
var import_node_path2 = __toESM(require("path"), 1);
// ../core/source/cache-modules/FileCache/ManifestManager.ts
var import_node_path = __toESM(require("path"), 1);
var ManifestManager = class _ManifestManager {
static fileName = "tags-manifest.json";
value;
baseTags = [];
fs;
dir;
constructor(options) {
this.dir = options.dir;
this.fs = options.fs;
if (options.baseTags?.length) this.baseTags.push(...options.baseTags);
}
get filePath() {
return import_node_path.default.join(this.dir, _ManifestManager.fileName);
}
isLoaded() {
return !!this.value;
}
async updateRevalidatedAt(tags) {
for (const tag of tags) {
const data = this.value?.items[tag] || {
revalidatedAt: Date.now()
};
data.revalidatedAt = Date.now();
if (this.value) {
this.value.items[tag] = data;
}
}
await this.write();
}
async write() {
try {
await this.fs.mkdir(import_node_path.default.dirname(this.dir));
await this.fs.writeFile(this.filePath, JSON.stringify(this?.value || {}));
} catch (err) {
}
}
/**
* Load the tags manifest from the file system
*/
async load() {
try {
this.value = JSON.parse(await this.fs.readFile(this.filePath, "utf8"));
} catch (err) {
this.value = { version: 1, items: {} };
}
}
/**
* Check if tag is expired
*/
expired(tag, lastModified) {
const now = Date.now();
return this.baseTags.includes(tag) || this.value?.items[tag]?.revalidatedAt && this.value?.items[tag].revalidatedAt >= lastModified;
}
};
// ../core/source/cache-modules/FileCache/index.ts
var FileCache = class {
distDir;
debug = false;
fs;
manifestManager;
constructor(options) {
this.fs = options.fs;
this.debug = !!options.debug;
this.distDir = options.dir;
this.manifestManager = new ManifestManager({
dir: options.dir,
fs: options.fs,
baseTags: []
});
}
/**
* Get cached value
*/
async get(key) {
try {
const filePath = this.getFilePath([key]);
const fileTagsPath = this.getFilePath(["tags", key]);
const [fileData, fileTags] = await Promise.all([
this.fs.readFile(filePath, "utf8"),
this.fs.readFile(fileTagsPath, "utf8")
]);
const { mtime } = await this.fs.stat(filePath);
const tags = JSON.parse(fileTags);
if (!fileData || await this.isExpired(tags, mtime.getTime())) {
throw new Error("Not found or expired");
}
if (this.debug)
console.log(
consoleColors.background.blue("[CACHE:SYSTEM]"),
consoleColors.text.green("[HIT]")
);
return {
lastModified: mtime.getTime(),
value: fileData,
tags
};
} catch (error) {
}
if (this.debug)
console.log(
consoleColors.background.blue("[CACHE:SYSTEM]"),
consoleColors.text.red("[SKIP]")
);
return await Promise.resolve(null);
}
/**
* set cache
*/
async set(key, data, tags = []) {
const filePath = this.getFilePath([key]);
const fileTagsPath = this.getFilePath(["tags", key]);
await Promise.all([
this.fs.mkdir(import_node_path2.default.dirname(filePath)),
this.fs.mkdir(import_node_path2.default.dirname(fileTagsPath))
]);
await Promise.all([
await this.fs.writeFile(filePath, data),
await this.fs.writeFile(fileTagsPath, JSON.stringify(tags))
]);
}
/**
* Revalidate cache using tags
*/
async revalidateTags(tags) {
await this.manifestManager.load();
if (this.manifestManager.isLoaded()) {
await this.manifestManager.updateRevalidatedAt(tags);
}
}
async isExpired(tags, lastModified) {
await this.manifestManager.load();
const wasRevalidated = tags.some((tag) => {
return this.manifestManager.expired(tag, lastModified);
});
return wasRevalidated;
}
getFilePath(pathname) {
return import_node_path2.default.join(this.distDir, ...pathname);
}
};
// source/modules/cache/FileCache.ts
var import_types = __toESM(require_types(), 1);
var FileCache2 = class {
fileHandler;
constructor(options) {
this.fileHandler = new FileCache(options);
}
async get(key, { kind }) {
if (kind === import_types.IncrementalCacheKind.FETCH) {
return await this.getFetchCache(key);
}
return null;
}
async getFetchCache(key) {
const result = await this.fileHandler.get(key);
if (!result) return null;
const parsedValue = JSON.parse(result.value);
return {
value: parsedValue,
lastModified: result.lastModified
};
}
async set(key, data, ctx) {
if (data?.kind === import_types.CachedRouteKind.FETCH) {
await this.setFetchCache(key, data, ctx);
}
}
async setFetchCache(key, data, ctx) {
await this.fileHandler.set(
key,
JSON.stringify({
...data,
tags: ctx.tags
}),
ctx.tags
);
}
async revalidateTag(tags) {
await this.fileHandler.revalidateTags(tags);
}
resetRequestCache() {
}
};
// source/utils/index.ts
var import_types2 = __toESM(require_types(), 1);
function serializeCacheValue(value) {
if (value.kind === import_types2.CachedRouteKind.FETCH) {
return JSON.stringify(value.data || "");
}
return "";
}
// source/modules/CustomCacheHandler.ts
var EXCLUDED_KEYS = ["favicon.ico"];
var memoryCache;
var CustomCacheHandler = class _CustomCacheHandler {
flushToDisk = false;
memoryCache;
fileCache;
constructor(options) {
const debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE;
this.flushToDisk = !!options.flushToDisk;
memoryCache ??= new LRUCache({
debug,
maxSize: Number.parseInt(process.env.CACHE_MEMORY_LIMIT ?? "0", 10),
ttl: Number.parseInt(process.env.CACHE_MEMORY_LIFETIME ?? "0", 10),
serializeValue: serializeCacheValue
});
if (options.fs && options.serverDistDir) {
this.fileCache = new FileCache2({
debug,
fs: options.fs,
dir: import_node_path3.default.join(options.serverDistDir, "..", "cache", "fetch-cache")
});
}
this.memoryCache = memoryCache;
}
async revalidateTag(args) {
const tags = _CustomCacheHandler.normalizeTags(args);
if (!tags.length) return;
await this.fileCache?.revalidateTag(tags);
this.memoryCache.revalidateTags(tags);
}
async get(key, ctx) {
if (EXCLUDED_KEYS.some((excludedKey) => key.includes(excludedKey)))
return null;
const memoryValue = this.memoryCache.get(key);
if (memoryValue) return memoryValue;
if (process.env.NEXT_RUNTIME === "edge" || !this.flushToDisk) return null;
const localValue = await this.fileCache?.get(key, ctx);
if (localValue?.value) {
this.memoryCache.set(key, localValue.value);
return localValue;
}
return null;
}
async set(key, data, ctx) {
if (!data) return;
this.memoryCache.set(key, data);
if (this.flushToDisk && this.fileCache)
await this.fileCache.set(key, data, ctx);
}
resetRequestCache() {
}
static normalizeTags(args) {
const [tags] = args;
if (typeof tags === "string") return [tags];
if (Array.isArray(tags)) return tags;
return [];
}
};
// source/index.ts
var index_default = CustomCacheHandler;
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
CacheHandler
});