@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.
509 lines (504 loc) • 16.8 kB
JavaScript
// src/RedisStringsHandler.ts
import { commandOptions, createClient } from "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 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 = 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;
export {
index_default as default
};
//# sourceMappingURL=index.mjs.map