Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

dont serve content while caching new site

Changed files
+208 -46
hosting-service
src
+15 -6
hosting-service/src/lib/backfill.ts
··· 1 import { getAllSites } from './db'; 2 import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils'; 3 import { logger } from './observability'; 4 5 export interface BackfillOptions { 6 skipExisting?: boolean; // Skip sites already in cache ··· 96 return; 97 } 98 99 - // Download and cache site 100 - await downloadAndCacheSite(site.did, site.rkey, siteData.record, pdsEndpoint, siteData.cid); 101 - stats.cached++; 102 - processed++; 103 - logger.info('Successfully cached site during backfill', { did: site.did, rkey: site.rkey }); 104 - console.log(`✅ [${processed}/${sites.length}] Cached: ${site.display_name || site.rkey}`); 105 } catch (err) { 106 stats.failed++; 107 processed++;
··· 1 import { getAllSites } from './db'; 2 import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils'; 3 import { logger } from './observability'; 4 + import { markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache'; 5 6 export interface BackfillOptions { 7 skipExisting?: boolean; // Skip sites already in cache ··· 97 return; 98 } 99 100 + // Mark site as being cached to prevent serving stale content during update 101 + markSiteAsBeingCached(site.did, site.rkey); 102 + 103 + try { 104 + // Download and cache site 105 + await downloadAndCacheSite(site.did, site.rkey, siteData.record, pdsEndpoint, siteData.cid); 106 + stats.cached++; 107 + processed++; 108 + logger.info('Successfully cached site during backfill', { did: site.did, rkey: site.rkey }); 109 + console.log(`✅ [${processed}/${sites.length}] Cached: ${site.display_name || site.rkey}`); 110 + } finally { 111 + // Always unmark, even if caching fails 112 + unmarkSiteAsBeingCached(site.did, site.rkey); 113 + } 114 } catch (err) { 115 stats.failed++; 116 processed++;
+19
hosting-service/src/lib/cache.ts
··· 164 console.log(`[Cache] Invalidated site ${did}:${rkey} - ${fileCount} files, ${metaCount} metadata, ${htmlCount} HTML`); 165 } 166 167 // Get overall cache statistics 168 export function getCacheStats() { 169 return { ··· 173 metadataHitRate: metadataCache.getHitRate(), 174 rewrittenHtml: rewrittenHtmlCache.getStats(), 175 rewrittenHtmlHitRate: rewrittenHtmlCache.getHitRate(), 176 }; 177 }
··· 164 console.log(`[Cache] Invalidated site ${did}:${rkey} - ${fileCount} files, ${metaCount} metadata, ${htmlCount} HTML`); 165 } 166 167 + // Track sites currently being cached (to prevent serving stale cache during updates) 168 + const sitesBeingCached = new Set<string>(); 169 + 170 + export function markSiteAsBeingCached(did: string, rkey: string): void { 171 + const key = `${did}:${rkey}`; 172 + sitesBeingCached.add(key); 173 + } 174 + 175 + export function unmarkSiteAsBeingCached(did: string, rkey: string): void { 176 + const key = `${did}:${rkey}`; 177 + sitesBeingCached.delete(key); 178 + } 179 + 180 + export function isSiteBeingCached(did: string, rkey: string): boolean { 181 + const key = `${did}:${rkey}`; 182 + return sitesBeingCached.has(key); 183 + } 184 + 185 // Get overall cache statistics 186 export function getCacheStats() { 187 return { ··· 191 metadataHitRate: metadataCache.getHitRate(), 192 rewrittenHtml: rewrittenHtmlCache.getStats(), 193 rewrittenHtmlHitRate: rewrittenHtmlCache.getHitRate(), 194 + sitesBeingCached: sitesBeingCached.size, 195 }; 196 }
+43 -35
hosting-service/src/lib/firehose.ts
··· 10 import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs' 11 import { Firehose } from '@atproto/sync' 12 import { IdResolver } from '@atproto/identity' 13 - import { invalidateSiteCache } from './cache' 14 15 const CACHE_DIR = './cache/sites' 16 ··· 187 // Invalidate in-memory caches before updating 188 invalidateSiteCache(did, site) 189 190 - // Cache the record with verified CID (uses atomic swap internally) 191 - // All instances cache locally for edge serving 192 - await downloadAndCacheSite( 193 - did, 194 - site, 195 - fsRecord, 196 - pdsEndpoint, 197 - verifiedCid 198 - ) 199 - 200 - // Acquire distributed lock only for database write to prevent duplicate writes 201 - // Note: upsertSite will check cache-only mode internally and skip if needed 202 - const lockKey = `db:upsert:${did}:${site}` 203 - const lockAcquired = await tryAcquireLock(lockKey) 204 - 205 - if (!lockAcquired) { 206 - this.log('Another instance is writing to DB, skipping upsert', { 207 - did, 208 - site 209 - }) 210 - this.log('Successfully processed create/update (cached locally)', { 211 - did, 212 - site 213 - }) 214 - return 215 - } 216 217 try { 218 - // Upsert site to database (only one instance does this) 219 - // In cache-only mode, this will be a no-op 220 - await upsertSite(did, site, fsRecord.site) 221 - this.log( 222 - 'Successfully processed create/update (cached + DB updated)', 223 - { did, site } 224 ) 225 } finally { 226 - // Always release lock, even if DB write fails 227 - await releaseLock(lockKey) 228 } 229 } 230
··· 10 import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs' 11 import { Firehose } from '@atproto/sync' 12 import { IdResolver } from '@atproto/identity' 13 + import { invalidateSiteCache, markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache' 14 15 const CACHE_DIR = './cache/sites' 16 ··· 187 // Invalidate in-memory caches before updating 188 invalidateSiteCache(did, site) 189 190 + // Mark site as being cached to prevent serving stale content during update 191 + markSiteAsBeingCached(did, site) 192 193 try { 194 + // Cache the record with verified CID (uses atomic swap internally) 195 + // All instances cache locally for edge serving 196 + await downloadAndCacheSite( 197 + did, 198 + site, 199 + fsRecord, 200 + pdsEndpoint, 201 + verifiedCid 202 ) 203 + 204 + // Acquire distributed lock only for database write to prevent duplicate writes 205 + // Note: upsertSite will check cache-only mode internally and skip if needed 206 + const lockKey = `db:upsert:${did}:${site}` 207 + const lockAcquired = await tryAcquireLock(lockKey) 208 + 209 + if (!lockAcquired) { 210 + this.log('Another instance is writing to DB, skipping upsert', { 211 + did, 212 + site 213 + }) 214 + this.log('Successfully processed create/update (cached locally)', { 215 + did, 216 + site 217 + }) 218 + return 219 + } 220 + 221 + try { 222 + // Upsert site to database (only one instance does this) 223 + // In cache-only mode, this will be a no-op 224 + await upsertSite(did, site, fsRecord.site) 225 + this.log( 226 + 'Successfully processed create/update (cached + DB updated)', 227 + { did, site } 228 + ) 229 + } finally { 230 + // Always release lock, even if DB write fails 231 + await releaseLock(lockKey) 232 + } 233 } finally { 234 + // Always unmark, even if caching fails 235 + unmarkSiteAsBeingCached(did, site) 236 } 237 } 238
+129 -3
hosting-service/src/server.ts
··· 7 import { readFile, access } from 'fs/promises'; 8 import { lookup } from 'mime-types'; 9 import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 10 - import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata } from './lib/cache'; 11 import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects'; 12 13 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; ··· 43 } 44 } 45 46 // Cache for redirect rules (per site) 47 const redirectRulesCache = new Map<string, RedirectRule[]>(); 48 ··· 139 140 // Internal function to serve a file (used by both normal serving and rewrites) 141 async function serveFileInternal(did: string, rkey: string, filePath: string) { 142 // Default to first index file if path is empty 143 let requestPath = filePath || INDEX_FILES[0]; 144 ··· 360 361 // Internal function to serve a file with rewriting 362 async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) { 363 // Default to first index file if path is empty 364 let requestPath = filePath || INDEX_FILES[0]; 365 ··· 581 return false; 582 } 583 584 try { 585 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 586 // Clear redirect rules cache since the site was updated ··· 590 } catch (err) { 591 logger.error('Failed to cache site', err, { did, rkey }); 592 return false; 593 } 594 } 595 ··· 618 const rawPath = url.pathname.replace(/^\//, ''); 619 const path = sanitizePath(rawPath); 620 621 - // Check if this is sites.wisp.place subdomain 622 - if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 623 // Sanitize the path FIRST to prevent path traversal 624 const sanitizedFullPath = sanitizePath(rawPath); 625 ··· 652 const did = await resolveDid(identifier); 653 if (!did) { 654 return c.text('Invalid identifier', 400); 655 } 656 657 // Ensure site is cached ··· 697 return c.text('Invalid site configuration', 500); 698 } 699 700 const cached = await ensureSiteCached(customDomain.did, rkey); 701 if (!cached) { 702 return c.text('Site not found', 404); ··· 725 return c.text('Invalid site configuration', 500); 726 } 727 728 const cached = await ensureSiteCached(domainInfo.did, rkey); 729 if (!cached) { 730 return c.text('Site not found', 404); ··· 750 const rkey = customDomain.rkey; 751 if (!isValidRkey(rkey)) { 752 return c.text('Invalid site configuration', 500); 753 } 754 755 const cached = await ensureSiteCached(customDomain.did, rkey);
··· 7 import { readFile, access } from 'fs/promises'; 8 import { lookup } from 'mime-types'; 9 import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 10 + import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata, markSiteAsBeingCached, unmarkSiteAsBeingCached, isSiteBeingCached } from './lib/cache'; 11 import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects'; 12 13 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; ··· 43 } 44 } 45 46 + /** 47 + * Return a response indicating the site is being updated 48 + */ 49 + function siteUpdatingResponse(): Response { 50 + const html = `<!DOCTYPE html> 51 + <html> 52 + <head> 53 + <meta charset="utf-8"> 54 + <meta name="viewport" content="width=device-width, initial-scale=1"> 55 + <title>Site Updating</title> 56 + <style> 57 + @media (prefers-color-scheme: light) { 58 + :root { 59 + --background: oklch(0.90 0.012 35); 60 + --foreground: oklch(0.18 0.01 30); 61 + --primary: oklch(0.35 0.02 35); 62 + --accent: oklch(0.78 0.15 345); 63 + } 64 + } 65 + @media (prefers-color-scheme: dark) { 66 + :root { 67 + --background: oklch(0.23 0.015 285); 68 + --foreground: oklch(0.90 0.005 285); 69 + --primary: oklch(0.70 0.10 295); 70 + --accent: oklch(0.85 0.08 5); 71 + } 72 + } 73 + body { 74 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 75 + display: flex; 76 + align-items: center; 77 + justify-content: center; 78 + min-height: 100vh; 79 + margin: 0; 80 + background: var(--background); 81 + color: var(--foreground); 82 + } 83 + .container { 84 + text-align: center; 85 + padding: 2rem; 86 + max-width: 500px; 87 + } 88 + h1 { 89 + font-size: 2.5rem; 90 + margin-bottom: 1rem; 91 + font-weight: 600; 92 + color: var(--primary); 93 + } 94 + p { 95 + font-size: 1.25rem; 96 + opacity: 0.8; 97 + margin-bottom: 2rem; 98 + color: var(--foreground); 99 + } 100 + .spinner { 101 + border: 4px solid var(--accent); 102 + border-radius: 50%; 103 + border-top: 4px solid var(--primary); 104 + width: 40px; 105 + height: 40px; 106 + animation: spin 1s linear infinite; 107 + margin: 0 auto; 108 + } 109 + @keyframes spin { 110 + 0% { transform: rotate(0deg); } 111 + 100% { transform: rotate(360deg); } 112 + } 113 + </style> 114 + <meta http-equiv="refresh" content="3"> 115 + </head> 116 + <body> 117 + <div class="container"> 118 + <h1>Site Updating</h1> 119 + <p>This site is undergoing an update right now. Check back in a moment...</p> 120 + <div class="spinner"></div> 121 + </div> 122 + </body> 123 + </html>`; 124 + 125 + return new Response(html, { 126 + status: 503, 127 + headers: { 128 + 'Content-Type': 'text/html; charset=utf-8', 129 + 'Cache-Control': 'no-store, no-cache, must-revalidate', 130 + 'Retry-After': '3', 131 + }, 132 + }); 133 + } 134 + 135 // Cache for redirect rules (per site) 136 const redirectRulesCache = new Map<string, RedirectRule[]>(); 137 ··· 228 229 // Internal function to serve a file (used by both normal serving and rewrites) 230 async function serveFileInternal(did: string, rkey: string, filePath: string) { 231 + // Check if site is currently being cached - if so, return updating response 232 + if (isSiteBeingCached(did, rkey)) { 233 + return siteUpdatingResponse(); 234 + } 235 + 236 // Default to first index file if path is empty 237 let requestPath = filePath || INDEX_FILES[0]; 238 ··· 454 455 // Internal function to serve a file with rewriting 456 async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) { 457 + // Check if site is currently being cached - if so, return updating response 458 + if (isSiteBeingCached(did, rkey)) { 459 + return siteUpdatingResponse(); 460 + } 461 + 462 // Default to first index file if path is empty 463 let requestPath = filePath || INDEX_FILES[0]; 464 ··· 680 return false; 681 } 682 683 + // Mark site as being cached to prevent serving stale content during update 684 + markSiteAsBeingCached(did, rkey); 685 + 686 try { 687 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 688 // Clear redirect rules cache since the site was updated ··· 692 } catch (err) { 693 logger.error('Failed to cache site', err, { did, rkey }); 694 return false; 695 + } finally { 696 + // Always unmark, even if caching fails 697 + unmarkSiteAsBeingCached(did, rkey); 698 } 699 } 700 ··· 723 const rawPath = url.pathname.replace(/^\//, ''); 724 const path = sanitizePath(rawPath); 725 726 + // Check if this is sites.wisp.place subdomain (strip port for comparison) 727 + const hostnameWithoutPort = hostname.split(':')[0]; 728 + if (hostnameWithoutPort === `sites.${BASE_HOST}`) { 729 // Sanitize the path FIRST to prevent path traversal 730 const sanitizedFullPath = sanitizePath(rawPath); 731 ··· 758 const did = await resolveDid(identifier); 759 if (!did) { 760 return c.text('Invalid identifier', 400); 761 + } 762 + 763 + // Check if site is currently being cached - return updating response early 764 + if (isSiteBeingCached(did, site)) { 765 + return siteUpdatingResponse(); 766 } 767 768 // Ensure site is cached ··· 808 return c.text('Invalid site configuration', 500); 809 } 810 811 + // Check if site is currently being cached - return updating response early 812 + if (isSiteBeingCached(customDomain.did, rkey)) { 813 + return siteUpdatingResponse(); 814 + } 815 + 816 const cached = await ensureSiteCached(customDomain.did, rkey); 817 if (!cached) { 818 return c.text('Site not found', 404); ··· 841 return c.text('Invalid site configuration', 500); 842 } 843 844 + // Check if site is currently being cached - return updating response early 845 + if (isSiteBeingCached(domainInfo.did, rkey)) { 846 + return siteUpdatingResponse(); 847 + } 848 + 849 const cached = await ensureSiteCached(domainInfo.did, rkey); 850 if (!cached) { 851 return c.text('Site not found', 404); ··· 871 const rkey = customDomain.rkey; 872 if (!isValidRkey(rkey)) { 873 return c.text('Invalid site configuration', 500); 874 + } 875 + 876 + // Check if site is currently being cached - return updating response early 877 + if (isSiteBeingCached(customDomain.did, rkey)) { 878 + return siteUpdatingResponse(); 879 } 880 881 const cached = await ensureSiteCached(customDomain.did, rkey);
+2 -2
src/lib/oauth-client.ts
··· 110 // Loopback client for local development 111 // For loopback, scopes and redirect_uri must be in client_id query string 112 const redirectUri = 'http://127.0.0.1:8000/api/auth/callback'; 113 - const scope = 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs blob:*/* blob?maxSize=100000000 rpc:app.bsky.actor.getProfile?aud=*'; 114 const params = new URLSearchParams(); 115 params.append('redirect_uri', redirectUri); 116 params.append('scope', scope); ··· 145 application_type: 'web', 146 token_endpoint_auth_method: 'private_key_jwt', 147 token_endpoint_auth_signing_alg: "ES256", 148 - scope: "atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs blob:*/* blob?maxSize=100000000 rpc:app.bsky.actor.getProfile?aud=*", 149 dpop_bound_access_tokens: true, 150 jwks_uri: `${config.domain}/jwks.json`, 151 subject_type: 'public',
··· 110 // Loopback client for local development 111 // For loopback, scopes and redirect_uri must be in client_id query string 112 const redirectUri = 'http://127.0.0.1:8000/api/auth/callback'; 113 + const scope = 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs blob:*/* blob?maxSize=100000000 rpc:*?aud=did:web:api.bsky.app#bsky_appview'; 114 const params = new URLSearchParams(); 115 params.append('redirect_uri', redirectUri); 116 params.append('scope', scope); ··· 145 application_type: 'web', 146 token_endpoint_auth_method: 'private_key_jwt', 147 token_endpoint_auth_signing_alg: "ES256", 148 + scope: "atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs blob:*/* blob?maxSize=100000000 rpc:*?aud=did:web:api.bsky.app#bsky_appview", 149 dpop_bound_access_tokens: true, 150 jwks_uri: `${config.domain}/jwks.json`, 151 subject_type: 'public',