28 changed files with 675 additions and 240 deletions
@ -0,0 +1,205 @@ |
|||||||
|
/** |
||||||
|
* Cache for Nostr events to provide offline access |
||||||
|
* Stores events with TTL to reduce relay load and improve resilience |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; |
||||||
|
import { createHash } from 'crypto'; |
||||||
|
import logger from '../logger.js'; |
||||||
|
|
||||||
|
interface CacheEntry { |
||||||
|
events: NostrEvent[]; |
||||||
|
timestamp: number; |
||||||
|
ttl: number; // Time to live in milliseconds
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate cache key from filter |
||||||
|
* Creates a deterministic key based on filter parameters |
||||||
|
*/ |
||||||
|
function generateCacheKey(filter: NostrFilter): string { |
||||||
|
// Sort filter keys for consistency
|
||||||
|
const sortedFilter = Object.keys(filter) |
||||||
|
.sort() |
||||||
|
.reduce((acc, key) => { |
||||||
|
const value = filter[key as keyof NostrFilter]; |
||||||
|
if (value !== undefined) { |
||||||
|
// Sort array values for consistency
|
||||||
|
if (Array.isArray(value)) { |
||||||
|
acc[key] = [...value].sort(); |
||||||
|
} else { |
||||||
|
acc[key] = value; |
||||||
|
} |
||||||
|
} |
||||||
|
return acc; |
||||||
|
}, {} as Record<string, unknown>); |
||||||
|
|
||||||
|
const filterStr = JSON.stringify(sortedFilter); |
||||||
|
return createHash('sha256').update(filterStr).digest('hex'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate cache key for multiple filters |
||||||
|
*/ |
||||||
|
function generateMultiFilterCacheKey(filters: NostrFilter[]): string { |
||||||
|
const keys = filters.map(f => generateCacheKey(f)).sort(); |
||||||
|
return createHash('sha256').update(keys.join('|')).digest('hex'); |
||||||
|
} |
||||||
|
|
||||||
|
export class EventCache { |
||||||
|
private cache: Map<string, CacheEntry> = new Map(); |
||||||
|
private defaultTTL: number = 5 * 60 * 1000; // 5 minutes default
|
||||||
|
private maxCacheSize: number = 10000; // Maximum number of cache entries
|
||||||
|
|
||||||
|
constructor(defaultTTL?: number, maxCacheSize?: number) { |
||||||
|
if (defaultTTL) { |
||||||
|
this.defaultTTL = defaultTTL; |
||||||
|
} |
||||||
|
if (maxCacheSize) { |
||||||
|
this.maxCacheSize = maxCacheSize; |
||||||
|
} |
||||||
|
|
||||||
|
// Cleanup expired entries every 5 minutes
|
||||||
|
if (typeof setInterval !== 'undefined') { |
||||||
|
setInterval(() => { |
||||||
|
this.cleanup(); |
||||||
|
}, 5 * 60 * 1000); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cached events for a filter |
||||||
|
*/ |
||||||
|
get(filters: NostrFilter[]): NostrEvent[] | null { |
||||||
|
const key = generateMultiFilterCacheKey(filters); |
||||||
|
const entry = this.cache.get(key); |
||||||
|
|
||||||
|
if (!entry) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if entry has expired
|
||||||
|
const now = Date.now(); |
||||||
|
if (now - entry.timestamp > entry.ttl) { |
||||||
|
this.cache.delete(key); |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Filter events to match the current filter (in case filter changed slightly)
|
||||||
|
// For now, we return all cached events - the caller should filter if needed
|
||||||
|
return entry.events; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Set cached events for filters |
||||||
|
*/ |
||||||
|
set(filters: NostrFilter[], events: NostrEvent[], ttl?: number): void { |
||||||
|
// Prevent cache from growing too large
|
||||||
|
if (this.cache.size >= this.maxCacheSize) { |
||||||
|
this.evictOldest(); |
||||||
|
} |
||||||
|
|
||||||
|
const key = generateMultiFilterCacheKey(filters); |
||||||
|
this.cache.set(key, { |
||||||
|
events, |
||||||
|
timestamp: Date.now(), |
||||||
|
ttl: ttl || this.defaultTTL |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Invalidate cache entries matching a filter pattern |
||||||
|
* Useful when events are published/updated |
||||||
|
*/ |
||||||
|
invalidate(filters: NostrFilter[]): void { |
||||||
|
const key = generateMultiFilterCacheKey(filters); |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Invalidate all cache entries for a specific event ID |
||||||
|
* Useful when an event is updated |
||||||
|
*/ |
||||||
|
invalidateEvent(eventId: string): void { |
||||||
|
// Find all cache entries containing this event
|
||||||
|
for (const [key, entry] of this.cache.entries()) { |
||||||
|
if (entry.events.some(e => e.id === eventId)) { |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Invalidate all cache entries for a specific pubkey |
||||||
|
* Useful when a user's events are updated |
||||||
|
*/ |
||||||
|
invalidatePubkey(pubkey: string): void { |
||||||
|
// Find all cache entries containing events from this pubkey
|
||||||
|
for (const [key, entry] of this.cache.entries()) { |
||||||
|
if (entry.events.some(e => e.pubkey === pubkey)) { |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all cache entries |
||||||
|
*/ |
||||||
|
clear(): void { |
||||||
|
this.cache.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear expired entries |
||||||
|
*/ |
||||||
|
cleanup(): void { |
||||||
|
const now = Date.now(); |
||||||
|
let cleaned = 0; |
||||||
|
|
||||||
|
for (const [key, entry] of this.cache.entries()) { |
||||||
|
if (now - entry.timestamp > entry.ttl) { |
||||||
|
this.cache.delete(key); |
||||||
|
cleaned++; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (cleaned > 0) { |
||||||
|
logger.debug({ cleaned, remaining: this.cache.size }, 'Event cache cleanup'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Evict oldest entries when cache is full |
||||||
|
*/ |
||||||
|
private evictOldest(): void { |
||||||
|
// Sort entries by timestamp (oldest first)
|
||||||
|
const entries = Array.from(this.cache.entries()) |
||||||
|
.map(([key, entry]) => ({ key, timestamp: entry.timestamp })) |
||||||
|
.sort((a, b) => a.timestamp - b.timestamp); |
||||||
|
|
||||||
|
// Remove oldest 10% of entries
|
||||||
|
const toRemove = Math.max(1, Math.floor(entries.length * 0.1)); |
||||||
|
for (let i = 0; i < toRemove; i++) { |
||||||
|
this.cache.delete(entries[i].key); |
||||||
|
} |
||||||
|
|
||||||
|
logger.debug({ removed: toRemove, remaining: this.cache.size }, 'Event cache eviction'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cache statistics |
||||||
|
*/ |
||||||
|
getStats(): { size: number; maxSize: number; entries: number } { |
||||||
|
return { |
||||||
|
size: this.cache.size, |
||||||
|
maxSize: this.maxCacheSize, |
||||||
|
entries: Array.from(this.cache.values()).reduce((sum, entry) => sum + entry.events.length, 0) |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const eventCache = new EventCache( |
||||||
|
5 * 60 * 1000, // 5 minutes default TTL
|
||||||
|
10000 // Max 10k cache entries
|
||||||
|
); |
||||||
@ -0,0 +1,99 @@ |
|||||||
|
/** |
||||||
|
* Standardized error handling utilities |
||||||
|
* Provides consistent error handling, logging, and sanitization across the application |
||||||
|
*/ |
||||||
|
|
||||||
|
import { error } from '@sveltejs/kit'; |
||||||
|
import logger from '../services/logger.js'; |
||||||
|
import { sanitizeError } from './security.js'; |
||||||
|
|
||||||
|
export interface ErrorContext { |
||||||
|
operation?: string; |
||||||
|
npub?: string; |
||||||
|
repo?: string; |
||||||
|
filePath?: string; |
||||||
|
branch?: string; |
||||||
|
[key: string]: unknown; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Standardized error handler for API endpoints |
||||||
|
* Handles errors consistently with proper logging and sanitization |
||||||
|
*/ |
||||||
|
export function handleApiError( |
||||||
|
err: unknown, |
||||||
|
context: ErrorContext = {}, |
||||||
|
defaultMessage: string = 'An error occurred' |
||||||
|
): ReturnType<typeof error> { |
||||||
|
const sanitizedError = sanitizeError(err); |
||||||
|
const errorMessage = err instanceof Error ? err.message : defaultMessage; |
||||||
|
|
||||||
|
// Log error with structured context (pino-style)
|
||||||
|
logger.error({
|
||||||
|
error: sanitizedError,
|
||||||
|
...context
|
||||||
|
}, `API Error: ${errorMessage}`); |
||||||
|
|
||||||
|
// Return sanitized error response
|
||||||
|
return error(500, sanitizedError); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle validation errors (400 Bad Request) |
||||||
|
*/ |
||||||
|
export function handleValidationError( |
||||||
|
message: string, |
||||||
|
context: ErrorContext = {} |
||||||
|
): ReturnType<typeof error> { |
||||||
|
logger.warn(context, `Validation Error: ${message}`); |
||||||
|
return error(400, message); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle authentication errors (401 Unauthorized) |
||||||
|
*/ |
||||||
|
export function handleAuthError( |
||||||
|
message: string = 'Authentication required', |
||||||
|
context: ErrorContext = {} |
||||||
|
): ReturnType<typeof error> { |
||||||
|
logger.warn(context, `Auth Error: ${message}`); |
||||||
|
return error(401, message); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle authorization errors (403 Forbidden) |
||||||
|
*/ |
||||||
|
export function handleAuthorizationError( |
||||||
|
message: string = 'Insufficient permissions', |
||||||
|
context: ErrorContext = {} |
||||||
|
): ReturnType<typeof error> { |
||||||
|
logger.warn(context, `Authorization Error: ${message}`); |
||||||
|
return error(403, message); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle not found errors (404 Not Found) |
||||||
|
*/ |
||||||
|
export function handleNotFoundError( |
||||||
|
message: string = 'Resource not found', |
||||||
|
context: ErrorContext = {} |
||||||
|
): ReturnType<typeof error> { |
||||||
|
logger.info(context, `Not Found: ${message}`); |
||||||
|
return error(404, message); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Wrap async handler functions with standardized error handling |
||||||
|
*/ |
||||||
|
export function withErrorHandling<T extends (...args: any[]) => Promise<any>>( |
||||||
|
handler: T, |
||||||
|
defaultContext?: ErrorContext |
||||||
|
): T { |
||||||
|
return (async (...args: Parameters<T>) => { |
||||||
|
try { |
||||||
|
return await handler(...args); |
||||||
|
} catch (err) { |
||||||
|
throw handleApiError(err, defaultContext); |
||||||
|
} |
||||||
|
}) as T; |
||||||
|
} |
||||||
Loading…
Reference in new issue