my website at ewancroft.uk

feat: implement comprehensive caching to prevent 504 timeouts

- Add configurable cache TTL system with environment-aware defaults
- Implement dynamic TTL based on data type (profile: 60m, music: 10m, identity: 24h)
- Add HTTP Cache-Control headers for browser and CDN caching
- Cache identity resolution to reduce Slingshot API calls
- Add stale-while-revalidate for graceful cache updates
- Update environment configuration with optional cache TTL overrides

Fixes 504 Gateway Timeout errors by reducing AT Protocol API calls
and serving cached responses. All cache durations are configurable
via environment variables while maintaining DRY principles.

ewancroft.uk 11a9a83c 3cc2da33

verified
Changed files
+193 -10
src
+25
.env.example
··· 50 50 # Use "*" to allow all origins (not recommended for production) 51 51 # Example: https://example.com,https://app.example.com 52 52 PUBLIC_CORS_ALLOWED_ORIGINS="https://your-site-url.com" 53 + 54 + # Cache TTL Configuration (optional) 55 + # Configure how long different types of data are cached (in minutes) 56 + # Longer TTLs reduce API calls and prevent timeouts, but data may be less fresh 57 + # Leave empty to use defaults (optimized for production) 58 + # Profile data (default: 5 min dev, 60 min prod) 59 + # CACHE_TTL_PROFILE=60 60 + # Site info (default: 5 min dev, 120 min prod) 61 + # CACHE_TTL_SITE_INFO=120 62 + # Links (default: 5 min dev, 60 min prod) 63 + # CACHE_TTL_LINKS=60 64 + # Music status (default: 2 min dev, 10 min prod) 65 + # CACHE_TTL_MUSIC_STATUS=10 66 + # Kibun status (default: 2 min dev, 15 min prod) 67 + # CACHE_TTL_KIBUN_STATUS=15 68 + # Tangled repos (default: 5 min dev, 60 min prod) 69 + # CACHE_TTL_TANGLED_REPOS=60 70 + # Blog posts (default: 5 min dev, 30 min prod) 71 + # CACHE_TTL_BLOG_POSTS=30 72 + # Publications (default: 5 min dev, 60 min prod) 73 + # CACHE_TTL_PUBLICATIONS=60 74 + # Individual posts (default: 5 min dev, 60 min prod) 75 + # CACHE_TTL_INDIVIDUAL_POST=60 76 + # Identity resolution (default: 30 min dev, 1440 min/24h prod) 77 + # CACHE_TTL_IDENTITY=1440
+23 -1
src/hooks.server.ts
··· 1 1 import type { Handle } from '@sveltejs/kit'; 2 2 import { PUBLIC_CORS_ALLOWED_ORIGINS } from '$env/static/public'; 3 + import { HTTP_CACHE_HEADERS } from '$lib/config/cache.config'; 3 4 4 5 /** 5 6 * Global request handler with CORS support ··· 31 32 32 33 const response = await resolve(event, { 33 34 filterSerializedResponseHeaders: (name) => { 34 - return name === 'content-type' || name.startsWith('x-'); 35 + return name === 'content-type' || name === 'cache-control' || name.startsWith('x-'); 35 36 } 36 37 }); 38 + 39 + // Add HTTP caching headers for better performance and reduced timeouts 40 + // Layout data (root route) is cached aggressively since profile/site info changes infrequently 41 + if (!event.url.pathname.startsWith('/api/')) { 42 + // Root layout loads profile and site info - cache aggressively 43 + if (event.url.pathname === '/' || event.url.pathname === '') { 44 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.LAYOUT); 45 + } 46 + // Blog listing pages 47 + else if (event.url.pathname.startsWith('/blog') || event.url.pathname.startsWith('/archive')) { 48 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.BLOG_LISTING); 49 + } 50 + // Individual blog post pages 51 + else if (event.url.pathname.match(/^\/[a-z0-9-]+$/)) { 52 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.BLOG_POST); 53 + } 54 + // Other pages get moderate caching 55 + else { 56 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.LAYOUT); 57 + } 58 + } 37 59 38 60 // Add CORS headers for API routes 39 61 if (event.url.pathname.startsWith('/api/')) {
+95
src/lib/config/cache.config.ts
··· 1 + import { dev } from '$app/environment'; 2 + 3 + /** 4 + * Cache configuration with environment-aware TTL values 5 + * 6 + * Development: Shorter TTLs for faster iteration 7 + * Production: Longer TTLs to reduce API calls and prevent timeouts 8 + */ 9 + 10 + // Parse environment variable or use default (in milliseconds) 11 + const getEnvTTL = (key: string, defaultMinutes: number): number => { 12 + if (typeof process !== 'undefined' && process.env?.[key]) { 13 + const minutes = parseInt(process.env[key], 10); 14 + return isNaN(minutes) ? defaultMinutes * 60 * 1000 : minutes * 60 * 1000; 15 + } 16 + return defaultMinutes * 60 * 1000; 17 + }; 18 + 19 + /** 20 + * Default TTL values (in minutes) for different data types 21 + * 22 + * Profile data changes infrequently, so we can cache it longer 23 + * Music and Kibun statuses change frequently, so shorter cache 24 + */ 25 + const DEFAULT_TTL = { 26 + // Profile data: 60 minutes (changes infrequently) 27 + PROFILE: dev ? 5 : 60, 28 + 29 + // Site info: 120 minutes (rarely changes) 30 + SITE_INFO: dev ? 5 : 120, 31 + 32 + // Links: 60 minutes (changes occasionally) 33 + LINKS: dev ? 5 : 60, 34 + 35 + // Music status: 10 minutes (changes frequently) 36 + MUSIC_STATUS: dev ? 2 : 10, 37 + 38 + // Kibun status: 15 minutes (changes occasionally) 39 + KIBUN_STATUS: dev ? 2 : 15, 40 + 41 + // Tangled repos: 60 minutes (changes occasionally) 42 + TANGLED_REPOS: dev ? 5 : 60, 43 + 44 + // Blog posts: 30 minutes (balance between freshness and performance) 45 + BLOG_POSTS: dev ? 5 : 30, 46 + 47 + // Publications: 60 minutes (rarely changes) 48 + PUBLICATIONS: dev ? 5 : 60, 49 + 50 + // Individual posts: 60 minutes (content doesn't change) 51 + INDIVIDUAL_POST: dev ? 5 : 60, 52 + 53 + // Identity resolution: 1440 minutes (24 hours - DIDs are stable) 54 + IDENTITY: dev ? 30 : 1440 55 + }; 56 + 57 + /** 58 + * Cache TTL configuration 59 + * Values are loaded from environment variables with fallbacks to defaults 60 + */ 61 + export const CACHE_TTL = { 62 + PROFILE: getEnvTTL('CACHE_TTL_PROFILE', DEFAULT_TTL.PROFILE), 63 + SITE_INFO: getEnvTTL('CACHE_TTL_SITE_INFO', DEFAULT_TTL.SITE_INFO), 64 + LINKS: getEnvTTL('CACHE_TTL_LINKS', DEFAULT_TTL.LINKS), 65 + MUSIC_STATUS: getEnvTTL('CACHE_TTL_MUSIC_STATUS', DEFAULT_TTL.MUSIC_STATUS), 66 + KIBUN_STATUS: getEnvTTL('CACHE_TTL_KIBUN_STATUS', DEFAULT_TTL.KIBUN_STATUS), 67 + TANGLED_REPOS: getEnvTTL('CACHE_TTL_TANGLED_REPOS', DEFAULT_TTL.TANGLED_REPOS), 68 + BLOG_POSTS: getEnvTTL('CACHE_TTL_BLOG_POSTS', DEFAULT_TTL.BLOG_POSTS), 69 + PUBLICATIONS: getEnvTTL('CACHE_TTL_PUBLICATIONS', DEFAULT_TTL.PUBLICATIONS), 70 + INDIVIDUAL_POST: getEnvTTL('CACHE_TTL_INDIVIDUAL_POST', DEFAULT_TTL.INDIVIDUAL_POST), 71 + IDENTITY: getEnvTTL('CACHE_TTL_IDENTITY', DEFAULT_TTL.IDENTITY) 72 + } as const; 73 + 74 + /** 75 + * HTTP Cache-Control header values for different routes 76 + * These tell browsers and CDNs how long to cache responses 77 + * 78 + * Format: max-age=X (browser cache), s-maxage=Y (CDN cache), stale-while-revalidate=Z 79 + */ 80 + export const HTTP_CACHE_HEADERS = { 81 + // Layout data (profile, site info) - cache aggressively 82 + LAYOUT: `public, max-age=${CACHE_TTL.PROFILE / 1000}, s-maxage=${CACHE_TTL.PROFILE / 1000}, stale-while-revalidate=${CACHE_TTL.PROFILE / 1000}`, 83 + 84 + // Blog posts listing - moderate caching 85 + BLOG_LISTING: `public, max-age=${CACHE_TTL.BLOG_POSTS / 1000}, s-maxage=${CACHE_TTL.BLOG_POSTS / 1000}, stale-while-revalidate=${CACHE_TTL.BLOG_POSTS / 1000}`, 86 + 87 + // Individual blog post - cache aggressively (content doesn't change) 88 + BLOG_POST: `public, max-age=${CACHE_TTL.INDIVIDUAL_POST / 1000}, s-maxage=${CACHE_TTL.INDIVIDUAL_POST / 1000}, stale-while-revalidate=${CACHE_TTL.INDIVIDUAL_POST / 1000}`, 89 + 90 + // Music status - short cache (changes frequently) 91 + MUSIC_STATUS: `public, max-age=${CACHE_TTL.MUSIC_STATUS / 1000}, s-maxage=${CACHE_TTL.MUSIC_STATUS / 1000}, stale-while-revalidate=${CACHE_TTL.MUSIC_STATUS / 1000}`, 92 + 93 + // API endpoints - moderate caching 94 + API: `public, max-age=300, s-maxage=300, stale-while-revalidate=600` 95 + } as const;
+1
src/lib/config/index.ts
··· 1 1 export * from './slugs'; 2 + export * from './cache.config';
+14
src/lib/services/atproto/agents.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import type { ResolvedIdentity } from './types'; 3 + import { cache } from './cache'; 3 4 4 5 /** 5 6 * Creates an AtpAgent with optional fetch function injection ··· 46 47 47 48 /** 48 49 * Resolves a DID to find its PDS endpoint using Slingshot. 50 + * Results are cached to reduce resolution calls. 49 51 */ 50 52 export async function resolveIdentity( 51 53 did: string, ··· 53 55 ): Promise<ResolvedIdentity> { 54 56 console.info(`[Identity] Resolving DID: ${did}`); 55 57 58 + // Check cache first 59 + const cacheKey = `identity:${did}`; 60 + const cached = cache.get<ResolvedIdentity>(cacheKey); 61 + if (cached) { 62 + console.info('[Identity] Using cached identity resolution'); 63 + return cached; 64 + } 65 + 56 66 // Prefer an injected fetch (from SvelteKit load), fall back to global fetch 57 67 const _fetch = fetchFn ?? globalThis.fetch; 58 68 ··· 84 94 if (!data.did || !data.pds) { 85 95 throw new Error('Invalid response from identity resolver'); 86 96 } 97 + 98 + // Cache the resolved identity 99 + console.info('[Identity] Caching resolved identity'); 100 + cache.set(cacheKey, data); 87 101 88 102 return data; 89 103 }
+35 -9
src/lib/services/atproto/cache.ts
··· 1 1 import type { CacheEntry } from './types'; 2 + import { CACHE_TTL } from '$lib/config/cache.config'; 2 3 3 4 /** 4 - * Simple in-memory cache with TTL support 5 + * Simple in-memory cache with configurable TTL support 6 + * 7 + * TTL values are configured per data type in cache.config.ts 8 + * and can be overridden via environment variables 5 9 */ 6 10 export class ATProtoCache { 7 11 private cache = new Map<string, CacheEntry<any>>(); 8 - private readonly TTL = 5 * 60 * 1000; // 5 minutes 12 + 13 + /** 14 + * Get TTL for a cache key based on its prefix 15 + */ 16 + private getTTL(key: string): number { 17 + if (key.startsWith('profile:')) return CACHE_TTL.PROFILE; 18 + if (key.startsWith('siteinfo:')) return CACHE_TTL.SITE_INFO; 19 + if (key.startsWith('links:')) return CACHE_TTL.LINKS; 20 + if (key.startsWith('music-status:')) return CACHE_TTL.MUSIC_STATUS; 21 + if (key.startsWith('kibun-status:')) return CACHE_TTL.KIBUN_STATUS; 22 + if (key.startsWith('tangled:')) return CACHE_TTL.TANGLED_REPOS; 23 + if (key.startsWith('blog-posts:')) return CACHE_TTL.BLOG_POSTS; 24 + if (key.startsWith('publications:')) return CACHE_TTL.PUBLICATIONS; 25 + if (key.startsWith('post:')) return CACHE_TTL.INDIVIDUAL_POST; 26 + if (key.startsWith('identity:')) return CACHE_TTL.IDENTITY; 27 + 28 + // Default fallback (30 minutes) 29 + return 30 * 60 * 1000; 30 + } 9 31 10 32 get<T>(key: string): T | null { 11 - console.debug(`[Cache] Getting key: ${key}`); 33 + console.info(`[Cache] Getting key: ${key}`); 12 34 const entry = this.cache.get(key); 13 35 if (!entry) { 14 - console.debug(`[Cache] Cache miss for key: ${key}`); 36 + console.info(`[Cache] Cache miss for key: ${key}`); 15 37 return null; 16 38 } 17 39 18 - if (Date.now() - entry.timestamp > this.TTL) { 19 - console.debug(`[Cache] Entry expired for key: ${key}`); 40 + const ttl = this.getTTL(key); 41 + const age = Date.now() - entry.timestamp; 42 + 43 + if (age > ttl) { 44 + console.info(`[Cache] Entry expired for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)`); 20 45 this.cache.delete(key); 21 46 return null; 22 47 } 23 48 24 - console.debug(`[Cache] Cache hit for key: ${key}`); 49 + console.info(`[Cache] Cache hit for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)`); 25 50 return entry.data; 26 51 } 27 52 28 53 set<T>(key: string, data: T): void { 29 - console.debug(`[Cache] Setting key: ${key}`, data); 54 + const ttl = this.getTTL(key); 55 + console.info(`[Cache] Setting key: ${key} (ttl: ${Math.round(ttl / 1000)}s)`); 30 56 this.cache.set(key, { 31 57 data, 32 58 timestamp: Date.now() ··· 34 60 } 35 61 36 62 delete(key: string): void { 37 - console.debug(`[Cache] Deleting key: ${key}`); 63 + console.info(`[Cache] Deleting key: ${key}`); 38 64 this.cache.delete(key); 39 65 } 40 66