import type { CacheInterface, TypeCacheKey, TypePartialCacheKey } from '@cachehub/core'; import { CacheKeySerializationHelper } from '@cachehub/core'; export class CloudflareCache implements CacheInterface { private readonly getCache: () => Promise; /** * Constructor * * @param {string} baseUrl - The base URL for cache keys * @param {() => Promise} [getCache] - Function to get the cache instance */ public constructor(private readonly baseUrl: string, getCache?: () => Promise) { this.getCache = getCache ?? (async (): Promise => (caches as any).default); } /** * Sets a value for a given key for a certain period of time * * @param {string | TypeCacheKey} key - The key of the entry * @param {TypeValue} value - The value to be stored * @param {number} [expiresAfter] - The time in seconds after which the entry should expire. * @returns {Promise} True if the value is set successfully, false otherwise */ public async set(key: string | TypeCacheKey, value: TypeValue, expiresAfter?: number): Promise { const cache = await this.getCache(); const serializedKey = CacheKeySerializationHelper.serialize(key); const headers = new Headers(); if (expiresAfter) { headers.append('Cache-Control', `max-age=${expiresAfter}`); } const serializedValue = typeof value === 'string' ? value : JSON.stringify(value); const response = new Response(serializedValue, { headers }); await cache.put(this.getCacheKeyAsRequest(serializedKey), response); // Track this key for pattern deletion support if (typeof key !== 'string') { await this.trackKey(cache, key, serializedKey); } return true; } /** * Returns a value stored for a given key * * @param {string | TypeCacheKey} key - The key of the entry * @returns {Promise} The value stored for the given key */ public async get(key: string | TypeCacheKey): Promise { const cache = await this.getCache(); const serializedKey = CacheKeySerializationHelper.serialize(key); const response = await cache.match(this.getCacheKeyAsRequest(serializedKey)); if (response && response.status === 200) { const text = await response.text(); try { return JSON.parse(text) as TypeValue; } catch { return text as TypeValue; } } return undefined; } /** * Deletes an entry with a given key or pattern * * Supports: * - String keys: 'exact-key' * - TypeCacheKey objects: { namespace: 'license', id: '123', context: { action: 'activation' } } * - TypePartialCacheKey objects: { namespace: 'license', id: '123' } for pattern deletion * * @param {string | TypeCacheKey | TypePartialCacheKey} key - The key to delete * @returns {Promise} True if any entries were deleted, false otherwise */ public async delete(key: string | TypeCacheKey | TypePartialCacheKey): Promise { const cache = await this.getCache(); // Handle different key types if (typeof key === 'string') { // String key - use as-is const deleted = await cache.delete(this.getCacheKeyAsRequest(key)); if (deleted) { await this.untrackKey(cache, key); } return deleted; } else if (this.isExactDeletion(key)) { // Exact deletion: namespace + id with no context const serializedKey = CacheKeySerializationHelper.serialize(key); const deleted = await cache.delete(this.getCacheKeyAsRequest(serializedKey)); if (deleted) { await this.untrackKey(cache, serializedKey); } return deleted; } else { // Pattern deletion: any key with context (partial matching) return await this.deleteByPattern(cache, key as TypePartialCacheKey); } } /** * Checks if a key exists in the cache * * @param {string | CacheKey} key - The key to check * @returns {Promise} True if the key exists, false otherwise */ public async has(key: string | TypeCacheKey): Promise { const cache = await this.getCache(); const serializedKey = CacheKeySerializationHelper.serialize(key); const response = await cache.match(this.getCacheKeyAsRequest(serializedKey)); return response !== undefined; } /** * Type guard to check if a key should be treated as exact deletion vs pattern deletion * * For exact deletion: Only when we have complete information that could represent an actual stored key * For pattern deletion: Any key intended for bulk operations (partial info or no context) * * @private * @param {TypeCacheKey | TypePartialCacheKey} key - The key to check * @returns {boolean} True if it should be treated as exact deletion */ private isExactDeletion(key: TypeCacheKey | TypePartialCacheKey): key is TypeCacheKey { // Never use exact deletion - always use pattern deletion for structured keys // This ensures keys like { namespace: 'license', id: '123' } delete all matches // and keys like { namespace: 'license', id: '123', context: { action: 'activation' } } // delete all keys that match the partial context return false; } /** * Deletes cache entries matching a pattern using key tracking * * @private * @param {Cache} cache - The cache instance * @param {TypePartialCacheKey} partialKey - The partial key pattern to match * @returns {Promise} True if any entries were deleted, false otherwise */ private async deleteByPattern(cache: Cache, partialKey: TypePartialCacheKey): Promise { // Use the most specific tracking key available for pattern deletion const trackingKey = this.getMostSpecificTrackingKey(partialKey); let deletedCount = 0; const trackedKeys = await this.getTrackedKeys(cache, trackingKey); for (const trackedKey of trackedKeys) { // Double-check that this key actually matches our pattern if (this.keyMatchesPattern(trackedKey, partialKey)) { const deleted = await cache.delete(this.getCacheKeyAsRequest(trackedKey)); if (deleted) { deletedCount++; // Remove from all tracking lists await this.untrackKey(cache, trackedKey); } } } return deletedCount > 0; } /** * Tracks a cache key for pattern deletion support * * @private * @param {Cache} cache - The cache instance * @param {TypeCacheKey} key - The structured cache key * @param {string} serializedKey - The serialized cache key * @returns {Promise} */ private async trackKey(cache: Cache, key: TypeCacheKey, serializedKey: string): Promise { const trackingKeys = this.generateTrackingKeys(key); for (const trackingKey of trackingKeys) { try { const existingKeys = await this.getTrackedKeys(cache, trackingKey); if (!existingKeys.includes(serializedKey)) { existingKeys.push(serializedKey); await this.setTrackedKeys(cache, trackingKey, existingKeys); } } catch (error) { console.warn(`Failed to track key ${serializedKey} under ${trackingKey}:`, error); } } } /** * Removes a cache key from tracking lists * * @private * @param {Cache} cache - The cache instance * @param {string} serializedKey - The serialized cache key to untrack * @returns {Promise} */ private async untrackKey(cache: Cache, serializedKey: string): Promise { // We need to check all possible tracking keys since we don't know which ones this key belongs to // This is a limitation of the approach - we'll check common patterns const possiblePatterns = this.generatePossibleTrackingKeys(serializedKey); for (const trackingKey of possiblePatterns) { try { const existingKeys = await this.getTrackedKeys(cache, trackingKey); const updatedKeys = existingKeys.filter(k => k !== serializedKey); if (updatedKeys.length !== existingKeys.length) { if (updatedKeys.length > 0) { await this.setTrackedKeys(cache, trackingKey, updatedKeys); } else { // Remove empty tracking list await cache.delete(this.getCacheKeyAsRequest(trackingKey)); } } } catch (error) { // Ignore errors when cleaning up tracking } } } /** * Gets the most specific tracking key for a partial key pattern * * @private * @param {TypePartialCacheKey} partialKey - The partial key pattern * @returns {string} The most specific tracking key */ private getMostSpecificTrackingKey(partialKey: TypePartialCacheKey): string { // Serialize the partial key to get all its parts const serializedKey = CacheKeySerializationHelper.serializePartial(partialKey); // Use the full serialized key as the most specific tracking key return `__track__:${serializedKey}`; } /** * Checks if a serialized key matches a partial key pattern * * @private * @param {string} serializedKey - The serialized cache key * @param {TypePartialCacheKey} partialKey - The partial key pattern to match * @returns {boolean} True if the key matches the pattern */ private keyMatchesPattern(serializedKey: string, partialKey: TypePartialCacheKey): boolean { const parts = serializedKey.split('|'); // Check namespace match if (parts.length === 0 || parts[0] !== partialKey.namespace) { return false; } // Check id match if specified if (partialKey.id && (parts.length < 2 || parts[1] !== partialKey.id)) { return false; } // Check context match if specified if (partialKey.context && Object.keys(partialKey.context).length > 0) { // Extract context parts from serialized key (parts[2] onwards are context:value pairs) const contextParts = parts.slice(2); const contextMap = new Map(); // Parse context parts into a map for (const contextPart of contextParts) { const colonIndex = contextPart.indexOf(':'); if (colonIndex > 0) { const key = contextPart.substring(0, colonIndex); const value = contextPart.substring(colonIndex + 1); // Unescape the value to match original format const unescapedValue = this.unescapeValue(value); contextMap.set(key, unescapedValue); } } // Check that all required context properties match for (const [requiredKey, requiredValue] of Object.entries(partialKey.context)) { if (requiredValue !== undefined && requiredValue !== null) { const actualValue = contextMap.get(requiredKey); if (actualValue !== requiredValue) { return false; } } } } return true; } /** * Generates tracking keys for a given cache key * * @private * @param {TypeCacheKey | TypePartialCacheKey} key - The cache key * @returns {string[]} Array of tracking keys */ private generateTrackingKeys(key: TypeCacheKey | TypePartialCacheKey): string[] { const trackingKeys: string[] = []; // Serialize the key to get all parts const serializedKey = this.isExactDeletion(key) ? CacheKeySerializationHelper.serialize(key) : CacheKeySerializationHelper.serializePartial(key); const parts = serializedKey.split('|'); // Generate tracking keys for all levels from 1 to N // This allows deletion by any number of parts: namespace, namespace|id, namespace|id|context1, etc. for (let i = 1; i <= parts.length; i++) { const trackingKeyParts = parts.slice(0, i); trackingKeys.push(`__track__:${trackingKeyParts.join('|')}`); } return trackingKeys; } /** * Generates possible tracking keys for a serialized cache key (for cleanup) * * @private * @param {string} serializedKey - The serialized cache key * @returns {string[]} Array of possible tracking keys */ private generatePossibleTrackingKeys(serializedKey: string): string[] { const parts = serializedKey.split('|'); const trackingKeys: string[] = []; // Generate tracking keys for all levels from N down to 1 // This allows tracking at any depth: namespace, namespace|id, namespace|id|context1, etc. for (let i = 1; i <= parts.length; i++) { const trackingKeyParts = parts.slice(0, i); trackingKeys.push(`__track__:${trackingKeyParts.join('|')}`); } return trackingKeys; } /** * Gets tracked keys for a tracking key * * @private * @param {Cache} cache - The cache instance * @param {string} trackingKey - The tracking key * @returns {Promise} Array of tracked keys */ private async getTrackedKeys(cache: Cache, trackingKey: string): Promise { const response = await cache.match(this.getCacheKeyAsRequest(trackingKey)); if (response && response.status === 200) { const text = await response.text(); try { return JSON.parse(text) as string[]; } catch { return []; } } return []; } /** * Sets tracked keys for a tracking key * * @private * @param {Cache} cache - The cache instance * @param {string} trackingKey - The tracking key * @param {string[]} keys - Array of keys to track * @returns {Promise} */ private async setTrackedKeys(cache: Cache, trackingKey: string, keys: string[]): Promise { const headers = new Headers(); headers.set('Content-Type', 'application/json'); const response = new Response(JSON.stringify(keys), { headers }); await cache.put(this.getCacheKeyAsRequest(trackingKey), response); } /** * Unescapes special characters in cache key values (reverse of CacheKeySerializationHelper.escapeValue) * * @private * @param {string} value - Value to unescape * @returns {string} Unescaped value */ private unescapeValue(value: string): string { // Reverse the escaping done in CacheKeySerializationHelper.escapeValue return value .replace(/\\\*/g, '*') // Unescape wildcards .replace(/\\\|/g, '|') // Unescape pipe separators .replace(/\\\\/g, '\\'); // Unescape backslashes last } /** * Returns a cache key as request for a given key * * @param {string} key - The key of the entry * @returns {Request} The cache request */ private getCacheKeyAsRequest(key: string): Request { const cacheUrl = new URL(`${this.baseUrl}/${key}`); return new Request(cacheUrl.toString(), { method: 'GET' }); } }