@trieb.work/nextjs-turbo-redis-cache
Version:
Designed for speed, scalability, and optimized performance, nextjs-turbo-redis-cache is your custom cache handler for demanding production environments.
1,398 lines (1,386 loc) • 48.6 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 __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);
// src/index.ts
var index_exports = {};
__export(index_exports, {
RedisStringsHandler: () => RedisStringsHandler,
bufferAndMapReplacer: () => bufferAndMapReplacer,
bufferAndMapReviver: () => bufferAndMapReviver,
default: () => index_default,
getRedisCacheComponentsHandler: () => getRedisCacheComponentsHandler,
jsonCacheValueSerializer: () => jsonCacheValueSerializer,
redisCacheHandler: () => redisCacheHandler
});
module.exports = __toCommonJS(index_exports);
// src/RedisStringsHandler.ts
var import_redis = require("redis");
// src/utils/debug.ts
function debug(color = "none", ...args) {
const colorCode = {
red: "\x1B[31m",
blue: "\x1B[34m",
green: "\x1B[32m",
yellow: "\x1B[33m",
cyan: "\x1B[36m",
white: "\x1B[37m",
none: ""
};
if (process.env.DEBUG_CACHE_HANDLER) {
console.log(colorCode[color], "DEBUG CACHE HANDLER: ", ...args);
}
}
function debugVerbose(color, ...args) {
if (process.env.DEBUG_CACHE_HANDLER_VERBOSE_VERBOSE) {
console.log("\x1B[35m", "DEBUG SYNCED MAP: ", ...args);
}
}
// src/SyncedMap.ts
var SYNC_CHANNEL_SUFFIX = ":sync-channel:";
var SyncedMap = class {
constructor(options) {
this.client = options.client;
this.keyPrefix = options.keyPrefix;
this.redisKey = options.redisKey;
this.syncChannel = `${options.keyPrefix}${SYNC_CHANNEL_SUFFIX}${options.redisKey}`;
this.database = options.database;
this.querySize = options.querySize;
this.filterKeys = options.filterKeys;
this.resyncIntervalMs = options.resyncIntervalMs;
this.customizedSync = options.customizedSync;
this.map = /* @__PURE__ */ new Map();
this.subscriberClient = this.client.duplicate();
this.setupLock = new Promise((resolve) => {
this.setupLockResolve = resolve;
});
this.setup().catch((error) => {
console.error("Failed to setup SyncedMap:", error);
throw error;
});
}
async setup() {
let setupPromises = [];
if (!this.customizedSync?.withoutRedisHashmap) {
setupPromises.push(this.initialSync());
this.setupPeriodicResync();
}
setupPromises.push(this.setupPubSub());
await Promise.all(setupPromises);
this.setupLockResolve();
}
async initialSync() {
let cursor = 0;
const hScanOptions = { COUNT: this.querySize };
try {
do {
const remoteItems = await redisErrorHandler(
"SyncedMap.initialSync(), operation: hScan " + this.syncChannel + " " + this.keyPrefix + " " + this.redisKey + " " + cursor + " " + this.querySize,
this.client.hScan(
this.keyPrefix + this.redisKey,
cursor,
hScanOptions
)
);
for (const { field, value } of remoteItems.tuples) {
if (this.filterKeys(field)) {
const parsedValue = JSON.parse(value);
this.map.set(field, parsedValue);
}
}
cursor = remoteItems.cursor;
} while (cursor !== 0);
await this.cleanupKeysNotInRedis();
} catch (error) {
console.error("Error during initial sync:", error);
throw error;
}
}
async cleanupKeysNotInRedis() {
let cursor = 0;
const scanOptions = { COUNT: this.querySize, MATCH: `${this.keyPrefix}*` };
let remoteKeys = [];
try {
do {
const remoteKeysPortion = await redisErrorHandler(
"SyncedMap.cleanupKeysNotInRedis(), operation: scan " + this.keyPrefix,
this.client.scan(cursor, scanOptions)
);
remoteKeys = remoteKeys.concat(remoteKeysPortion.keys);
cursor = remoteKeysPortion.cursor;
} while (cursor !== 0);
const remoteKeysSet = new Set(
remoteKeys.map((key) => key.substring(this.keyPrefix.length))
);
const keysToDelete = [];
for (const key of this.map.keys()) {
const keyStr = key;
if (!remoteKeysSet.has(keyStr) && this.filterKeys(keyStr)) {
keysToDelete.push(keyStr);
}
}
if (keysToDelete.length > 0) {
await this.delete(keysToDelete);
}
} catch (error) {
console.error("Error during cleanup of keys not in Redis:", error);
throw error;
}
}
setupPeriodicResync() {
if (this.resyncIntervalMs && this.resyncIntervalMs > 0) {
setInterval(() => {
this.initialSync().catch((error) => {
console.error("Error during periodic resync:", error);
});
}, this.resyncIntervalMs);
}
}
async setupPubSub() {
const syncHandler = async (message) => {
const syncMessage = JSON.parse(message);
if (syncMessage.type === "insert") {
if (syncMessage.key !== void 0 && syncMessage.value !== void 0) {
this.map.set(syncMessage.key, syncMessage.value);
}
} else if (syncMessage.type === "delete") {
if (syncMessage.keys) {
for (const key of syncMessage.keys) {
this.map.delete(key);
}
}
}
};
const keyEventHandler = async (key, message) => {
debug(
"yellow",
"SyncedMap.keyEventHandler() called with message",
this.redisKey,
message,
key
);
if (key.startsWith(this.keyPrefix)) {
const keyInMap = key.substring(this.keyPrefix.length);
if (this.filterKeys(keyInMap)) {
debugVerbose(
"SyncedMap.keyEventHandler() key matches filter and will be deleted",
this.redisKey,
message,
key
);
await this.delete(keyInMap, true);
}
} else {
debugVerbose(
"SyncedMap.keyEventHandler() key does not have prefix",
this.redisKey,
message,
key
);
}
};
try {
const connectIfNeeded = async () => {
if (!this.subscriberClient.isOpen && !this.subscriberClient.isReady) {
await this.subscriberClient.connect();
}
};
await connectIfNeeded().catch(async (error) => {
console.error("Failed to connect subscriber client. Retrying...");
try {
await connectIfNeeded();
} catch (err) {
console.error("Failed to connect subscriber client.", err);
throw err;
}
void error;
});
if ((process.env.SKIP_KEYSPACE_CONFIG_CHECK || "").toUpperCase() !== "TRUE") {
const keyspaceEventConfig = (await this.subscriberClient.configGet("notify-keyspace-events"))?.["notify-keyspace-events"];
if (!keyspaceEventConfig.includes("E")) {
throw new Error(
'Keyspace event configuration is set to "' + keyspaceEventConfig + "\" but has to include 'E' for Keyevent events, published with __keyevent@<db>__ prefix. We recommend to set it to 'Exe' like so `redis-cli -h localhost config set notify-keyspace-events Exe`"
);
}
if (!keyspaceEventConfig.includes("A") && !(keyspaceEventConfig.includes("x") && keyspaceEventConfig.includes("e"))) {
throw new Error(
'Keyspace event configuration is set to "' + keyspaceEventConfig + "\" but has to include 'A' or 'x' and 'e' for expired and evicted events. We recommend to set it to 'Exe' like so `redis-cli -h localhost config set notify-keyspace-events Exe`"
);
}
}
await Promise.all([
// We use a custom channel for insert/delete For the following reason:
// With custom channel we can delete multiple entries in one message. If we would listen to unlink / del we
// could get thousands of messages for one revalidateTag (For example revalidateTag("algolia") would send an enormous amount of network packages)
// Also we can send the value in the message for insert
this.subscriberClient.subscribe(this.syncChannel, syncHandler),
// Subscribe to Redis keyevent notifications for evicted and expired keys
this.subscriberClient.subscribe(
`__keyevent@${this.database}__:evicted`,
keyEventHandler
),
this.subscriberClient.subscribe(
`__keyevent@${this.database}__:expired`,
keyEventHandler
)
]);
this.subscriberClient.on("error", async (err) => {
console.error("Subscriber client error:", err);
try {
await this.subscriberClient.quit();
this.subscriberClient = this.client.duplicate();
await this.setupPubSub();
} catch (reconnectError) {
console.error(
"Failed to reconnect subscriber client:",
reconnectError
);
}
});
} catch (error) {
console.error("Error setting up pub/sub client:", error);
throw error;
}
}
async waitUntilReady() {
await this.setupLock;
}
get(key) {
debugVerbose(
"SyncedMap.get() called with key",
key,
JSON.stringify(this.map.get(key))?.substring(0, 100)
);
return this.map.get(key);
}
async set(key, value) {
debugVerbose(
"SyncedMap.set() called with key",
key,
JSON.stringify(value)?.substring(0, 100)
);
this.map.set(key, value);
const operations = [];
if (this.customizedSync?.withoutSetSync) {
return;
}
if (!this.customizedSync?.withoutRedisHashmap) {
operations.push(
redisErrorHandler(
"SyncedMap.set(), operation: hSet " + this.syncChannel + " " + this.keyPrefix + " " + key,
this.client.hSet(
this.keyPrefix + this.redisKey,
key,
JSON.stringify(value)
)
)
);
}
const insertMessage = {
type: "insert",
key,
value
};
operations.push(
redisErrorHandler(
"SyncedMap.set(), operation: publish " + this.syncChannel + " " + this.keyPrefix + " " + key,
this.client.publish(this.syncChannel, JSON.stringify(insertMessage))
)
);
await Promise.all(operations);
}
async delete(keys, withoutSyncMessage = false) {
debugVerbose(
"SyncedMap.delete() called with keys",
this.redisKey,
keys,
withoutSyncMessage
);
const keysArray = Array.isArray(keys) ? keys : [keys];
const operations = [];
for (const key of keysArray) {
this.map.delete(key);
}
if (!this.customizedSync?.withoutRedisHashmap) {
operations.push(
redisErrorHandler(
"SyncedMap.delete(), operation: hDel " + this.syncChannel + " " + this.keyPrefix + " " + this.redisKey + " " + keysArray,
this.client.hDel(this.keyPrefix + this.redisKey, keysArray)
)
);
}
if (!withoutSyncMessage) {
const deletionMessage = {
type: "delete",
keys: keysArray
};
operations.push(
redisErrorHandler(
"SyncedMap.delete(), operation: publish " + this.syncChannel + " " + this.keyPrefix + " " + keysArray,
this.client.publish(
this.syncChannel,
JSON.stringify(deletionMessage)
)
)
);
}
await Promise.all(operations);
debugVerbose(
"SyncedMap.delete() finished operations",
this.redisKey,
keys,
operations.length
);
}
has(key) {
return this.map.has(key);
}
entries() {
return this.map.entries();
}
};
// src/DeduplicatedRequestHandler.ts
var DeduplicatedRequestHandler = class {
constructor(fn, cachingTimeMs, inMemoryDeduplicationCache) {
// Method to handle deduplicated requests
this.deduplicatedFunction = (key) => {
debugVerbose(
"DeduplicatedRequestHandler.deduplicatedFunction() called with",
key
);
const self = this;
const dedupedFn = async (...args) => {
debugVerbose(
"DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn called with",
key
);
if (self.inMemoryDeduplicationCache && self.inMemoryDeduplicationCache.has(key)) {
debugVerbose(
"DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn ",
key,
"found key in inMemoryDeduplicationCache"
);
const res = await self.inMemoryDeduplicationCache.get(key).then((v) => structuredClone(v));
debugVerbose(
"DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn ",
key,
"found key in inMemoryDeduplicationCache and served result from there",
JSON.stringify(res).substring(0, 200)
);
return res;
}
const promise = self.fn(...args);
self.inMemoryDeduplicationCache.set(key, promise);
debugVerbose(
"DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn ",
key,
"did not found key in inMemoryDeduplicationCache. Setting it now and waiting for promise to resolve"
);
try {
const ts = performance.now();
const result = await promise;
debugVerbose(
"DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn ",
key,
"promise resolved (in ",
performance.now() - ts,
"ms). Returning result",
JSON.stringify(result).substring(0, 200)
);
return structuredClone(result);
} finally {
setTimeout(() => {
debugVerbose(
"DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn ",
key,
"deleting key from inMemoryDeduplicationCache after ",
self.cachingTimeMs,
"ms"
);
self.inMemoryDeduplicationCache.delete(key);
}, self.cachingTimeMs);
}
};
return dedupedFn;
};
this.fn = fn;
this.cachingTimeMs = cachingTimeMs;
this.inMemoryDeduplicationCache = inMemoryDeduplicationCache;
}
// Method to manually seed a result into the cache
seedRequestReturn(key, value) {
const resultPromise = new Promise((res) => res(value));
this.inMemoryDeduplicationCache.set(key, resultPromise);
debugVerbose(
"DeduplicatedRequestHandler.seedRequestReturn() seeded result ",
key,
value.substring(0, 200)
);
setTimeout(() => {
this.inMemoryDeduplicationCache.delete(key);
}, this.cachingTimeMs);
}
};
// src/utils/json.ts
function bufferAndMapReviver(_, value) {
if (value && typeof value === "object" && typeof value.$binary === "string") {
return Buffer.from(value.$binary, "base64");
}
if (value && typeof value === "object" && typeof value.$map === "object" && !!value.$map) {
return new Map(
Object.entries(value.$map).map(([key, value2]) => {
const revivedValue = bufferAndMapReviver("", value2);
return [key, revivedValue];
})
);
}
return value;
}
function bufferAndMapReplacer(_, value) {
if (Buffer.isBuffer(value)) {
return {
$binary: value.toString("base64")
};
}
if (value && typeof value === "object" && value?.type === "Buffer" && Array.isArray(value.data)) {
return {
$binary: Buffer.from(value.data).toString("base64")
};
}
if (value && typeof value === "object" && value instanceof Map) {
return {
$map: Object.fromEntries(
Array.from(value.entries()).map(([key, value2]) => {
const replacedValue = bufferAndMapReplacer("", value2);
return [key, replacedValue];
})
)
};
}
return value;
}
// src/serializer.ts
var jsonCacheValueSerializer = {
serialize(value) {
return JSON.stringify(value, bufferAndMapReplacer);
},
deserialize(stored) {
return JSON.parse(stored, bufferAndMapReviver);
}
};
// src/RedisStringsHandler.ts
function redisErrorHandler(debugInfo, redisCommandResult) {
const beforeTimestamp = performance.now();
return redisCommandResult.catch((error) => {
console.error(
"Redis command error",
(performance.now() - beforeTimestamp).toFixed(2),
"ms",
debugInfo,
error
);
throw error;
});
}
function isAbortError(error) {
if (!error || typeof error !== "object") return false;
const err = error;
return err.name === "AbortError" || err.code === "ABORT_ERR";
}
if (process.env.DEBUG_CACHE_HANDLER) {
setInterval(() => {
const start = performance.now();
setImmediate(() => {
const duration = performance.now() - start;
if (duration > 100) {
debug(
"yellow",
`RedisStringsHandler detected an event loop lag of: ${duration.toFixed(2)}ms. If your container is hosted in a cloud provider with container suspension this is normal. If not you should increase the CPU of your container.`
);
}
});
}, 500);
}
var NEXT_CACHE_IMPLICIT_TAG_ID = "_N_T_";
var REVALIDATED_TAGS_KEY = "__revalidated_tags__";
var killContainerOnErrorCount = 0;
var RedisStringsHandler = class {
constructor({
redisUrl = process.env.REDIS_URL ? process.env.REDIS_URL : process.env.REDISHOST ? `redis://${process.env.REDISHOST}:${process.env.REDISPORT}` : "redis://localhost:6379",
database = process.env.VERCEL_ENV === "production" ? 0 : 1,
keyPrefix = process.env.KEY_PREFIX || process.env.VERCEL_URL || "UNDEFINED_URL_",
sharedTagsKey = "__sharedTags__",
getTimeoutMs = process.env.REDIS_COMMAND_TIMEOUT_MS ? Number.parseInt(process.env.REDIS_COMMAND_TIMEOUT_MS) ?? 500 : 500,
revalidateTagQuerySize = 250,
avgResyncIntervalMs = 60 * 60 * 1e3,
redisGetDeduplication = true,
inMemoryCachingTime = 1e4,
defaultStaleAge = 60 * 60 * 24 * 14,
estimateExpireAge = (staleAge) => process.env.VERCEL_ENV === "production" ? staleAge * 2 : staleAge * 1.2,
killContainerOnErrorThreshold = process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD ? Number.parseInt(process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD) ?? 0 : 0,
socketOptions,
clientOptions,
valueSerializer = jsonCacheValueSerializer
}) {
this.clientReadyCalls = 0;
try {
this.keyPrefix = keyPrefix;
this.redisGetDeduplication = redisGetDeduplication;
this.inMemoryCachingTime = inMemoryCachingTime;
this.defaultStaleAge = defaultStaleAge;
this.estimateExpireAge = estimateExpireAge;
this.killContainerOnErrorThreshold = killContainerOnErrorThreshold;
this.getTimeoutMs = getTimeoutMs;
this.valueSerializer = valueSerializer;
try {
this.client = (0, import_redis.createClient)({
url: redisUrl,
pingInterval: 1e4,
// Useful with Redis deployments that do not use TCP Keep-Alive. Restarts the connection if it is idle for too long.
...database !== 0 ? { database } : {},
...socketOptions ? { socket: { ...socketOptions } } : {},
...clientOptions || {}
});
this.client.on("error", (error) => {
console.error(
"Redis client error",
error,
killContainerOnErrorCount++
);
setTimeout(() => {
if (!this.client.isOpen && !this.client.isReady) {
this.client.connect().catch((error2) => {
console.error(
"Failed to reconnect Redis client after connection loss:",
error2
);
});
}
}, 1e3);
if (this.killContainerOnErrorThreshold > 0 && killContainerOnErrorCount >= this.killContainerOnErrorThreshold) {
console.error(
"Redis client error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)",
error,
killContainerOnErrorCount++
);
this.client.disconnect();
this.client.quit();
setTimeout(() => {
process.exit(1);
}, 500);
}
});
this.client.connect().then(() => {
debug("green", "Redis client connected.");
}).catch((error) => {
console.error("Failed to connect Redis client:", error);
throw error;
});
} catch (error) {
console.error("Failed to initialize Redis client");
throw error;
}
const filterKeys = (key) => key !== REVALIDATED_TAGS_KEY && key !== sharedTagsKey;
this.sharedTagsMap = new SyncedMap({
client: this.client,
keyPrefix,
redisKey: sharedTagsKey,
database,
querySize: revalidateTagQuerySize,
filterKeys,
resyncIntervalMs: avgResyncIntervalMs - avgResyncIntervalMs / 10 + Math.random() * (avgResyncIntervalMs / 10)
});
this.revalidatedTagsMap = new SyncedMap({
client: this.client,
keyPrefix,
redisKey: REVALIDATED_TAGS_KEY,
database,
querySize: revalidateTagQuerySize,
filterKeys,
resyncIntervalMs: avgResyncIntervalMs + avgResyncIntervalMs / 10 + Math.random() * (avgResyncIntervalMs / 10)
});
this.inMemoryDeduplicationCache = new SyncedMap({
client: this.client,
keyPrefix,
redisKey: "inMemoryDeduplicationCache",
database,
querySize: revalidateTagQuerySize,
filterKeys,
customizedSync: {
withoutRedisHashmap: true,
withoutSetSync: true
}
});
const redisGet = this.client.get.bind(this.client);
this.redisDeduplicationHandler = new DeduplicatedRequestHandler(
redisGet,
inMemoryCachingTime,
this.inMemoryDeduplicationCache
);
this.redisGet = redisGet;
this.deduplicatedRedisGet = this.redisDeduplicationHandler.deduplicatedFunction;
} catch (error) {
console.error(
"RedisStringsHandler constructor error",
error,
killContainerOnErrorCount++
);
if (killContainerOnErrorThreshold > 0 && killContainerOnErrorCount >= killContainerOnErrorThreshold) {
console.error(
"RedisStringsHandler constructor error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)",
error,
killContainerOnErrorCount++
);
process.exit(1);
}
throw error;
}
}
resetRequestCache() {
}
async assertClientIsReady() {
if (this.clientReadyCalls > 10) {
throw new Error(
"assertClientIsReady called more than 10 times without being ready."
);
}
await Promise.race([
Promise.all([
this.sharedTagsMap.waitUntilReady(),
this.revalidatedTagsMap.waitUntilReady()
]),
new Promise(
(_, reject) => setTimeout(() => {
reject(
new Error(
"assertClientIsReady: Timeout waiting for Redis maps to be ready"
)
);
}, 3e4)
)
]);
this.clientReadyCalls = 0;
if (!this.client.isReady) {
throw new Error(
"assertClientIsReady: Redis client is not ready yet or connection is lost."
);
}
}
async get(key, ctx) {
try {
if (ctx.kind !== "APP_ROUTE" && ctx.kind !== "APP_PAGE" && ctx.kind !== "FETCH") {
console.warn(
"RedisStringsHandler.get() called with",
key,
ctx,
" this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ",
ctx?.kind
);
}
debug("green", "RedisStringsHandler.get() called with", key, ctx);
await this.assertClientIsReady();
const clientGet = this.redisGetDeduplication ? this.deduplicatedRedisGet(key) : this.redisGet;
const redisGetOperation = clientGet(
(0, import_redis.commandOptions)({ signal: AbortSignal.timeout(this.getTimeoutMs) }),
this.keyPrefix + key
).catch((error) => {
if (isAbortError(error)) {
return null;
}
throw error;
});
const serializedCacheEntry = await redisErrorHandler(
"RedisStringsHandler.get(), operation: get" + (this.redisGetDeduplication ? "deduplicated" : "") + " " + this.getTimeoutMs + "ms " + this.keyPrefix + " " + key,
redisGetOperation
);
debug(
"green",
"RedisStringsHandler.get() finished with result (serializedCacheEntry)",
serializedCacheEntry?.substring(0, 200)
);
if (!serializedCacheEntry) {
return null;
}
let cacheEntry;
try {
cacheEntry = await this.valueSerializer.deserialize(serializedCacheEntry);
} catch (err) {
console.warn(
"RedisStringsHandler.get() valueSerializer.deserialize failed, treating as cache miss",
this.keyPrefix + key,
err
);
return null;
}
debug(
"green",
"RedisStringsHandler.get() finished with result (cacheEntry)",
JSON.stringify(cacheEntry).substring(0, 200)
);
if (!cacheEntry) {
return null;
}
if (!cacheEntry?.tags) {
console.warn(
"RedisStringsHandler.get() called with",
key,
ctx,
"cacheEntry is mall formed (missing tags)"
);
}
if (!cacheEntry?.value) {
console.warn(
"RedisStringsHandler.get() called with",
key,
ctx,
"cacheEntry is mall formed (missing value)"
);
}
if (!cacheEntry?.lastModified) {
console.warn(
"RedisStringsHandler.get() called with",
key,
ctx,
"cacheEntry is mall formed (missing lastModified)"
);
}
if (ctx.kind === "FETCH") {
const combinedTags = /* @__PURE__ */ new Set([
...ctx?.softTags || [],
...ctx?.tags || []
]);
if (combinedTags.size === 0) {
return cacheEntry;
}
for (const tag of combinedTags) {
const revalidationTime = this.revalidatedTagsMap.get(tag);
if (revalidationTime && revalidationTime > cacheEntry.lastModified) {
const redisKey = this.keyPrefix + key;
this.client.unlink(redisKey).catch((err) => {
console.error(
"Error occurred while unlinking stale data. Error was:",
err
);
}).finally(async () => {
await this.sharedTagsMap.delete(key);
await this.revalidatedTagsMap.delete(tag);
});
debug(
"green",
'RedisStringsHandler.get() found revalidation time for tag. Cache entry is stale and will be deleted and "null" will be returned.',
tag,
redisKey,
revalidationTime,
cacheEntry
);
return null;
}
}
}
return cacheEntry;
} catch (error) {
console.error(
"RedisStringsHandler.get() Error occurred while getting cache entry. Returning null so site can continue to serve content while cache is disabled. The original error was:",
error,
killContainerOnErrorCount++
);
if (this.killContainerOnErrorThreshold > 0 && killContainerOnErrorCount >= this.killContainerOnErrorThreshold) {
console.error(
"RedisStringsHandler get() error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)",
error,
killContainerOnErrorCount
);
this.client.disconnect();
this.client.quit();
setTimeout(() => {
process.exit(1);
}, 500);
}
return null;
}
}
async set(key, data, ctx) {
try {
if (data.kind !== "APP_ROUTE" && data.kind !== "APP_PAGE" && data.kind !== "FETCH") {
console.warn(
"RedisStringsHandler.set() called with",
key,
ctx,
data,
" this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ",
data?.kind
);
}
await this.assertClientIsReady();
if (data.kind === "APP_PAGE" || data.kind === "APP_ROUTE") {
const tags = data.headers["x-next-cache-tags"]?.split(",");
ctx.tags = [...ctx.tags || [], ...tags || []];
}
const cacheEntry = {
lastModified: Date.now(),
tags: ctx?.tags || [],
value: data
};
const serializedCacheEntry = await this.valueSerializer.serialize(cacheEntry);
if (this.redisGetDeduplication) {
this.redisDeduplicationHandler.seedRequestReturn(
key,
serializedCacheEntry
);
}
const revalidate = (
// For fetch requests in newest versions, the revalidate context property is never used, and instead the revalidate property of the passed-in data is used
data.kind === "FETCH" && data.revalidate || ctx.revalidate || ctx.cacheControl?.revalidate || data?.revalidate
);
const expireAt = revalidate && Number.isSafeInteger(revalidate) && revalidate > 0 ? this.estimateExpireAge(revalidate) : this.estimateExpireAge(this.defaultStaleAge);
const setOperation = redisErrorHandler(
"RedisStringsHandler.set(), operation: set " + this.keyPrefix + " " + key,
this.client.set(this.keyPrefix + key, serializedCacheEntry, {
EX: expireAt
})
);
debug(
"blue",
"RedisStringsHandler.set() will set the following serializedCacheEntry",
this.keyPrefix,
key,
data,
ctx,
serializedCacheEntry?.substring(0, 200),
expireAt
);
let setTagsOperation;
if (ctx.tags && ctx.tags.length > 0) {
const currentTags = this.sharedTagsMap.get(key);
const currentIsSameAsNew = currentTags?.length === ctx.tags.length && currentTags.every((v) => ctx.tags.includes(v)) && ctx.tags.every((v) => currentTags.includes(v));
if (!currentIsSameAsNew) {
setTagsOperation = this.sharedTagsMap.set(
key,
structuredClone(ctx.tags)
);
}
}
debug(
"blue",
"RedisStringsHandler.set() will set the following sharedTagsMap",
key,
ctx.tags
);
await Promise.all([setOperation, setTagsOperation]);
} catch (error) {
console.error(
"RedisStringsHandler.set() Error occurred while setting cache entry. The original error was:",
error,
killContainerOnErrorCount++
);
if (this.killContainerOnErrorThreshold > 0 && killContainerOnErrorCount >= this.killContainerOnErrorThreshold) {
console.error(
"RedisStringsHandler set() error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)",
error,
killContainerOnErrorCount
);
this.client.disconnect();
this.client.quit();
setTimeout(() => {
process.exit(1);
}, 500);
}
throw error;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async revalidateTag(tagOrTags, ...rest) {
try {
debug(
"red",
"RedisStringsHandler.revalidateTag() called with",
tagOrTags,
rest
);
const tags = new Set([tagOrTags || []].flat());
await this.assertClientIsReady();
const keysToDelete = /* @__PURE__ */ new Set();
for (const tag of tags) {
if (tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID)) {
const now = Date.now();
debug(
"red",
"RedisStringsHandler.revalidateTag() set revalidation time for tag",
tag,
"to",
now
);
await this.revalidatedTagsMap.set(tag, now);
}
}
for (const [key, sharedTags] of this.sharedTagsMap.entries()) {
if (sharedTags.some((tag) => tags.has(tag))) {
keysToDelete.add(key);
}
}
debug(
"red",
"RedisStringsHandler.revalidateTag() found",
keysToDelete,
"keys to delete"
);
if (keysToDelete.size === 0) {
return;
}
const redisKeys = Array.from(keysToDelete);
const fullRedisKeys = redisKeys.map((key) => this.keyPrefix + key);
const deleteKeysOperation = redisErrorHandler(
"RedisStringsHandler.revalidateTag(), operation: unlink " + this.keyPrefix + " " + fullRedisKeys,
this.client.unlink(fullRedisKeys)
);
if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) {
for (const key of keysToDelete) {
this.inMemoryDeduplicationCache.delete(key);
}
}
const deleteTagsOperation = this.sharedTagsMap.delete(redisKeys);
await Promise.all([deleteKeysOperation, deleteTagsOperation]);
debug(
"red",
"RedisStringsHandler.revalidateTag() finished delete operations"
);
} catch (error) {
console.error(
"RedisStringsHandler.revalidateTag() Error occurred while revalidating tags. The original error was:",
error,
killContainerOnErrorCount++
);
if (this.killContainerOnErrorThreshold > 0 && killContainerOnErrorCount >= this.killContainerOnErrorThreshold) {
console.error(
"RedisStringsHandler revalidateTag() error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)",
error,
killContainerOnErrorCount
);
this.client.disconnect();
this.client.quit();
setTimeout(() => {
process.exit(1);
}, 500);
}
throw error;
}
}
};
// src/utils/prefix.ts
var import_node_fs = __toESM(require("fs"));
var import_node_path = __toESM(require("path"));
function readBuildId(serverDistDir) {
try {
if (serverDistDir) {
const buildIdPath = import_node_path.default.join(serverDistDir, "..", "BUILD_ID");
const buildId = import_node_fs.default.readFileSync(buildIdPath, "utf8").trim();
return buildId || void 0;
}
} catch {
}
try {
const fromCwd = import_node_path.default.join(process.cwd(), ".next", "BUILD_ID");
const buildId = import_node_fs.default.readFileSync(fromCwd, "utf8").trim();
return buildId || void 0;
} catch {
return void 0;
}
}
function resolveKeyPrefix({
optionKeyPrefix,
serverDistDir,
env
}) {
if (optionKeyPrefix !== void 0) {
return optionKeyPrefix;
}
const keyPrefixEnv = env.KEY_PREFIX && env.KEY_PREFIX.length > 0 ? env.KEY_PREFIX : void 0;
const vercelUrl = env.VERCEL_URL && env.VERCEL_URL.length > 0 ? env.VERCEL_URL : void 0;
const buildId = readBuildId(serverDistDir);
return keyPrefixEnv ?? vercelUrl ?? buildId ?? "UNDEFINED_URL_";
}
// src/CachedHandler.ts
var cachedHandler;
var CachedHandler = class {
constructor(options) {
if (!cachedHandler) {
console.log("created cached handler");
const keyPrefix = resolveKeyPrefix({
optionKeyPrefix: options.keyPrefix,
serverDistDir: options.serverDistDir,
env: process.env
});
cachedHandler = new RedisStringsHandler({
...options,
keyPrefix
});
}
}
get(...args) {
debugVerbose("CachedHandler.get called with", args);
return cachedHandler.get(...args);
}
set(...args) {
debugVerbose("CachedHandler.set called with", args);
return cachedHandler.set(...args);
}
revalidateTag(...args) {
debugVerbose("CachedHandler.revalidateTag called with", args);
return cachedHandler.revalidateTag(...args);
}
resetRequestCache(...args) {
return cachedHandler.resetRequestCache(...args);
}
};
// src/CacheComponentsHandler.ts
var import_redis2 = require("redis");
var REVALIDATED_TAGS_KEY2 = "__cacheComponents_revalidated_tags__";
var SHARED_TAGS_KEY = "__cacheComponents_sharedTags__";
var killContainerOnErrorCount2 = 0;
async function streamToBuffer(stream) {
const reader = stream.getReader();
const chunks = [];
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
}
}
if (chunks.length === 1) {
return chunks[0];
}
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
function bufferToReadableStream(buffer) {
return new ReadableStream({
start(controller) {
controller.enqueue(buffer);
controller.close();
}
});
}
var RedisCacheComponentsHandler = class {
constructor({
redisUrl = process.env.REDIS_URL ? process.env.REDIS_URL : process.env.REDISHOST ? `redis://${process.env.REDISHOST}:${process.env.REDISPORT}` : "redis://localhost:6379",
database = process.env.VERCEL_ENV === "production" ? 0 : 1,
keyPrefix,
getTimeoutMs = process.env.REDIS_COMMAND_TIMEOUT_MS ? Number.parseInt(process.env.REDIS_COMMAND_TIMEOUT_MS) ?? 500 : 500,
revalidateTagQuerySize = 250,
avgResyncIntervalMs = 60 * 60 * 1e3,
socketOptions,
clientOptions,
killContainerOnErrorThreshold = process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD ? Number.parseInt(process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD) ?? 0 : 0,
serverDistDir
}) {
try {
this.keyPrefix = resolveKeyPrefix({
optionKeyPrefix: keyPrefix,
serverDistDir,
env: process.env
});
this.getTimeoutMs = getTimeoutMs;
this.client = (0, import_redis2.createClient)({
url: redisUrl,
pingInterval: 1e4,
...database !== 0 ? { database } : {},
...socketOptions ? { socket: { ...socketOptions } } : {},
...clientOptions || {}
});
this.client.on("error", (error) => {
console.error(
"RedisCacheComponentsHandler client error",
error,
killContainerOnErrorCount2++
);
setTimeout(() => {
if (this.client.isOpen) return;
this.client.connect().catch((err) => {
console.error(
"Failed to reconnect RedisCacheComponentsHandler client after connection loss:",
err
);
});
}, 1e3);
if (killContainerOnErrorThreshold > 0 && killContainerOnErrorCount2 >= killContainerOnErrorThreshold) {
console.error(
"RedisCacheComponentsHandler client error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)",
error,
killContainerOnErrorCount2++
);
this.client.disconnect();
this.client.quit();
setTimeout(() => {
process.exit(1);
}, 500);
}
});
this.client.connect().then(() => {
debug("green", "RedisCacheComponentsHandler client connected.");
}).catch(() => {
this.client.connect().catch((error) => {
console.error(
"Failed to connect RedisCacheComponentsHandler client:",
error
);
this.client.disconnect();
throw error;
});
});
const filterKeys = (key) => key !== REVALIDATED_TAGS_KEY2 && key !== SHARED_TAGS_KEY;
this.revalidatedTagsMap = new SyncedMap({
client: this.client,
keyPrefix: this.keyPrefix,
redisKey: REVALIDATED_TAGS_KEY2,
database,
querySize: revalidateTagQuerySize,
filterKeys,
resyncIntervalMs: avgResyncIntervalMs + avgResyncIntervalMs / 10 + Math.random() * (avgResyncIntervalMs / 10)
});
this.sharedTagsMap = new SyncedMap({
client: this.client,
keyPrefix: this.keyPrefix,
redisKey: SHARED_TAGS_KEY,
database,
querySize: revalidateTagQuerySize,
filterKeys,
resyncIntervalMs: avgResyncIntervalMs - avgResyncIntervalMs / 10 + Math.random() * (avgResyncIntervalMs / 10)
});
} catch (error) {
console.error("RedisCacheComponentsHandler constructor error", error);
throw error;
}
}
async assertClientIsReady() {
if (!this.client.isReady && !this.client.isOpen) {
await this.client.connect().catch((error) => {
console.error(
"RedisCacheComponentsHandler assertClientIsReady reconnect error:",
error
);
throw error;
});
}
await Promise.all([
this.revalidatedTagsMap.waitUntilReady(),
this.sharedTagsMap.waitUntilReady()
]);
}
async computeMaxRevalidation(tags) {
let max = 0;
for (const tag of tags) {
const ts = this.revalidatedTagsMap.get(tag);
if (ts && ts > max) {
max = ts;
}
}
return max;
}
async get(cacheKey, softTags) {
const redisKey = `${this.keyPrefix}${cacheKey}`;
try {
await this.assertClientIsReady();
const serialized = await redisErrorHandler(
"RedisCacheComponentsHandler.get(), operation: get " + this.getTimeoutMs + "ms " + redisKey,
this.client.get(
(0, import_redis2.commandOptions)({ signal: AbortSignal.timeout(this.getTimeoutMs) }),
redisKey
)
);
if (!serialized) {
return void 0;
}
const stored = JSON.parse(serialized);
const now = Date.now();
const expiryTime = stored.timestamp + stored.expire * 1e3;
if (Number.isFinite(stored.expire) && stored.expire > 0 && now > expiryTime) {
await this.client.unlink(redisKey).catch(() => {
});
await this.sharedTagsMap.delete(cacheKey).catch(() => {
});
return void 0;
}
const maxRevalidation = await this.computeMaxRevalidation([
...stored.tags || [],
...softTags || []
]);
if (maxRevalidation > 0 && maxRevalidation > stored.timestamp) {
await this.client.unlink(redisKey).catch(() => {
});
await this.sharedTagsMap.delete(cacheKey).catch(() => {
});
return void 0;
}
const valueBuffer = typeof stored.value === "string" ? new Uint8Array(Buffer.from(stored.value, "base64")) : stored.value;
return {
...stored,
value: bufferToReadableStream(valueBuffer)
};
} catch (error) {
console.error(
"RedisCacheComponentsHandler.get() Error occurred while getting cache entry. Returning undefined so site can continue to serve content while cache is disabled. The original error was:",
error,
killContainerOnErrorCount2++
);
return void 0;
}
}
async set(cacheKey, pendingEntry) {
try {
await this.assertClientIsReady();
const entry = await pendingEntry;
const [storeStream] = entry.value.tee();
const buffer = await streamToBuffer(storeStream);
const stored = {
value: Buffer.from(buffer).toString("base64"),
tags: entry.tags || [],
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate
};
let serialized;
try {
const cleanStored = {
value: stored.value,
tags: Array.isArray(stored.tags) ? [...stored.tags] : [],
stale: Number(stored.stale),
timestamp: Number(stored.timestamp),
expire: Number(stored.expire),
revalidate: Number(stored.revalidate)
};
serialized = JSON.stringify(cleanStored);
} catch (jsonError) {
console.error("JSON.stringify error:", jsonError);
console.error("Stored object:", stored);
throw jsonError;
}
const ttlSeconds = Number.isFinite(stored.expire) && stored.expire > 0 ? Math.floor(stored.expire) : void 0;
const redisKey = `${this.keyPrefix}${cacheKey}`;
const setOperation = redisErrorHandler(
"RedisCacheComponentsHandler.set(), operation: set " + redisKey,
this.client.set(redisKey, serialized, {
...ttlSeconds ? { EX: ttlSeconds } : {}
})
);
let tagsOperation;
const tags = stored.tags || [];
if (tags.length > 0) {
const currentTags = this.sharedTagsMap.get(cacheKey);
const currentIsSameAsNew = currentTags?.length === tags.length && currentTags.every((v) => tags.includes(v)) && tags.every((v) => currentTags.includes(v));
if (!currentIsSameAsNew) {
tagsOperation = this.sharedTagsMap.set(cacheKey, [...tags]);
}
}
await Promise.all([setOperation, tagsOperation]);
} catch (error) {
console.error(
"RedisCacheComponentsHandler.set() Error occurred while setting cache entry. The original error was:",
error,
killContainerOnErrorCount2++
);
throw error;
}
}
async refreshTags() {
await this.assertClientIsReady();
}
async getExpiration(tags) {
try {
await this.assertClientIsReady();
return this.computeMaxRevalidation(tags || []);
} catch (error) {
console.error(
"RedisCacheComponentsHandler.getExpiration() Error occurred while getting expiration for tags. The original error was:",
error
);
return 0;
}
}
async updateTags(tags, _durations) {
try {
void _durations;
await this.assertClientIsReady();
const now = Date.now();
const tagsSet = new Set(tags || []);
for (const tag of tagsSet) {
await this.revalidatedTagsMap.set(tag, now);
}
const keysToDelete = /* @__PURE__ */ new Set();
for (const [key, storedTags] of this.sharedTagsMap.entries()) {
if (storedTags.some((tag) => tagsSet.has(tag))) {
keysToDelete.add(key);
}
}
if (keysToDelete.size === 0) {
return;
}
const cacheKeys = Array.from(keysToDelete);
const fullRedisKeys = cacheKeys.map((key) => `${this.keyPrefix}${key}`);
await redisErrorHandler(
"RedisCacheComponentsHandler.updateTags(), operation: unlink",
this.client.unlink(fullRedisKeys)
);
const deleteTagsOperation = this.sharedTagsMap.delete(cacheKeys);
await deleteTagsOperation;
} catch (error) {
console.error(
"RedisCacheComponentsHandler.updateTags() Error occurred while updating tags. The original error was:",
error,
killContainerOnErrorCount2++
);
throw error;
}
}
};
var singletonHandler;
function getRedisCacheComponentsHandler(options = {}) {
if (!singletonHandler) {
singletonHandler = new RedisCacheComponentsHandler(options);
}
return singletonHandler;
}
var redisCacheHandler = getRedisCacheComponentsHandler();
// src/index.ts
var index_default = CachedHandler;
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
RedisStringsHandler,
bufferAndMapReplacer,
bufferAndMapReviver,
getRedisCacheComponentsHandler,
jsonCacheValueSerializer,
redisCacheHandler
});
//# sourceMappingURL=index.js.map