Initial release v1.0.0
Open-source marketplace for AI Agent skills. Features: - Next.js 15 web app with i18n (en/fa) - CLI tool for skill installation (npx skillhub) - GitHub crawler/indexer with multi-strategy discovery - Security scanning for all indexed skills - Self-hostable with Docker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
224
apps/web/lib/cache.ts
Normal file
224
apps/web/lib/cache.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
// Redis client singleton
|
||||
let redis: Redis | null = null;
|
||||
|
||||
/**
|
||||
* Get Redis client instance (singleton)
|
||||
* Returns null if REDIS_URL is not set (graceful degradation)
|
||||
*/
|
||||
export function getRedis(): Redis | null {
|
||||
if (redis) return redis;
|
||||
|
||||
const redisUrl = process.env.REDIS_URL;
|
||||
if (!redisUrl) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Cache] REDIS_URL not set, caching disabled');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
redis = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy: (times) => {
|
||||
if (times > 3) return null; // Stop retrying after 3 attempts
|
||||
return Math.min(times * 100, 3000);
|
||||
},
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Cache] Redis error:', err.message);
|
||||
});
|
||||
|
||||
redis.on('connect', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Cache] Redis connected');
|
||||
});
|
||||
|
||||
return redis;
|
||||
} catch (error) {
|
||||
console.error('[Cache] Failed to initialize Redis:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data by key
|
||||
* Returns null if not cached or Redis unavailable
|
||||
*/
|
||||
export async function getCached<T>(key: string): Promise<T | null> {
|
||||
const client = getRedis();
|
||||
if (!client) return null;
|
||||
|
||||
try {
|
||||
const data = await client.get(key);
|
||||
if (!data) return null;
|
||||
return JSON.parse(data) as T;
|
||||
} catch (error) {
|
||||
console.error(`[Cache] Error getting key ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache with TTL (time-to-live in seconds)
|
||||
*/
|
||||
export async function setCache(
|
||||
key: string,
|
||||
data: unknown,
|
||||
ttlSeconds: number
|
||||
): Promise<void> {
|
||||
const client = getRedis();
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.setex(key, ttlSeconds, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error(`[Cache] Error setting key ${key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache by exact key
|
||||
*/
|
||||
export async function invalidateCache(key: string): Promise<void> {
|
||||
const client = getRedis();
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.del(key);
|
||||
} catch (error) {
|
||||
console.error(`[Cache] Error deleting key ${key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache by pattern (e.g., "skills:*")
|
||||
* Use with caution - KEYS command can be slow on large datasets
|
||||
*/
|
||||
export async function invalidateCachePattern(pattern: string): Promise<void> {
|
||||
const client = getRedis();
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const keys = await client.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[Cache] Invalidated ${keys.length} keys matching ${pattern}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Cache] Error invalidating pattern ${pattern}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Redis is available and connected
|
||||
*/
|
||||
export async function isCacheAvailable(): Promise<boolean> {
|
||||
const client = getRedis();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
await client.ping();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache key builders for consistency
|
||||
export const cacheKeys = {
|
||||
stats: () => 'stats:global',
|
||||
categories: () => 'categories:all',
|
||||
featuredSkills: () => 'skills:featured',
|
||||
recentSkills: () => 'skills:recent',
|
||||
searchSkills: (hash: string) => `skills:search:${hash}`,
|
||||
skill: (id: string) => `skill:${id.replace(/\//g, ':')}`,
|
||||
skillView: (skillId: string, ip: string) => `view:${skillId.replace(/\//g, ':')}:${ip}`,
|
||||
skillDownload: (skillId: string, ip: string) => `download:${skillId.replace(/\//g, ':')}:${ip}`,
|
||||
};
|
||||
|
||||
// TTL values in seconds
|
||||
export const cacheTTL = {
|
||||
stats: 60 * 60, // 1 hour
|
||||
categories: 12 * 60 * 60, // 12 hours
|
||||
featured: 2 * 60 * 60, // 2 hours
|
||||
recent: 60 * 60, // 1 hour
|
||||
search: 30 * 60, // 30 minutes
|
||||
skill: 60 * 60, // 1 hour
|
||||
view: 60 * 60, // 1 hour - same IP can only count as 1 view per hour
|
||||
download: 5 * 60, // 5 minutes - same IP can only count as 1 download per 5 min
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a view should be counted for this IP + skill combination.
|
||||
* Returns true if this is a new view (should be counted), false if already viewed recently.
|
||||
* Uses Redis to track views per IP with 1-hour TTL.
|
||||
*/
|
||||
export async function shouldCountView(skillId: string, ip: string): Promise<boolean> {
|
||||
const client = getRedis();
|
||||
|
||||
// If Redis is not available, always count (graceful degradation)
|
||||
if (!client) return true;
|
||||
|
||||
const key = cacheKeys.skillView(skillId, ip);
|
||||
|
||||
try {
|
||||
// Try to set the key with NX (only if not exists) and EX (expiry)
|
||||
// Returns 'OK' if set successfully (new view), null if already exists
|
||||
const result = await client.set(key, '1', 'EX', cacheTTL.view, 'NX');
|
||||
return result === 'OK';
|
||||
} catch (error) {
|
||||
console.error(`[Cache] Error checking view for ${skillId}:`, error);
|
||||
// On error, count the view (graceful degradation)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a download should be counted for this IP + skill combination.
|
||||
* Returns true if this is a new download (should be counted), false if already downloaded recently.
|
||||
* Uses Redis to track downloads per IP with 5-minute TTL (shorter than views to allow re-downloads).
|
||||
*/
|
||||
export async function shouldCountDownload(skillId: string, ip: string): Promise<boolean> {
|
||||
const client = getRedis();
|
||||
|
||||
// If Redis is not available, always count (graceful degradation)
|
||||
if (!client) return true;
|
||||
|
||||
const key = cacheKeys.skillDownload(skillId, ip);
|
||||
|
||||
try {
|
||||
// Try to set the key with NX (only if not exists) and EX (expiry)
|
||||
// Returns 'OK' if set successfully (new download), null if already exists
|
||||
const result = await client.set(key, '1', 'EX', cacheTTL.download, 'NX');
|
||||
return result === 'OK';
|
||||
} catch (error) {
|
||||
console.error(`[Cache] Error checking download for ${skillId}:`, error);
|
||||
// On error, count the download (graceful degradation)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple hash for search query parameters
|
||||
*/
|
||||
export function hashSearchParams(params: Record<string, string | number | undefined>): string {
|
||||
const sorted = Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&');
|
||||
|
||||
// Simple hash function
|
||||
let hash = 0;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const char = sorted.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
Reference in New Issue
Block a user