UNPKG

@trieb.work/nextjs-turbo-redis-cache

Version:

The ultimate Redis caching solution for Next.js. Built for production-ready, large-scale projects, it delivers unparalleled performance and efficiency with features tailored for high-traffic applications.

532 lines (525 loc) 17.8 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; 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 __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { default: () => index_default }); module.exports = __toCommonJS(index_exports); // src/RedisStringsHandler.ts var import_redis = require("redis"); // 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.timeoutMs = options.timeoutMs; 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 this.client.hScan( getTimeoutRedisCommandOptions(this.timeoutMs), 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 this.client.scan( getTimeoutRedisCommandOptions(this.timeoutMs), 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 (_channel, message) => { const key = message; if (key.startsWith(this.keyPrefix)) { const keyInMap = key.substring(this.keyPrefix.length); if (this.filterKeys(keyInMap)) { await this.delete(keyInMap, true); } } }; try { await this.subscriberClient.connect(); 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 keyspace 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) { return this.map.get(key); } async set(key, value) { this.map.set(key, value); const operations = []; if (this.customizedSync?.withoutSetSync) { return; } if (!this.customizedSync?.withoutRedisHashmap) { const options = getTimeoutRedisCommandOptions(this.timeoutMs); operations.push( this.client.hSet( options, this.keyPrefix + this.redisKey, key, JSON.stringify(value) ) ); } const insertMessage = { type: "insert", key, value }; operations.push( this.client.publish(this.syncChannel, JSON.stringify(insertMessage)) ); await Promise.all(operations); } async delete(keys, withoutSyncMessage = false) { const keysArray = Array.isArray(keys) ? keys : [keys]; const operations = []; for (const key of keysArray) { this.map.delete(key); } if (!this.customizedSync?.withoutRedisHashmap) { const options = getTimeoutRedisCommandOptions(this.timeoutMs); operations.push( this.client.hDel(options, this.keyPrefix + this.redisKey, keysArray) ); } if (!withoutSyncMessage) { const deletionMessage = { type: "delete", keys: keysArray }; operations.push( this.client.publish(this.syncChannel, JSON.stringify(deletionMessage)) ); } await Promise.all(operations); } 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) => { const self = this; const dedupedFn = async (...args) => { if (self.inMemoryDeduplicationCache && self.inMemoryDeduplicationCache.has(key)) { const res = await self.inMemoryDeduplicationCache.get(key).then((v) => structuredClone(v)); return res; } const promise = self.fn(...args); self.inMemoryDeduplicationCache.set(key, promise); try { const result = await promise; return structuredClone(result); } finally { setTimeout(() => { 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); setTimeout(() => { this.inMemoryDeduplicationCache.delete(key); }, this.cachingTimeMs); } }; // src/RedisStringsHandler.ts var NEXT_CACHE_IMPLICIT_TAG_ID = "_N_T_"; var REVALIDATED_TAGS_KEY = "__revalidated_tags__"; function isImplicitTag(tag) { return tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID); } function getTimeoutRedisCommandOptions(timeoutMs) { return (0, import_redis.commandOptions)({ signal: AbortSignal.timeout(timeoutMs) }); } var RedisStringsHandler = class { constructor({ database = process.env.VERCEL_ENV === "production" ? 0 : 1, keyPrefix = process.env.VERCEL_URL || "UNDEFINED_URL_", sharedTagsKey = "__sharedTags__", timeoutMs = 5e3, revalidateTagQuerySize = 250, avgResyncIntervalMs = 60 * 60 * 1e3, redisGetDeduplication = true, inMemoryCachingTime = 1e4, defaultStaleAge = 60 * 60 * 24 * 14, estimateExpireAge = (staleAge) => process.env.VERCEL_ENV === "preview" ? staleAge * 1.2 : staleAge * 2 }) { this.keyPrefix = keyPrefix; this.timeoutMs = timeoutMs; this.redisGetDeduplication = redisGetDeduplication; this.inMemoryCachingTime = inMemoryCachingTime; this.defaultStaleAge = defaultStaleAge; this.estimateExpireAge = estimateExpireAge; try { this.client = (0, import_redis.createClient)({ ...database !== 0 ? { database } : {}, url: process.env.REDISHOST ? `redis://${process.env.REDISHOST}:${process.env.REDISPORT}` : "redis://localhost:6379" }); this.client.on("error", (error) => { console.error("Redis client error", error); }); this.client.connect().then(() => { console.info("Redis client connected."); }).catch((error) => { console.error("Failed to connect Redis client:", error); this.client.disconnect(); }); } 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, timeoutMs, querySize: revalidateTagQuerySize, filterKeys, resyncIntervalMs: avgResyncIntervalMs - avgResyncIntervalMs / 10 + Math.random() * (avgResyncIntervalMs / 10) }); this.revalidatedTagsMap = new SyncedMap({ client: this.client, keyPrefix, redisKey: REVALIDATED_TAGS_KEY, database, timeoutMs, querySize: revalidateTagQuerySize, filterKeys, resyncIntervalMs: avgResyncIntervalMs + avgResyncIntervalMs / 10 + Math.random() * (avgResyncIntervalMs / 10) }); this.inMemoryDeduplicationCache = new SyncedMap({ client: this.client, keyPrefix, redisKey: "inMemoryDeduplicationCache", database, timeoutMs, 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; } resetRequestCache(...args) { console.warn("WARNING resetRequestCache() was called", args); } async assertClientIsReady() { await Promise.all([ this.sharedTagsMap.waitUntilReady(), this.revalidatedTagsMap.waitUntilReady() ]); if (!this.client.isReady) { throw new Error("Redis client is not ready yet or connection is lost."); } } async get(key, ctx) { await this.assertClientIsReady(); const clientGet = this.redisGetDeduplication ? this.deduplicatedRedisGet(key) : this.redisGet; const result = await clientGet( getTimeoutRedisCommandOptions(this.timeoutMs), this.keyPrefix + key ); if (!result) { return null; } const cacheValue = JSON.parse(result); if (!cacheValue) { return null; } if (cacheValue.value?.kind === "FETCH") { cacheValue.value.data.body = Buffer.from( cacheValue.value.data.body ).toString("base64"); } const combinedTags = /* @__PURE__ */ new Set([ ...ctx?.softTags || [], ...ctx?.tags || [] ]); if (combinedTags.size === 0) { return cacheValue; } for (const tag of combinedTags) { const revalidationTime = this.revalidatedTagsMap.get(tag); if (revalidationTime && revalidationTime > cacheValue.lastModified) { const redisKey = this.keyPrefix + key; this.client.unlink(getTimeoutRedisCommandOptions(this.timeoutMs), redisKey).catch((err) => { console.error( "Error occurred while unlinking stale data. Retrying now. Error was:", err ); this.client.unlink( getTimeoutRedisCommandOptions(this.timeoutMs), redisKey ); }).finally(async () => { await this.sharedTagsMap.delete(key); await this.revalidatedTagsMap.delete(tag); }); return null; } } return cacheValue; } async set(key, data, ctx) { if (data.kind === "FETCH") { console.time("encoding" + key); data.data.body = Buffer.from(data.data.body, "base64").toString(); console.timeEnd("encoding" + key); } await this.assertClientIsReady(); data.lastModified = Date.now(); const value = JSON.stringify(data); if (this.redisGetDeduplication) { this.redisDeduplicationHandler.seedRequestReturn(key, value); } const expireAt = ctx.revalidate && Number.isSafeInteger(ctx.revalidate) && ctx.revalidate > 0 ? this.estimateExpireAge(ctx.revalidate) : this.estimateExpireAge(this.defaultStaleAge); const options = getTimeoutRedisCommandOptions(this.timeoutMs); const setOperation = this.client.set( options, this.keyPrefix + key, value, { EX: 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) ); } } await Promise.all([setOperation, setTagsOperation]); } async revalidateTag(tagOrTags) { const tags = new Set([tagOrTags || []].flat()); await this.assertClientIsReady(); for (const tag of tags) { if (isImplicitTag(tag)) { const now = Date.now(); await this.revalidatedTagsMap.set(tag, now); } } const keysToDelete = []; for (const [key, sharedTags] of this.sharedTagsMap.entries()) { if (sharedTags.some((tag) => tags.has(tag))) { keysToDelete.push(key); } } if (keysToDelete.length === 0) { return; } const fullRedisKeys = keysToDelete.map((key) => this.keyPrefix + key); const options = getTimeoutRedisCommandOptions(this.timeoutMs); const deleteKeysOperation = this.client.unlink(options, fullRedisKeys); if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) { for (const key of keysToDelete) { this.inMemoryDeduplicationCache.delete(key); } } const deleteTagsOperation = this.sharedTagsMap.delete(keysToDelete); await Promise.all([deleteKeysOperation, deleteTagsOperation]); } }; // src/CachedHandler.ts var cachedHandler; var CachedHandler = class { constructor(options) { if (!cachedHandler) { console.log("created cached handler"); cachedHandler = new RedisStringsHandler(options); } } get(...args) { return cachedHandler.get(...args); } set(...args) { return cachedHandler.set(...args); } revalidateTag(...args) { return cachedHandler.revalidateTag(...args); } resetRequestCache(...args) { return cachedHandler.resetRequestCache(...args); } }; // src/index.ts var index_default = CachedHandler; //# sourceMappingURL=index.js.map