nostr-deploy-server
Version:
Node.js server for hosting static websites under npub subdomains using Nostr protocol and Blossom servers
726 lines (612 loc) • 22.7 kB
text/typescript
import Keyv from 'keyv';
import { ParsedEvent } from '../types';
import { ConfigManager } from './config';
import { logger } from './logger';
const log = logger;
interface KeyvOptions {
store?: any;
namespace?: string;
ttl?: number;
serialize?: (data: any) => string;
deserialize?: (data: string) => any;
}
class AdvancedCacheManager {
private config = ConfigManager.getInstance().getConfig();
private store: any = null;
private initialized = false;
async initialize() {
if (this.initialized) return;
this.store = await this.createStore();
this.initialized = true;
if (this.store) {
this.store.on('error', (err: Error) => {
log.error('Cache Connection Error', err);
process.exit(1);
});
}
}
private async createStore() {
const cachePath = this.config.cachePath;
if (!cachePath || cachePath === 'in-memory') {
log.info('Using in-memory cache');
return undefined;
} else if (cachePath.startsWith('redis://')) {
try {
// @ts-ignore - @keyv/redis doesn't have TypeScript declarations
const { default: KeyvRedis } = await import('@keyv/redis');
log.info(`Using redis cache at ${cachePath}`);
return new KeyvRedis(cachePath);
} catch (error) {
log.error('Failed to initialize Redis cache, falling back to in-memory', error);
return undefined;
}
} else if (cachePath.startsWith('sqlite://')) {
try {
// @ts-ignore - @keyv/sqlite doesn't have TypeScript declarations
const { default: KeyvSqlite } = await import('@keyv/sqlite');
log.info(`Using sqlite cache at ${cachePath}`);
return new KeyvSqlite(cachePath);
} catch (error) {
log.error('Failed to initialize SQLite cache, falling back to in-memory', error);
return undefined;
}
}
log.warn(`Unknown cache path format: ${cachePath}, using in-memory cache`);
return undefined;
}
private getKeyvOptions(): KeyvOptions {
const json: KeyvOptions = {
serialize: (data: any) => {
// Handle Uint8Array serialization
if (data instanceof Uint8Array) {
return JSON.stringify({ __type: 'Uint8Array', data: Array.from(data) });
}
// Handle nested objects that might contain Uint8Array
if (typeof data === 'object' && data !== null) {
const serialized = this.serializeWithUint8Array(data);
return JSON.stringify(serialized);
}
return JSON.stringify(data);
},
deserialize: (data: string) => {
try {
const parsed = JSON.parse(data);
// Handle direct Uint8Array deserialization
if (parsed && parsed.__type === 'Uint8Array' && Array.isArray(parsed.data)) {
return new Uint8Array(parsed.data);
}
// Handle nested objects that might contain Uint8Array
if (typeof parsed === 'object' && parsed !== null) {
return this.deserializeWithUint8Array(parsed);
}
return parsed;
} catch (error) {
// Fallback for malformed data
return null;
}
},
};
const opts: KeyvOptions = this.store ? { store: this.store } : {};
return { ...opts, ...json };
}
// Helper method to serialize nested objects with Uint8Array
private serializeWithUint8Array(obj: any): any {
if (obj instanceof Uint8Array) {
return { __type: 'Uint8Array', data: Array.from(obj) };
}
if (Array.isArray(obj)) {
return obj.map((item) => this.serializeWithUint8Array(item));
}
if (typeof obj === 'object' && obj !== null) {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = this.serializeWithUint8Array(value);
}
return result;
}
return obj;
}
// Helper method to deserialize nested objects with Uint8Array
private deserializeWithUint8Array(obj: any): any {
if (obj && obj.__type === 'Uint8Array' && Array.isArray(obj.data)) {
return new Uint8Array(obj.data);
}
if (Array.isArray(obj)) {
return obj.map((item) => this.deserializeWithUint8Array(item));
}
if (typeof obj === 'object' && obj !== null) {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = this.deserializeWithUint8Array(value);
}
return result;
}
return obj;
}
private createCache<T>(namespace: string, ttl?: number): Keyv<T | undefined> {
return new Keyv<T | undefined>({
...this.getKeyvOptions(),
namespace,
ttl: (ttl || this.config.cacheTime) * 1000,
});
}
// Initialize all cache instances
async getCaches() {
await this.initialize();
return {
/** A cache that maps a domain to a pubkey ( domain -> pubkey ) */
pubkeyDomains: this.createCache<string>('domains'),
/** A cache that maps a pubkey to a set of blossom servers ( pubkey -> servers ) */
pubkeyServers: this.createCache<string[]>('servers'),
/** A cache that maps a pubkey to a set of relays ( pubkey -> relays ) */
pubkeyRelays: this.createCache<string[]>('relays'),
/** A cache that maps a pubkey + path to sha256 hash of the blob ( pubkey/path -> sha256 ) */
pathBlobs: this.createCache<ParsedEvent>('paths'),
/** A cache that maps a sha256 hash to a set of URLs that had the blob ( sha256 -> URLs ) */
blobURLs: this.createCache<string[]>('blobs'),
/** A cache for file content */
fileContent: this.createCache<Uint8Array>(
'content',
this.config.fileContentCacheTtlMs / 1000
),
/** A cache for negative results (not found) */
negativeCache: this.createCache<boolean>('negative', this.config.negativeCacheTtlMs / 1000),
};
}
}
// Create singleton instance
const cacheManager = new AdvancedCacheManager();
// Export the cache instances
export const getCacheInstances = () => cacheManager.getCaches();
// Utility functions for common cache operations
export class CacheService {
private static caches: any = null;
private static async getCaches() {
if (!this.caches) {
this.caches = await getCacheInstances();
}
return this.caches;
}
// ==========================================
// Core Cache Operations with Sliding Expiration
// ==========================================
/**
* Get value from cache and optionally refresh TTL (sliding expiration)
*/
private static async getWithSlidingExpiration<T>(
cache: any,
key: string,
refreshTtl: boolean = true,
customTtlSeconds?: number
): Promise<T | null> {
const value = await cache.get(key);
if (value && refreshTtl) {
// Refresh TTL by setting the same value with new expiration
const config = ConfigManager.getInstance().getConfig();
const ttlMs = customTtlSeconds ? customTtlSeconds * 1000 : config.cacheTime * 1000;
// Set the value again with refreshed TTL
await cache.set(key, value, ttlMs);
log.debug(
`🔄 TTL refreshed for cache key: ${key.substring(0, 32)}${key.length > 32 ? '...' : ''}`
);
}
return value || null;
}
/**
* Touch multiple cache entries to refresh their TTL
* Used when accessing a domain triggers refresh of all related cache entries
*/
private static async touchRelatedCacheEntries(pubkey: string, domain?: string): Promise<void> {
const config = ConfigManager.getInstance().getConfig();
// Only perform if sliding expiration is enabled
if (!config.slidingExpiration) {
return;
}
const caches = await this.getCaches();
const operations: Promise<void>[] = [];
try {
// Refresh domain mapping if provided
if (domain) {
const domainValue = await caches.pubkeyDomains.get(domain);
if (domainValue) {
operations.push(caches.pubkeyDomains.set(domain, domainValue, config.cacheTime * 1000));
}
}
// Refresh pubkey-related caches
const [servers, relays] = await Promise.all([
caches.pubkeyServers.get(pubkey),
caches.pubkeyRelays.get(pubkey),
]);
if (servers) {
operations.push(caches.pubkeyServers.set(pubkey, servers, config.cacheTime * 1000));
}
if (relays) {
operations.push(caches.pubkeyRelays.set(pubkey, relays, config.cacheTime * 1000));
}
// Execute all operations in parallel
await Promise.all(operations);
log.debug(
`🔄 TTL refreshed for ${
operations.length
} related cache entries for pubkey: ${pubkey.substring(0, 8)}...`
);
} catch (error) {
log.warn('Failed to refresh related cache entries:', error);
}
}
// ==========================================
// Domain Resolution Cache Operations
// ==========================================
// Domain resolution cache operations
static async getPubkeyForDomain(domain: string): Promise<string | null> {
const caches = await this.getCaches();
const config = ConfigManager.getInstance().getConfig();
const result = await this.getWithSlidingExpiration(
caches.pubkeyDomains,
domain,
config.slidingExpiration
);
if (result) {
log.debug(`🎯 Domain cache HIT: ${domain} → ${(result as string).substring(0, 8)}...`);
} else {
log.debug(`💔 Domain cache MISS: ${domain}`);
}
return result as string | null;
}
static async setPubkeyForDomain(domain: string, pubkey: string): Promise<void> {
const caches = await this.getCaches();
await caches.pubkeyDomains.set(domain, pubkey);
}
// ==========================================
// Blossom Servers Cache Operations
// ==========================================
// Blossom servers cache operations
static async getBlossomServersForPubkey(pubkey: string): Promise<string[] | null> {
const caches = await this.getCaches();
const config = ConfigManager.getInstance().getConfig();
const result = await this.getWithSlidingExpiration(
caches.pubkeyServers,
pubkey,
config.slidingExpiration
);
if (result) {
log.debug(
`🎯 Blossom servers cache HIT for ${pubkey.substring(0, 8)}... (${
(result as string[]).length
} servers)`
);
} else {
log.debug(`💔 Blossom servers cache MISS for ${pubkey.substring(0, 8)}...`);
}
return result as string[] | null;
}
static async setBlossomServersForPubkey(pubkey: string, servers: string[]): Promise<void> {
const caches = await this.getCaches();
await caches.pubkeyServers.set(pubkey, servers);
}
// ==========================================
// Relay Cache Operations
// ==========================================
// Relay cache operations
static async getRelaysForPubkey(pubkey: string): Promise<string[] | null> {
const caches = await this.getCaches();
const config = ConfigManager.getInstance().getConfig();
const result = await this.getWithSlidingExpiration(
caches.pubkeyRelays,
pubkey,
config.slidingExpiration
);
if (result) {
log.debug(
`🎯 Relay list cache HIT for ${pubkey.substring(0, 8)}... (${
(result as string[]).length
} relays)`
);
} else {
log.debug(`💔 Relay list cache MISS for ${pubkey.substring(0, 8)}...`);
}
return result as string[] | null;
}
static async setRelaysForPubkey(pubkey: string, relays: string[]): Promise<void> {
const caches = await this.getCaches();
await caches.pubkeyRelays.set(pubkey, relays);
}
// ==========================================
// Path to Blob Mapping Cache Operations
// ==========================================
// Path to blob mapping cache operations
static async getBlobForPath(pubkey: string, path: string): Promise<ParsedEvent | null> {
const caches = await this.getCaches();
const config = ConfigManager.getInstance().getConfig();
const key = pubkey + path;
const result = await this.getWithSlidingExpiration(
caches.pathBlobs,
key,
config.slidingExpiration
);
if (result) {
log.debug(
`🎯 Path mapping cache HIT: ${path} for ${pubkey.substring(0, 8)}... → ${(
result as ParsedEvent
).sha256.substring(0, 8)}...`
);
} else {
log.debug(`💔 Path mapping cache MISS: ${path} for ${pubkey.substring(0, 8)}...`);
}
return result as ParsedEvent | null;
}
static async setBlobForPath(pubkey: string, path: string, event: ParsedEvent): Promise<void> {
const caches = await this.getCaches();
const key = pubkey + path;
await caches.pathBlobs.set(key, event);
}
static async invalidateBlobForPath(pubkey: string, path: string): Promise<void> {
const caches = await this.getCaches();
const key = pubkey + path;
await caches.pathBlobs.delete(key);
}
// ==========================================
// Blob URLs Cache Operations
// ==========================================
// Blob URLs cache operations
static async getBlobURLs(sha256: string): Promise<string[] | null> {
const caches = await this.getCaches();
const config = ConfigManager.getInstance().getConfig();
const result = await this.getWithSlidingExpiration(
caches.blobURLs,
sha256,
config.slidingExpiration
);
if (result) {
log.debug(
`🎯 Blob URLs cache HIT for ${sha256.substring(0, 8)}... (${
(result as string[]).length
} URLs)`
);
} else {
log.debug(`💔 Blob URLs cache MISS for ${sha256.substring(0, 8)}...`);
}
return result as string[] | null;
}
static async setBlobURLs(sha256: string, urls: string[]): Promise<void> {
const caches = await this.getCaches();
await caches.blobURLs.set(sha256, urls);
}
// ==========================================
// File Content Cache Operations
// ==========================================
// File content cache operations
static async getFileContent(sha256: string): Promise<Uint8Array | null> {
const caches = await this.getCaches();
const config = ConfigManager.getInstance().getConfig();
// Use custom TTL for file content cache
const customTtlSeconds = config.fileContentCacheTtlMs / 1000;
const cached = await this.getWithSlidingExpiration(
caches.fileContent,
sha256,
config.slidingExpiration,
customTtlSeconds
);
// Ensure we always return a Uint8Array or null
if (!cached) return null;
// If the cached data is not a Uint8Array (deserialization issue), try to convert it
if (!(cached instanceof Uint8Array)) {
console.warn(`Cached file content for ${sha256} is not a Uint8Array, attempting conversion`);
// Handle case where it might be a plain object with numeric indices
if (typeof cached === 'object' && cached !== null) {
try {
// Try to convert object to array and then to Uint8Array
const values = Object.values(cached as any);
if (values.every((v) => typeof v === 'number' && v >= 0 && v <= 255)) {
return new Uint8Array(values as number[]);
}
} catch (error) {
console.error(`Failed to convert cached content for ${sha256}:`, error);
}
}
// If conversion fails, return null to force re-fetch
return null;
}
return cached;
}
static async setFileContent(sha256: string, content: Uint8Array): Promise<void> {
if (!(content instanceof Uint8Array)) {
throw new Error(`setFileContent requires Uint8Array, got ${typeof content}`);
}
const caches = await this.getCaches();
await caches.fileContent.set(sha256, content);
}
// Negative cache operations (for "not found" results)
static async isNegativeCached(key: string): Promise<boolean> {
const caches = await this.getCaches();
return (await caches.negativeCache.get(key)) || false;
}
static async setNegativeCache(key: string): Promise<void> {
const caches = await this.getCaches();
await caches.negativeCache.set(key, true);
}
// Clear all caches
static async clearAll(): Promise<void> {
const caches = await this.getCaches();
await Promise.all([
caches.pubkeyDomains.clear(),
caches.pubkeyServers.clear(),
caches.pubkeyRelays.clear(),
caches.pathBlobs.clear(),
caches.blobURLs.clear(),
caches.fileContent.clear(),
caches.negativeCache.clear(),
]);
log.info('All caches cleared');
}
// Get cache statistics (basic implementation)
static async getStats(): Promise<Record<string, any>> {
const caches = await this.getCaches();
// Basic stats - actual implementation would depend on cache store
return {
backend: cacheManager['config'].cachePath || 'in-memory',
initialized: cacheManager['initialized'],
// Individual cache stats would require store-specific implementation
};
}
// ==========================================
// Real-time Cache Invalidation Methods
// ==========================================
/**
* Invalidate relay list cache for a specific pubkey
* Used by real-time cache invalidation when relay list events are received
*/
static async invalidateRelaysForPubkey(pubkey: string): Promise<void> {
const caches = await this.getCaches();
await caches.pubkeyRelays.delete(pubkey);
log.info(`Invalidated relay list cache for: ${pubkey.substring(0, 8)}...`);
}
/**
* Invalidate blossom server list cache for a specific pubkey
* Used by real-time cache invalidation when blossom server events are received
*/
static async invalidateBlossomServersForPubkey(pubkey: string): Promise<void> {
const caches = await this.getCaches();
await caches.pubkeyServers.delete(pubkey);
log.info(`Invalidated blossom server cache for: ${pubkey.substring(0, 8)}...`);
}
/**
* Invalidate all cache entries for a specific pubkey
* Nuclear option for when we want to clear everything related to a user
*/
static async invalidateAllForPubkey(pubkey: string): Promise<void> {
const caches = await this.getCaches();
await Promise.all([
// Relay lists
caches.pubkeyRelays.delete(pubkey),
// Blossom servers
caches.pubkeyServers.delete(pubkey),
// Note: Path mappings and domain resolution would require scanning keys
]);
log.info(`Invalidated major cache entries for: ${pubkey.substring(0, 8)}...`);
}
/**
* Invalidate negative cache entries
* Useful when we know data has been published that was previously missing
*/
static async invalidateNegativeCache(pattern?: string): Promise<void> {
const caches = await this.getCaches();
if (pattern) {
await caches.negativeCache.delete(pattern);
log.debug(`Invalidated negative cache for pattern: ${pattern}`);
} else {
// Clear all negative cache entries
await caches.negativeCache.clear();
log.info('Cleared all negative cache entries');
}
}
// ==========================================
// High-Level Domain Access Method
// ==========================================
/**
* Main method for handling domain access with sliding expiration
* This method should be called when a user accesses a domain
* It refreshes TTL for all related cache entries
*/
static async handleDomainAccess(domain: string, pubkey: string): Promise<void> {
const config = ConfigManager.getInstance().getConfig();
if (!config.slidingExpiration) {
return;
}
log.info(
`🔄 Refreshing cache TTL for domain access: ${domain} (pubkey: ${pubkey.substring(0, 8)}...)`
);
try {
await this.touchRelatedCacheEntries(pubkey, domain);
log.debug(`✅ Cache TTL refresh completed for domain: ${domain}`);
} catch (error) {
log.warn(`⚠️ Failed to refresh cache TTL for domain: ${domain}`, error);
}
}
}
// Legacy exports for backward compatibility
export class MemoryCache<T> {
private cache: Map<string, { data: T; timestamp: number; ttl: number }>;
private maxSize: number;
private defaultTtl: number;
private cleanupInterval: NodeJS.Timeout | null = null;
constructor() {
const config = ConfigManager.getInstance().getConfig();
this.cache = new Map();
this.maxSize = config.maxCacheSize;
this.defaultTtl = config.cacheTtlSeconds * 1000;
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 60000);
}
public set(key: string, value: T, ttl?: number): void {
if (this.cache.size >= this.maxSize) {
this.cleanup();
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey) {
this.cache.delete(firstKey);
}
}
}
const entry = {
data: value,
timestamp: Date.now(),
ttl: ttl !== undefined ? ttl : this.defaultTtl,
};
this.cache.set(key, entry);
}
public get(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (this.isExpired(entry)) {
this.cache.delete(key);
return null;
}
return entry.data;
}
public has(key: string): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
if (this.isExpired(entry)) {
this.cache.delete(key);
return false;
}
return true;
}
public delete(key: string): boolean {
return this.cache.delete(key);
}
public clear(): void {
this.cache.clear();
}
public size(): number {
return this.cache.size;
}
public keys(): string[] {
return Array.from(this.cache.keys());
}
private isExpired(entry: { timestamp: number; ttl: number }): boolean {
return Date.now() - entry.timestamp > entry.ttl;
}
private cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
}
}
}
public destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.clear();
}
}
// Legacy exports - these will be replaced by the new cache system
export const pathMappingCache = new MemoryCache<string>();
export const relayListCache = new MemoryCache<string[]>();
export const blossomServerCache = new MemoryCache<string[]>();
export const fileContentCache = new MemoryCache<Uint8Array>();