UNPKG

@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
"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