@fortedigital/nextjs-cache-handler
Version:
Next.js cache handlers
301 lines (294 loc) • 10.5 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/helpers/buffer.ts
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("base64");
}
} else if (kind === "APP_PAGE") {
const appPageData = value;
const appPageValue = value;
if (appPageValue?.rscData) {
appPageData.rscData = appPageValue.rscData.toString("base64");
}
if (appPageValue?.segmentData) {
appPageData.segmentData = Object.fromEntries(
Array.from(appPageValue.segmentData.entries()).map(([key, value2]) => [
key,
value2.toString("base64")
])
);
}
}
}
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, "base64");
}
} else if (kind === "APP_PAGE") {
const appPageData = value;
const appPageValue = value;
if (appPageData.rscData) {
appPageValue.rscData = Buffer.from(appPageData.rscData, "base64");
}
if (appPageData.segmentData) {
appPageValue.segmentData = new Map(
Object.entries(appPageData.segmentData).map(([key, value2]) => [
key,
Buffer.from(value2, "base64")
])
);
}
}
}
// src/helpers/withAbortSignal.ts
function withAbortSignal(promiseFn, signal) {
if (!signal) return promiseFn();
return new Promise((resolve, reject) => {
if (signal.aborted) {
return reject(new Error("Aborted"));
}
let settled = false;
const onAbort = () => {
if (!settled) {
settled = true;
reject(new Error("Operation aborted"));
}
};
signal.addEventListener("abort", onAbort, { once: true });
promiseFn().then((res) => {
if (!settled) {
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(res);
}
}).catch((err) => {
if (!settled) {
settled = true;
signal.removeEventListener("abort", onAbort);
reject(err);
}
});
});
}
// src/helpers/withAbortSignalProxy.ts
function withAbortSignalProxy(obj, defaultSignal) {
function createProxy(signal) {
const handler = {
get(target, prop, receiver) {
if (prop === "withAbortSignal") {
return (s) => createProxy(s);
}
const orig = Reflect.get(target, prop, receiver);
if (typeof orig !== "function") return orig;
return (...args) => withAbortSignal(() => orig.apply(receiver, args), signal);
}
};
return new Proxy(obj, handler);
}
return createProxy(defaultSignal);
}
// src/handlers/redis-strings.ts
function createHandler({
client: innerClient,
keyPrefix = "",
sharedTagsKey = "__sharedTags__",
sharedTagsTtlKey = "__sharedTagsTtl__",
timeoutMs = 5e3,
keyExpirationStrategy = "EXPIREAT",
revalidateTagQuerySize = 1e4
}) {
const client = withAbortSignalProxy(innerClient);
const revalidatedTagsKey = keyPrefix + REVALIDATED_TAGS_KEY;
function assertClientIsReady() {
if (!client.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
]);
}
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;
const valueForStorage = cacheHandlerValue.value ? { ...cacheHandlerValue.value } : null;
if (valueForStorage) {
parseBuffersToStrings({ ...cacheHandlerValue, value: valueForStorage });
}
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]);
const serializedValue = JSON.stringify({
...cacheHandlerValue,
value: valueForStorage
});
switch (keyExpirationStrategy) {
case "EXAT": {
setOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).set(
keyPrefix + key,
serializedValue,
typeof lifespan?.expireAt === "number" ? {
EXAT: lifespan.expireAt
} : void 0
);
break;
}
case "EXPIREAT": {
setOperation = client.withAbortSignal(AbortSignal.timeout(timeoutMs)).set(keyPrefix + key, serializedValue);
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)
]);
}
};
}
export {
createHandler as default
};