import { createPool, Pool } from 'generic-pool'; import { RedisClientType } from 'redis'; import Settings from 'settings'; import { CacheOptions, SearchParams } from '../types'; import logger from '../utils/log'; const CACHE_VERSION = 'v2'; const compressData = async (data: string): Promise => { const stream = new CompressionStream('gzip'); const writer = stream.writable.getWriter(); const reader = stream.readable.getReader(); writer.write(new TextEncoder().encode(data)); writer.close(); const chunks: Uint8Array[] = []; let done = false; while (!done) { const { value, done: readerDone } = await reader.read(); done = readerDone; if (value) chunks.push(value); } const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return result; }; const decompressData = async (compressed: Uint8Array): Promise => { const stream = new DecompressionStream('gzip'); const writer = stream.writable.getWriter(); const reader = stream.readable.getReader(); writer.write(compressed as any); writer.close(); const chunks: Uint8Array[] = []; let done = false; while (!done) { const { value, done: readerDone } = await reader.read(); done = readerDone; if (value) chunks.push(value); } const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return new TextDecoder().decode(result); }; const hashCacheKey = (object?: Record) => { if (!object) { return ''; } const sortedFilters = Object.entries(object).sort(([firstKey], [secondKey]) => firstKey.localeCompare(secondKey) ); const cacheKey = Buffer.from( JSON.stringify(Object.fromEntries(sortedFilters)) ).toString('base64'); return `_${encodeURIComponent(cacheKey)}`; }; export const CacheKey = { List: (searchParams: SearchParams, headers?: Record) => `list_${encodeURIComponent(JSON.stringify(searchParams))}${hashCacheKey( headers )}`, Category: ( pk: number, searchParams?: SearchParams, headers?: Record ) => `category_${pk}_${encodeURIComponent( JSON.stringify(searchParams) )}${hashCacheKey(headers)}`, Basket: (namespace?: string) => `basket${namespace ? `_${namespace}` : ''}`, AllBaskets: () => 'all_baskets', CategorySlug: (slug: string) => `category_${slug}`, SpecialPage: ( pk: number, searchParams: SearchParams, headers?: Record ) => `special_page_${pk}_${encodeURIComponent( JSON.stringify(searchParams) )}${hashCacheKey(headers)}`, Product: (pk: number, searchParams: SearchParams) => `product_${pk}_${encodeURIComponent(JSON.stringify(searchParams))}`, GroupProduct: (pk: number, searchParams: SearchParams) => `group_product_${pk}_${encodeURIComponent(JSON.stringify(searchParams))}`, FlatPage: (pk: number) => `flat_page_${pk}`, LandingPage: (pk: number) => `landing_page_${pk}`, Widget: (slug: string) => `widget_${slug}`, WidgetSchema: (widgetSlug: string) => `widget_schema_${widgetSlug}`, PrettyUrl: (pathname: string) => `pretty_url_${pathname}`, Menu: (depth: number, parent?: string) => `menu_${depth}${parent ? `_${parent}` : ''}`, Seo: (url: string) => `seo_${url}`, RootSeo: 'root_seo', Form: (pk: number) => `form_${pk}` }; export class Cache { static PROXY_URL = `${process.env.NEXT_PUBLIC_URL}/api/cache`; private static serializeValue(value: any): string { return typeof value === 'object' ? JSON.stringify(value) : String(value); } private static validateKey(key: string): boolean { return !(!key || key.trim() === ''); } private static validateKeyValuePairs(keyValuePairs: Record): { isValid: boolean; invalidKeys: string[]; } { if (!keyValuePairs || Object.keys(keyValuePairs).length === 0) { return { isValid: false, invalidKeys: [] }; } const invalidKeys = Object.keys(keyValuePairs).filter( (key) => !this.validateKey(key) ); return { isValid: invalidKeys.length === 0, invalidKeys }; } static formatKey(key: string, locale: string) { return encodeURIComponent( `${CACHE_VERSION}_${Settings.commerceUrl}_${locale}_${key}` ); } static clientPool: Pool = createPool( { create: async () => { const { createClient } = await import('redis'); const redisUrl = `redis://${process.env.CACHE_HOST}:${ process.env.CACHE_PORT }/${process.env.CACHE_BUCKET ?? '0'}`; const options = { url: redisUrl, ...(process.env.CACHE_PASSWORD && { password: process.env.CACHE_PASSWORD }) }; const client: RedisClientType = createClient(options); client.on('error', (error) => { logger.error('Redis client error', { redisUrl, error }); }); await client.connect(); return client; }, destroy: async (client: RedisClientType) => { await client.disconnect(); } }, { max: 500, min: 2 } ); static async getClient() { return await Cache.clientPool.acquire(); } static async get(key: string): Promise { let value: any; let client: RedisClientType | undefined; try { client = await Cache.getClient(); const response = await client.get(key); if (response) { value = JSON.parse(response); } else { value = null; } } catch (error) { value = null; } finally { if (client) { await Cache.clientPool.release(client); } } return value; } static async set(key: string, value: any, expire?: number): Promise { let success = false; let client: RedisClientType | undefined; try { client = await Cache.getClient(); const serializedValue = Cache.serializeValue(value); if (expire) { await client.set(key, serializedValue, { EX: expire }); } else { await client.set(key, serializedValue); } success = true; } catch (error) { success = false; } finally { if (client) { await Cache.clientPool.release(client); } } return success; } static async wrap( key: string, locale: string, handler: () => Promise, options?: CacheOptions ): Promise { const requiredVariables = [ process.env.CACHE_HOST, process.env.CACHE_PORT, process.env.CACHE_SECRET ]; if (!requiredVariables.every((v) => v)) { return await handler(); } const defaultOptions: CacheOptions = { cache: true, expire: Settings.redis.defaultExpirationTime, compressed: process.env.CACHE_COMPRESSION_ENABLED !== 'false' }; const _options = Object.assign(defaultOptions, options); const formattedKey = Cache.formatKey(key, locale); if (Settings.usePrettyUrlRoute) { _options.expire = 120; } if (_options.cache) { let cachedValue: any; if (_options.useProxy) { const body = new URLSearchParams(); body.append('key', formattedKey); if (_options.compressed) { body.append('compressed', 'true'); } cachedValue = await Cache.proxyRequest('POST', body); } else { cachedValue = _options.compressed ? await Cache.getCompressed(formattedKey) : await Cache.get(formattedKey); } if (cachedValue) { return cachedValue; } } const data = await handler(); if (data && _options.cache) { if (_options.useProxy) { try { const body = new URLSearchParams(); body.append('key', formattedKey); body.append('value', JSON.stringify(data)); body.append( 'expire', String(_options?.expire ?? Settings.redis.defaultExpirationTime) ); if (_options.compressed) { body.append('compressed', 'true'); } await Cache.proxyRequest('PUT', body); } catch (error) { logger.error('Cache proxy error', error); } } else { if (_options.compressed) { await Cache.setCompressed(formattedKey, data, _options?.expire); } else { await Cache.set(formattedKey, JSON.stringify(data), _options?.expire); } } } return data; } static async proxyRequest(method: 'POST' | 'PUT', body: URLSearchParams) { const response = await ( await fetch(Cache.PROXY_URL, { method, headers: { authorization: process.env.CACHE_SECRET || '' }, body }) ).json(); return response; } static async mset( keyValuePairs: Record, expire?: number ): Promise { const validation = Cache.validateKeyValuePairs(keyValuePairs); if (!validation.isValid) { if (validation.invalidKeys.length > 0) { logger.error('Invalid keys in mset', { invalidKeys: validation.invalidKeys }); } else { logger.warn('mset called with empty keyValuePairs'); } return false; } let success = false; let client: RedisClientType | undefined; try { client = await Cache.getClient(); const pipeline = client.multi(); Object.entries(keyValuePairs).forEach(([key, value]) => { const serializedValue = Cache.serializeValue(value); if (expire) { pipeline.set(key, serializedValue, { EX: expire }); } else { pipeline.set(key, serializedValue); } }); const results = await pipeline.exec(); const failures = results?.filter((result) => result instanceof Error) || []; if (failures.length > 0) { success = false; } else { success = true; } } catch (error) { success = false; } finally { if (client) { await Cache.clientPool.release(client); } } return success; } static async setCompressed( key: string, value: any, expire?: number ): Promise { if (!Cache.validateKey(key)) { return false; } let success = false; let client: RedisClientType | undefined; try { client = await Cache.getClient(); const serializedValue = Cache.serializeValue(value); try { const compressed = await compressData(serializedValue); const compressedBase64 = Buffer.from(compressed).toString('base64'); if (expire) { await client.set(key, compressedBase64, { EX: expire }); } else { await client.set(key, compressedBase64); } success = true; } catch (compressionError) { if (expire) { await client.set(key, serializedValue, { EX: expire }); } else { await client.set(key, serializedValue); } success = true; } } catch (error) { success = false; } finally { if (client) { await Cache.clientPool.release(client); } } return success; } static async getCompressed(key: string): Promise { if (!Cache.validateKey(key)) { return null; } let value: unknown; let client: RedisClientType | undefined; try { client = await Cache.getClient(); const compressed = await client.get(key); if (compressed) { const compressedBuffer = Buffer.from(compressed, 'base64'); try { const decompressedString = await decompressData( new Uint8Array(compressedBuffer) ); value = JSON.parse(decompressedString); return value; } catch (decompressionError) { try { const rawString = compressed; const parsedData = JSON.parse(rawString); return parsedData; } catch (jsonError) { return null; } } } else { value = null; } } catch (error) { value = null; } finally { if (client) { await Cache.clientPool.release(client); } } return value; } }