@fortedigital/nextjs-cache-handler
Version:
Next.js cache handlers
246 lines (242 loc) • 9.01 kB
JavaScript
// src/constants.ts
var REVALIDATED_TAGS_KEY = "__revalidated_tags__";
// src/helpers/const.ts
var NEXT_CACHE_IMPLICIT_TAG_ID = "_N_T_";
var MAX_INT32 = 2 ** 31 - 1;
// src/helpers/isImplicitTag.ts
function isImplicitTag(tag) {
return tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID);
}
// src/handlers/redis-strings.ts
function createHandler({
client,
keyPrefix = "",
sharedTagsKey = "__sharedTags__",
sharedTagsTtlKey = "__sharedTagsTtl__",
timeoutMs = 5e3,
keyExpirationStrategy = "EXPIREAT",
revalidateTagQuerySize = 1e4
}) {
function assertClientIsReady() {
if (!client.withAbortSignal(AbortSignal.timeout(timeoutMs)).isReady) {
throw new Error(
"Redis client is not ready yet or connection is lost. Keep trying..."
);
}
}
async function revalidateTag(tag) {
assertClientIsReady();
if (isImplicitTag(tag)) {
await client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hSet(revalidatedTagsKey, tag, Date.now());
}
const tagsMap = /* @__PURE__ */ new Map();
let cursor = "0";
const hScanOptions = { COUNT: revalidateTagQuerySize };
do {
const remoteTagsPortion = await client.hScan(
keyPrefix + sharedTagsKey,
cursor,
hScanOptions
);
for (const { field, value } of remoteTagsPortion.entries) {
tagsMap.set(field, JSON.parse(value));
}
cursor = remoteTagsPortion.cursor;
} while (cursor !== "0");
const keysToDelete = [];
const tagsToDelete = [];
for (const [key, tags] of tagsMap) {
if (tags.includes(tag)) {
keysToDelete.push(keyPrefix + key);
tagsToDelete.push(key);
}
}
if (keysToDelete.length === 0) {
return;
}
const deleteKeysOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).unlink(keysToDelete);
const updateTagsOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hDel(keyPrefix + sharedTagsKey, tagsToDelete);
const updateTtlOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hDel(keyPrefix + sharedTagsTtlKey, tagsToDelete);
await Promise.all([
deleteKeysOperation,
updateTtlOperation,
updateTagsOperation
]);
}
async function revalidateSharedKeys() {
assertClientIsReady();
const ttlMap = /* @__PURE__ */ new Map();
let cursor = "0";
const hScanOptions = { COUNT: revalidateTagQuerySize };
do {
const remoteTagsPortion = await client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hScan(keyPrefix + sharedTagsTtlKey, cursor, hScanOptions);
for (const { field, value } of remoteTagsPortion.entries) {
ttlMap.set(field, Number(value));
}
cursor = remoteTagsPortion.cursor;
} while (cursor !== "0");
const tagsAndTtlToDelete = [];
const keysToDelete = [];
for (const [key, ttlInSeconds] of ttlMap) {
if ((/* @__PURE__ */ new Date()).getTime() > ttlInSeconds * 1e3) {
tagsAndTtlToDelete.push(key);
keysToDelete.push(keyPrefix + key);
}
}
if (tagsAndTtlToDelete.length === 0) {
return;
}
const deleteKeysOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).unlink(keysToDelete);
const updateTtlOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hDel(keyPrefix + sharedTagsTtlKey, tagsAndTtlToDelete);
const updateTagsOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hDel(keyPrefix + sharedTagsKey, tagsAndTtlToDelete);
await Promise.all([
deleteKeysOperation,
updateTagsOperation,
updateTtlOperation
]);
}
const revalidatedTagsKey = keyPrefix + REVALIDATED_TAGS_KEY;
return {
name: "redis-strings",
async get(key, { implicitTags }) {
assertClientIsReady();
const result = await client.withAbortSignal(AbortSignal.timeout(timeoutMs)).get(keyPrefix + key);
if (!result) {
return null;
}
const cacheValue = JSON.parse(result);
if (!cacheValue) {
return null;
}
convertStringsToBuffers(cacheValue);
const sharedTagKeyExists = await client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hExists(keyPrefix + sharedTagsKey, key);
if (!sharedTagKeyExists) {
await client.withAbortSignal(AbortSignal.timeout(timeoutMs)).unlink(keyPrefix + key);
return null;
}
const combinedTags = /* @__PURE__ */ new Set([...cacheValue.tags, ...implicitTags]);
if (combinedTags.size === 0) {
return cacheValue;
}
const revalidationTimes = await client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hmGet(revalidatedTagsKey, Array.from(combinedTags));
for (const timeString of revalidationTimes) {
if (timeString && Number.parseInt(timeString, 10) > cacheValue.lastModified) {
await client.withAbortSignal(AbortSignal.timeout(timeoutMs)).unlink(keyPrefix + key);
return null;
}
}
return cacheValue;
},
async set(key, cacheHandlerValue) {
assertClientIsReady();
let setOperation;
let expireOperation;
const lifespan = cacheHandlerValue.lifespan;
if (cacheHandlerValue?.value) {
parseBuffersToStrings(cacheHandlerValue);
}
const setTagsOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hSet(
keyPrefix + sharedTagsKey,
key,
JSON.stringify(cacheHandlerValue.tags ?? [])
);
const setSharedTtlOperation = lifespan ? client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hSet(keyPrefix + sharedTagsTtlKey, key, lifespan.expireAt) : void 0;
await Promise.all([setTagsOperation, setSharedTtlOperation]);
switch (keyExpirationStrategy) {
case "EXAT": {
setOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).set(
keyPrefix + key,
JSON.stringify(cacheHandlerValue),
typeof lifespan?.expireAt === "number" ? {
EXAT: lifespan.expireAt
} : void 0
);
break;
}
case "EXPIREAT": {
setOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).set(keyPrefix + key, JSON.stringify(cacheHandlerValue));
expireOperation = lifespan ? client.withAbortSignal(AbortSignal.timeout(timeoutMs)).expireAt(keyPrefix + key, lifespan.expireAt) : void 0;
break;
}
default: {
throw new Error(
`Invalid keyExpirationStrategy: ${keyExpirationStrategy}`
);
}
}
await Promise.all([setOperation, expireOperation]);
},
async revalidateTag(tag) {
assertClientIsReady();
if (isImplicitTag(tag)) {
await client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hSet(revalidatedTagsKey, tag, Date.now());
}
await Promise.all([revalidateTag(tag), revalidateSharedKeys()]);
},
async delete(key) {
await client.withAbortSignal(AbortSignal.timeout(timeoutMs)).unlink(keyPrefix + key);
await Promise.all([
client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hDel(keyPrefix + sharedTagsKey, key),
client.withAbortSignal(AbortSignal.timeout(timeoutMs)).hDel(keyPrefix + sharedTagsTtlKey, key)
]);
}
};
function parseBuffersToStrings(cacheHandlerValue) {
if (!cacheHandlerValue?.value) {
return;
}
const value = {
...cacheHandlerValue.value
};
const kind = value?.kind;
if (kind === "APP_ROUTE") {
const appRouteData = value;
const appRouteValue = value;
if (appRouteValue?.body) {
appRouteData.body = appRouteValue.body.toString();
}
} else if (kind === "APP_PAGE") {
const appPageData = value;
const appPageValue = value;
if (appPageValue?.rscData) {
appPageData.rscData = appPageValue.rscData.toString();
}
if (appPageValue?.segmentData) {
appPageData.segmentData = Object.fromEntries(
Array.from(appPageValue.segmentData.entries()).map(([key, value2]) => [
key,
value2.toString()
])
);
}
}
}
function convertStringsToBuffers(cacheValue) {
const value = cacheValue.value;
const kind = value?.kind;
if (kind === "APP_ROUTE") {
const appRouteData = value;
if (appRouteData?.body) {
const appRouteValue = value;
appRouteValue.body = Buffer.from(appRouteData.body, "utf-8");
}
} else if (kind === "APP_PAGE") {
const appPageData = value;
const appPageValue = value;
if (appPageData.rscData) {
appPageValue.rscData = Buffer.from(appPageData.rscData, "utf-8");
}
if (appPageData.segmentData) {
appPageValue.segmentData = new Map(
Object.entries(appPageData.segmentData).map(([key, value2]) => [
key,
Buffer.from(value2, "utf-8")
])
);
}
}
}
}
export {
createHandler as default
};