wip library to store cold objects in s3, warm objects on disk, and hot objects in memory
nodejs typescript
1/** 2 * Example HTTP server serving static sites from tiered storage 3 * 4 * This demonstrates a real-world use case: serving static websites 5 * with automatic caching across hot (memory), warm (disk), and cold (S3) tiers. 6 * 7 * Run with: bun run serve 8 */ 9 10import { Hono } from 'hono'; 11import { TieredStorage, MemoryStorageTier, DiskStorageTier, S3StorageTier } from './src/index.js'; 12import { readFile, readdir } from 'node:fs/promises'; 13import { lookup } from 'mime-types'; 14 15const S3_BUCKET = process.env.S3_BUCKET || 'tiered-storage-example'; 16const S3_METADATA_BUCKET = process.env.S3_METADATA_BUCKET; 17const S3_REGION = process.env.S3_REGION || 'us-east-1'; 18const S3_ENDPOINT = process.env.S3_ENDPOINT; 19const S3_FORCE_PATH_STYLE = process.env.S3_FORCE_PATH_STYLE !== 'false'; 20const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID; 21const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY; 22const PORT = parseInt(process.env.PORT || '3000', 10); 23 24const storage = new TieredStorage({ 25 tiers: { 26 hot: new MemoryStorageTier({ 27 maxSizeBytes: 50 * 1024 * 1024, 28 maxItems: 500, 29 }), 30 warm: new DiskStorageTier({ 31 directory: './cache/sites', 32 maxSizeBytes: 1024 * 1024 * 1024, 33 }), 34 cold: new S3StorageTier({ 35 bucket: S3_BUCKET, 36 metadataBucket: S3_METADATA_BUCKET, 37 region: S3_REGION, 38 endpoint: S3_ENDPOINT, 39 forcePathStyle: S3_FORCE_PATH_STYLE, 40 credentials: 41 AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY 42 ? { accessKeyId: AWS_ACCESS_KEY_ID, secretAccessKey: AWS_SECRET_ACCESS_KEY } 43 : undefined, 44 prefix: 'demo-sites/', 45 }), 46 }, 47 compression: true, 48 defaultTTL: 14 * 24 * 60 * 60 * 1000, 49 promotionStrategy: 'lazy', 50}); 51 52const app = new Hono(); 53 54// Site metadata 55const siteId = 'did:plc:example123'; 56const siteName = 'tiered-cache-demo'; 57 58/** 59 * Load the example site into storage 60 */ 61async function loadExampleSite() { 62 console.log('\n📦 Loading example site into tiered storage...\n'); 63 64 const files = [ 65 { name: 'index.html', skipTiers: [], mimeType: 'text/html' }, 66 { name: 'about.html', skipTiers: ['hot'], mimeType: 'text/html' }, 67 { name: 'docs.html', skipTiers: ['hot'], mimeType: 'text/html' }, 68 { name: 'style.css', skipTiers: ['hot'], mimeType: 'text/css' }, 69 { name: 'script.js', skipTiers: ['hot'], mimeType: 'application/javascript' }, 70 ]; 71 72 for (const file of files) { 73 const content = await readFile(`./example-site/${file.name}`, 'utf-8'); 74 const key = `${siteId}/${siteName}/${file.name}`; 75 76 await storage.set(key, content, { 77 skipTiers: file.skipTiers as ('hot' | 'warm')[], 78 metadata: { mimeType: file.mimeType }, 79 }); 80 81 const tierInfo = 82 file.skipTiers.length === 0 83 ? '🔥 hot + 💾 warm + ☁️ cold' 84 : `💾 warm + ☁️ cold (skipped hot)`; 85 const sizeKB = (content.length / 1024).toFixed(2); 86 console.log(`${file.name.padEnd(15)} ${sizeKB.padStart(6)} KB → ${tierInfo}`); 87 } 88 89 console.log('\n✅ Site loaded successfully!\n'); 90} 91 92/** 93 * Serve a file from tiered storage 94 */ 95app.get('/sites/:did/:siteName/:path{.*}', async (c) => { 96 const { did, siteName, path } = c.req.param(); 97 let filePath = path || 'index.html'; 98 99 if (filePath === '' || filePath.endsWith('/')) { 100 filePath += 'index.html'; 101 } 102 103 const key = `${did}/${siteName}/${filePath}`; 104 105 try { 106 const result = await storage.getWithMetadata(key); 107 108 if (!result) { 109 return c.text('404 Not Found', 404); 110 } 111 112 const mimeType = result.metadata.customMetadata?.mimeType || lookup(filePath) || 'application/octet-stream'; 113 114 const headers: Record<string, string> = { 115 'Content-Type': mimeType, 116 'X-Cache-Tier': result.source, // Which tier served this 117 'X-Cache-Size': result.metadata.size.toString(), 118 'X-Cache-Compressed': result.metadata.compressed.toString(), 119 'X-Cache-Access-Count': result.metadata.accessCount.toString(), 120 }; 121 122 // Add cache control based on tier 123 if (result.source === 'hot') { 124 headers['X-Cache-Status'] = 'HIT-MEMORY'; 125 } else if (result.source === 'warm') { 126 headers['X-Cache-Status'] = 'HIT-DISK'; 127 } else { 128 headers['X-Cache-Status'] = 'HIT-S3'; 129 } 130 131 const emoji = result.source === 'hot' ? '🔥' : result.source === 'warm' ? '💾' : '☁️'; 132 console.log(`${emoji} ${filePath.padEnd(20)} served from ${result.source.padEnd(4)} (${(result.metadata.size / 1024).toFixed(2)} KB, access #${result.metadata.accessCount})`); 133 134 return c.body(result.data as any, 200, headers); 135 } catch (error: any) { 136 console.error(`❌ Error serving ${filePath}:`, error.message); 137 return c.text('500 Internal Server Error', 500); 138 } 139}); 140 141/** 142 * Admin endpoint: Cache statistics 143 */ 144app.get('/admin/stats', async (c) => { 145 const stats = await storage.getStats(); 146 147 const html = ` 148<!DOCTYPE html> 149<html> 150<head> 151 <title>Tiered Storage Statistics</title> 152 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 153 <style> 154 * { margin: 0; padding: 0; box-sizing: border-box; } 155 body { 156 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 157 background: #0f172a; 158 color: #f1f5f9; 159 padding: 2rem; 160 line-height: 1.6; 161 } 162 .container { max-width: 1200px; margin: 0 auto; } 163 h1 { 164 font-size: 2rem; 165 margin-bottom: 0.5rem; 166 background: linear-gradient(135deg, #3b82f6, #8b5cf6); 167 -webkit-background-clip: text; 168 -webkit-text-fill-color: transparent; 169 } 170 .subtitle { color: #94a3b8; margin-bottom: 2rem; } 171 .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; } 172 .card { 173 background: #1e293b; 174 border: 1px solid #334155; 175 border-radius: 0.5rem; 176 padding: 1.5rem; 177 } 178 .tier-hot { border-left: 4px solid #ef4444; } 179 .tier-warm { border-left: 4px solid #f59e0b; } 180 .tier-cold { border-left: 4px solid #3b82f6; } 181 .card-title { 182 font-size: 1.2rem; 183 font-weight: 600; 184 margin-bottom: 1rem; 185 display: flex; 186 align-items: center; 187 gap: 0.5rem; 188 } 189 .stat { margin-bottom: 0.75rem; } 190 .stat-label { color: #94a3b8; font-size: 0.9rem; } 191 .stat-value { color: #f1f5f9; font-size: 1.5rem; font-weight: 700; } 192 .overall { background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1)); } 193 .refresh { 194 display: inline-block; 195 background: #3b82f6; 196 color: white; 197 padding: 0.75rem 1.5rem; 198 border-radius: 0.5rem; 199 text-decoration: none; 200 font-weight: 600; 201 margin-top: 1rem; 202 } 203 .refresh:hover { background: #2563eb; } 204 code { 205 background: #334155; 206 padding: 0.2rem 0.5rem; 207 border-radius: 0.25rem; 208 font-size: 0.9em; 209 } 210 </style> 211</head> 212<body> 213 <div class="container"> 214 <h1>📊 Tiered Storage Statistics</h1> 215 <p class="subtitle">Real-time cache performance metrics • Auto-refresh every 5 seconds</p> 216 217 <div class="grid"> 218 <div class="card tier-hot"> 219 <div class="card-title">🔥 Hot Tier (Memory)</div> 220 <div class="stat"> 221 <div class="stat-label">Items</div> 222 <div class="stat-value">${stats.hot?.items || 0}</div> 223 </div> 224 <div class="stat"> 225 <div class="stat-label">Size</div> 226 <div class="stat-value">${((stats.hot?.bytes || 0) / 1024).toFixed(2)} KB</div> 227 </div> 228 <div class="stat"> 229 <div class="stat-label">Hits / Misses</div> 230 <div class="stat-value">${stats.hot?.hits || 0} / ${stats.hot?.misses || 0}</div> 231 </div> 232 <div class="stat"> 233 <div class="stat-label">Evictions</div> 234 <div class="stat-value">${stats.hot?.evictions || 0}</div> 235 </div> 236 </div> 237 238 <div class="card tier-warm"> 239 <div class="card-title">💾 Warm Tier (Disk)</div> 240 <div class="stat"> 241 <div class="stat-label">Items</div> 242 <div class="stat-value">${stats.warm?.items || 0}</div> 243 </div> 244 <div class="stat"> 245 <div class="stat-label">Size</div> 246 <div class="stat-value">${((stats.warm?.bytes || 0) / 1024).toFixed(2)} KB</div> 247 </div> 248 <div class="stat"> 249 <div class="stat-label">Hits / Misses</div> 250 <div class="stat-value">${stats.warm?.hits || 0} / ${stats.warm?.misses || 0}</div> 251 </div> 252 </div> 253 254 <div class="card tier-cold"> 255 <div class="card-title">☁️ Cold Tier (S3)</div> 256 <div class="stat"> 257 <div class="stat-label">Items</div> 258 <div class="stat-value">${stats.cold.items}</div> 259 </div> 260 <div class="stat"> 261 <div class="stat-label">Size</div> 262 <div class="stat-value">${(stats.cold.bytes / 1024).toFixed(2)} KB</div> 263 </div> 264 </div> 265 </div> 266 267 <div class="card overall"> 268 <div class="card-title">📈 Overall Performance</div> 269 <div class="grid" style="grid-template-columns: repeat(3, 1fr);"> 270 <div class="stat"> 271 <div class="stat-label">Total Hits</div> 272 <div class="stat-value">${stats.totalHits}</div> 273 </div> 274 <div class="stat"> 275 <div class="stat-label">Total Misses</div> 276 <div class="stat-value">${stats.totalMisses}</div> 277 </div> 278 <div class="stat"> 279 <div class="stat-label">Hit Rate</div> 280 <div class="stat-value">${(stats.hitRate * 100).toFixed(1)}%</div> 281 </div> 282 </div> 283 </div> 284 285 <div style="margin-top: 2rem; padding: 1rem; background: #1e293b; border-radius: 0.5rem; border: 1px solid #334155;"> 286 <p style="margin-bottom: 0.5rem;"><strong>Try it out:</strong></p> 287 <p>Visit <code>http://localhost:${PORT}/sites/${siteId}/${siteName}/</code> to see the site</p> 288 <p>Watch the stats update as you browse different pages!</p> 289 </div> 290 </div> 291 292 <script> 293 // Auto-refresh stats every 5 seconds 294 setTimeout(() => window.location.reload(), 5000); 295 </script> 296</body> 297</html> 298 `; 299 300 return c.html(html); 301}); 302 303/** 304 * Admin endpoint: Invalidate cache 305 */ 306app.post('/admin/invalidate/:did/:siteName', async (c) => { 307 const { did, siteName } = c.req.param(); 308 const prefix = `${did}/${siteName}/`; 309 const deleted = await storage.invalidate(prefix); 310 311 console.log(`🗑️ Invalidated ${deleted} files for ${did}/${siteName}`); 312 313 return c.json({ success: true, deleted, prefix }); 314}); 315 316/** 317 * Admin endpoint: Bootstrap hot cache 318 */ 319app.post('/admin/bootstrap/hot', async (c) => { 320 const limit = parseInt(c.req.query('limit') || '100', 10); 321 const loaded = await storage.bootstrapHot(limit); 322 323 console.log(`🔥 Bootstrapped ${loaded} items into hot tier`); 324 325 return c.json({ success: true, loaded, limit }); 326}); 327 328/** 329 * Root redirect 330 */ 331app.get('/', (c) => { 332 return c.redirect(`/sites/${siteId}/${siteName}/`); 333}); 334 335/** 336 * Health check 337 */ 338app.get('/health', (c) => c.json({ status: 'ok' })); 339 340/** 341 * Test S3 connection 342 */ 343async function testS3Connection() { 344 console.log('\n🔍 Testing S3 connection...\n'); 345 346 try { 347 // Try to get stats (which lists objects) 348 const stats = await storage.getStats(); 349 console.log(`✅ S3 connection successful!`); 350 console.log(` Found ${stats.cold.items} items (${(stats.cold.bytes / 1024).toFixed(2)} KB)\n`); 351 return true; 352 } catch (error: any) { 353 console.error('❌ S3 connection failed:', error.message); 354 console.error('\nDebug Info:'); 355 console.error(` Bucket: ${S3_BUCKET}`); 356 console.error(` Region: ${S3_REGION}`); 357 console.error(` Endpoint: ${S3_ENDPOINT || '(default AWS S3)'}`); 358 console.error(` Access Key: ${AWS_ACCESS_KEY_ID?.substring(0, 8)}...`); 359 console.error(` Force Path Style: ${S3_FORCE_PATH_STYLE}`); 360 console.error('\nCommon issues:'); 361 console.error(' • Check that bucket exists'); 362 console.error(' • Verify credentials are correct'); 363 console.error(' • Ensure endpoint URL is correct'); 364 console.error(' • Check firewall/network access'); 365 console.error(' • For S3-compatible services, verify region name\n'); 366 return false; 367 } 368} 369 370/** 371 * Periodic cache clearing - demonstrates tier bootstrapping 372 */ 373function startCacheClearInterval() { 374 const CLEAR_INTERVAL_MS = 60 * 1000; // 1 minute 375 376 setInterval(async () => { 377 console.log('\n' + '═'.repeat(60)); 378 console.log('🧹 CACHE CLEAR - Clearing hot and warm tiers...'); 379 console.log(' (Cold tier on S3 remains intact)'); 380 console.log('═'.repeat(60) + '\n'); 381 382 try { 383 // Clear hot tier (memory) 384 if (storage['config'].tiers.hot) { 385 await storage['config'].tiers.hot.clear(); 386 console.log('✓ Hot tier (memory) cleared'); 387 } 388 389 // Clear warm tier (disk) 390 if (storage['config'].tiers.warm) { 391 await storage['config'].tiers.warm.clear(); 392 console.log('✓ Warm tier (disk) cleared'); 393 } 394 395 console.log('\n💡 Next request will bootstrap from S3 (cold tier)\n'); 396 console.log('─'.repeat(60) + '\n'); 397 } catch (error: any) { 398 console.error('❌ Error clearing cache:', error.message); 399 } 400 }, CLEAR_INTERVAL_MS); 401 402 console.log(`⏰ Cache clear interval started (every ${CLEAR_INTERVAL_MS / 1000}s)\n`); 403} 404 405/** 406 * Main startup 407 */ 408async function main() { 409 console.log('╔════════════════════════════════════════════════╗'); 410 console.log('║ Tiered Storage Demo Server ║'); 411 console.log('╚════════════════════════════════════════════════╝\n'); 412 413 console.log('Configuration:'); 414 console.log(` S3 Bucket: ${S3_BUCKET}`); 415 console.log(` S3 Region: ${S3_REGION}`); 416 console.log(` S3 Endpoint: ${S3_ENDPOINT || '(default AWS S3)'}`); 417 console.log(` Force Path Style: ${S3_FORCE_PATH_STYLE}`); 418 console.log(` Port: ${PORT}`); 419 420 try { 421 // Test S3 connection first 422 const s3Connected = await testS3Connection(); 423 if (!s3Connected) { 424 process.exit(1); 425 } 426 427 // Load the example site 428 await loadExampleSite(); 429 430 // Start periodic cache clearing 431 startCacheClearInterval(); 432 433 // Start the server 434 console.log('🚀 Starting server...\n'); 435 436 const server = Bun.serve({ 437 port: PORT, 438 fetch: app.fetch, 439 }); 440 441 console.log('╔════════════════════════════════════════════════╗'); 442 console.log('║ Server Running! ║'); 443 console.log('╚════════════════════════════════════════════════╝\n'); 444 console.log(`📍 Demo Site: http://localhost:${PORT}/sites/${siteId}/${siteName}/`); 445 console.log(`📊 Statistics: http://localhost:${PORT}/admin/stats`); 446 console.log(`💚 Health: http://localhost:${PORT}/health`); 447 console.log('\n🎯 Try browsing the site and watch which tier serves each file!\n'); 448 console.log('💡 Caches clear every 60 seconds - watch files get re-fetched from S3!\n'); 449 if (S3_METADATA_BUCKET) { 450 console.log(`✨ Metadata bucket: ${S3_METADATA_BUCKET} (fast updates enabled!)\n`); 451 } else { 452 console.log('⚠️ No metadata bucket - using legacy mode (slower updates)\n'); 453 } 454 console.log('Press Ctrl+C to stop\n'); 455 console.log('─'.repeat(60)); 456 console.log('Request Log:\n'); 457 } catch (error: any) { 458 console.error('\n❌ Failed to start server:', error.message); 459 if (error.message.includes('Forbidden')) { 460 console.error('\nS3 connection issue. Check:'); 461 console.error(' 1. Bucket exists on S3 service'); 462 console.error(' 2. Credentials are correct'); 463 console.error(' 3. Permissions allow read/write'); 464 } 465 process.exit(1); 466 } 467} 468 469main().catch(console.error);