nostr-deploy-server
Version:
Node.js server for hosting static websites under npub subdomains using Nostr protocol and Blossom servers
607 lines • 25 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.fileContentCache = exports.blossomServerCache = exports.relayListCache = exports.pathMappingCache = exports.MemoryCache = exports.CacheService = exports.getCacheInstances = void 0;
const keyv_1 = __importDefault(require("keyv"));
const config_1 = require("./config");
const logger_1 = require("./logger");
const log = logger_1.logger;
class AdvancedCacheManager {
constructor() {
this.config = config_1.ConfigManager.getInstance().getConfig();
this.store = null;
this.initialized = false;
}
async initialize() {
if (this.initialized)
return;
this.store = await this.createStore();
this.initialized = true;
if (this.store) {
this.store.on('error', (err) => {
log.error('Cache Connection Error', err);
process.exit(1);
});
}
}
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 Promise.resolve().then(() => __importStar(require('@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 Promise.resolve().then(() => __importStar(require('@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;
}
getKeyvOptions() {
const json = {
serialize: (data) => {
// 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) => {
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 = this.store ? { store: this.store } : {};
return { ...opts, ...json };
}
// Helper method to serialize nested objects with Uint8Array
serializeWithUint8Array(obj) {
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 = {};
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
deserializeWithUint8Array(obj) {
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 = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = this.deserializeWithUint8Array(value);
}
return result;
}
return obj;
}
createCache(namespace, ttl) {
return new keyv_1.default({
...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('domains'),
/** A cache that maps a pubkey to a set of blossom servers ( pubkey -> servers ) */
pubkeyServers: this.createCache('servers'),
/** A cache that maps a pubkey to a set of relays ( pubkey -> relays ) */
pubkeyRelays: this.createCache('relays'),
/** A cache that maps a pubkey + path to sha256 hash of the blob ( pubkey/path -> sha256 ) */
pathBlobs: this.createCache('paths'),
/** A cache that maps a sha256 hash to a set of URLs that had the blob ( sha256 -> URLs ) */
blobURLs: this.createCache('blobs'),
/** A cache for file content */
fileContent: this.createCache('content', this.config.fileContentCacheTtlMs / 1000),
/** A cache for negative results (not found) */
negativeCache: this.createCache('negative', this.config.negativeCacheTtlMs / 1000),
};
}
}
// Create singleton instance
const cacheManager = new AdvancedCacheManager();
// Export the cache instances
const getCacheInstances = () => cacheManager.getCaches();
exports.getCacheInstances = getCacheInstances;
// Utility functions for common cache operations
class CacheService {
static async getCaches() {
if (!this.caches) {
this.caches = await (0, exports.getCacheInstances)();
}
return this.caches;
}
// ==========================================
// Core Cache Operations with Sliding Expiration
// ==========================================
/**
* Get value from cache and optionally refresh TTL (sliding expiration)
*/
static async getWithSlidingExpiration(cache, key, refreshTtl = true, customTtlSeconds) {
const value = await cache.get(key);
if (value && refreshTtl) {
// Refresh TTL by setting the same value with new expiration
const config = config_1.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
*/
static async touchRelatedCacheEntries(pubkey, domain) {
const config = config_1.ConfigManager.getInstance().getConfig();
// Only perform if sliding expiration is enabled
if (!config.slidingExpiration) {
return;
}
const caches = await this.getCaches();
const operations = [];
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) {
const caches = await this.getCaches();
const config = config_1.ConfigManager.getInstance().getConfig();
const result = await this.getWithSlidingExpiration(caches.pubkeyDomains, domain, config.slidingExpiration);
if (result) {
log.debug(`🎯 Domain cache HIT: ${domain} → ${result.substring(0, 8)}...`);
}
else {
log.debug(`💔 Domain cache MISS: ${domain}`);
}
return result;
}
static async setPubkeyForDomain(domain, pubkey) {
const caches = await this.getCaches();
await caches.pubkeyDomains.set(domain, pubkey);
}
// ==========================================
// Blossom Servers Cache Operations
// ==========================================
// Blossom servers cache operations
static async getBlossomServersForPubkey(pubkey) {
const caches = await this.getCaches();
const config = config_1.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.length} servers)`);
}
else {
log.debug(`💔 Blossom servers cache MISS for ${pubkey.substring(0, 8)}...`);
}
return result;
}
static async setBlossomServersForPubkey(pubkey, servers) {
const caches = await this.getCaches();
await caches.pubkeyServers.set(pubkey, servers);
}
// ==========================================
// Relay Cache Operations
// ==========================================
// Relay cache operations
static async getRelaysForPubkey(pubkey) {
const caches = await this.getCaches();
const config = config_1.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.length} relays)`);
}
else {
log.debug(`💔 Relay list cache MISS for ${pubkey.substring(0, 8)}...`);
}
return result;
}
static async setRelaysForPubkey(pubkey, relays) {
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, path) {
const caches = await this.getCaches();
const config = config_1.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.sha256.substring(0, 8)}...`);
}
else {
log.debug(`💔 Path mapping cache MISS: ${path} for ${pubkey.substring(0, 8)}...`);
}
return result;
}
static async setBlobForPath(pubkey, path, event) {
const caches = await this.getCaches();
const key = pubkey + path;
await caches.pathBlobs.set(key, event);
}
static async invalidateBlobForPath(pubkey, path) {
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) {
const caches = await this.getCaches();
const config = config_1.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.length} URLs)`);
}
else {
log.debug(`💔 Blob URLs cache MISS for ${sha256.substring(0, 8)}...`);
}
return result;
}
static async setBlobURLs(sha256, urls) {
const caches = await this.getCaches();
await caches.blobURLs.set(sha256, urls);
}
// ==========================================
// File Content Cache Operations
// ==========================================
// File content cache operations
static async getFileContent(sha256) {
const caches = await this.getCaches();
const config = config_1.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);
if (values.every((v) => typeof v === 'number' && v >= 0 && v <= 255)) {
return new Uint8Array(values);
}
}
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, content) {
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) {
const caches = await this.getCaches();
return (await caches.negativeCache.get(key)) || false;
}
static async setNegativeCache(key) {
const caches = await this.getCaches();
await caches.negativeCache.set(key, true);
}
// Clear all caches
static async clearAll() {
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() {
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) {
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) {
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) {
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) {
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, pubkey) {
const config = config_1.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);
}
}
}
exports.CacheService = CacheService;
CacheService.caches = null;
// Legacy exports for backward compatibility
class MemoryCache {
constructor() {
this.cleanupInterval = null;
const config = config_1.ConfigManager.getInstance().getConfig();
this.cache = new Map();
this.maxSize = config.maxCacheSize;
this.defaultTtl = config.cacheTtlSeconds * 1000;
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 60000);
}
set(key, value, ttl) {
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);
}
get(key) {
const entry = this.cache.get(key);
if (!entry)
return null;
if (this.isExpired(entry)) {
this.cache.delete(key);
return null;
}
return entry.data;
}
has(key) {
const entry = this.cache.get(key);
if (!entry)
return false;
if (this.isExpired(entry)) {
this.cache.delete(key);
return false;
}
return true;
}
delete(key) {
return this.cache.delete(key);
}
clear() {
this.cache.clear();
}
size() {
return this.cache.size;
}
keys() {
return Array.from(this.cache.keys());
}
isExpired(entry) {
return Date.now() - entry.timestamp > entry.ttl;
}
cleanup() {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
}
}
}
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.clear();
}
}
exports.MemoryCache = MemoryCache;
// Legacy exports - these will be replaced by the new cache system
exports.pathMappingCache = new MemoryCache();
exports.relayListCache = new MemoryCache();
exports.blossomServerCache = new MemoryCache();
exports.fileContentCache = new MemoryCache();
//# sourceMappingURL=cache.js.map