UNPKG

@rb2bv/cache-handler

Version:
149 lines (144 loc) 4.44 kB
// 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 };