@rb2bv/cache-handler
Version:
Next.js self-hosting simplified.
149 lines (144 loc) • 4.44 kB
JavaScript
// src/handlers/redis-stack.ts
import { ErrorReply, SchemaFieldTypes } from "redis";
import { randomBytes } from "node:crypto";
// src/constants.ts
var MAX_INT32 = 2 ** 31 - 1;
var TIME_ONE_YEAR = 31536e3;
var REVALIDATED_TAGS_KEY = "__revalidated_tags__";
// src/helpers/get-timeout-redis-command-options.ts
import { commandOptions } from "redis";
function getTimeoutRedisCommandOptions(timeoutMs) {
if (timeoutMs === 0) {
return commandOptions({});
}
return commandOptions({ signal: AbortSignal.timeout(timeoutMs) });
}
// src/helpers/is-implicit-tag.ts
var NEXT_CACHE_IMPLICIT_TAG_ID = "_N_T_";
function isImplicitTag(tag) {
return tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID);
}
// src/handlers/redis-stack.ts
function createHandler({
client,
keyPrefix = "",
timeoutMs = 5e3,
revalidateTagQuerySize = 100
}) {
function assertClientIsReady() {
if (!client.isReady) {
throw new Error("Redis client is not ready");
}
}
function sanitizeTag(str) {
return str.replace(/[^a-zA-Z0-9]/gi, "_");
}
const indexName = `idx:tags-${randomBytes(32).toString("hex")}`;
async function createIndexIfNotExists() {
try {
await client.ft.create(
indexName,
{
"$.tags": { type: SchemaFieldTypes.TEXT, AS: "tag" }
},
{
ON: "JSON",
TEMPORARY: TIME_ONE_YEAR
}
);
} catch (error) {
if (error instanceof ErrorReply && error.message === "Index already exists") {
return;
}
throw error;
}
}
const revalidatedTagsKey = keyPrefix + REVALIDATED_TAGS_KEY;
return {
name: "redis-stack",
async get(key, { implicitTags }) {
assertClientIsReady();
const cacheValue = await client.json.get(
getTimeoutRedisCommandOptions(timeoutMs),
keyPrefix + key
);
if (!cacheValue) {
return null;
}
const sanitizedImplicitTags = implicitTags.map(sanitizeTag);
const combinedTags = /* @__PURE__ */ new Set([...cacheValue.tags, ...sanitizedImplicitTags]);
if (combinedTags.size === 0) {
return cacheValue;
}
const revalidationTimes = await client.hmGet(
getTimeoutRedisCommandOptions(timeoutMs),
revalidatedTagsKey,
Array.from(combinedTags)
);
for (const timeString of revalidationTimes) {
if (timeString && Number.parseInt(timeString, 10) > cacheValue.lastModified) {
await client.unlink(getTimeoutRedisCommandOptions(timeoutMs), keyPrefix + key);
return null;
}
}
return cacheValue;
},
async set(key, cacheHandlerValue) {
assertClientIsReady();
cacheHandlerValue.tags = cacheHandlerValue.tags.map(sanitizeTag);
const options = getTimeoutRedisCommandOptions(timeoutMs);
const setCacheValue = client.json.set(
options,
keyPrefix + key,
".",
cacheHandlerValue
);
const expireCacheValue = cacheHandlerValue.lifespan ? client.expireAt(options, keyPrefix + key, cacheHandlerValue.lifespan.expireAt) : void 0;
await Promise.all([setCacheValue, expireCacheValue]);
},
async revalidateTag(tag) {
assertClientIsReady();
await createIndexIfNotExists();
const sanitizedTag = sanitizeTag(tag);
if (isImplicitTag(tag)) {
await client.hSet(
getTimeoutRedisCommandOptions(timeoutMs),
revalidatedTagsKey,
sanitizedTag,
Date.now()
);
}
let from = 0;
const keysToDelete = [];
while (true) {
const { documents: documentIds } = await client.ft.searchNoContent(
getTimeoutRedisCommandOptions(timeoutMs),
indexName,
`@tag:(${sanitizedTag})`,
{
LIMIT: { from, size: revalidateTagQuerySize },
TIMEOUT: timeoutMs
}
);
for (const id of documentIds) {
keysToDelete.push(id);
}
if (documentIds.length < revalidateTagQuerySize) {
break;
}
from += revalidateTagQuerySize;
}
if (keysToDelete.length === 0) {
return;
}
const options = getTimeoutRedisCommandOptions(timeoutMs);
await client.unlink(options, keysToDelete);
},
async delete(key) {
await client.unlink(getTimeoutRedisCommandOptions(timeoutMs), key);
}
};
}
export {
createHandler as default
};