28 changed files with 675 additions and 240 deletions
@ -0,0 +1,205 @@
@@ -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 @@
@@ -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