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 1 import { getAllSites } from './db'; 2 2 import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils'; 3 3 import { logger } from './observability'; 4 + import { markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache'; 4 5 5 6 export interface BackfillOptions { 6 7 skipExisting?: boolean; // Skip sites already in cache ··· 96 97 return; 97 98 } 98 99 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}`); 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 + } 105 114 } catch (err) { 106 115 stats.failed++; 107 116 processed++;
+19
hosting-service/src/lib/cache.ts
··· 164 164 console.log(`[Cache] Invalidated site ${did}:${rkey} - ${fileCount} files, ${metaCount} metadata, ${htmlCount} HTML`); 165 165 } 166 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 + 167 185 // Get overall cache statistics 168 186 export function getCacheStats() { 169 187 return { ··· 173 191 metadataHitRate: metadataCache.getHitRate(), 174 192 rewrittenHtml: rewrittenHtmlCache.getStats(), 175 193 rewrittenHtmlHitRate: rewrittenHtmlCache.getHitRate(), 194 + sitesBeingCached: sitesBeingCached.size, 176 195 }; 177 196 }
+43 -35
hosting-service/src/lib/firehose.ts
··· 10 10 import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs' 11 11 import { Firehose } from '@atproto/sync' 12 12 import { IdResolver } from '@atproto/identity' 13 - import { invalidateSiteCache } from './cache' 13 + import { invalidateSiteCache, markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache' 14 14 15 15 const CACHE_DIR = './cache/sites' 16 16 ··· 187 187 // Invalidate in-memory caches before updating 188 188 invalidateSiteCache(did, site) 189 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 - } 190 + // Mark site as being cached to prevent serving stale content during update 191 + markSiteAsBeingCached(did, site) 216 192 217 193 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 } 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 224 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 + } 225 233 } finally { 226 - // Always release lock, even if DB write fails 227 - await releaseLock(lockKey) 234 + // Always unmark, even if caching fails 235 + unmarkSiteAsBeingCached(did, site) 228 236 } 229 237 } 230 238
+129 -3
hosting-service/src/server.ts
··· 7 7 import { readFile, access } from 'fs/promises'; 8 8 import { lookup } from 'mime-types'; 9 9 import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 10 - import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata } from './lib/cache'; 10 + import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata, markSiteAsBeingCached, unmarkSiteAsBeingCached, isSiteBeingCached } from './lib/cache'; 11 11 import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects'; 12 12 13 13 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; ··· 43 43 } 44 44 } 45 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 + 46 135 // Cache for redirect rules (per site) 47 136 const redirectRulesCache = new Map<string, RedirectRule[]>(); 48 137 ··· 139 228 140 229 // Internal function to serve a file (used by both normal serving and rewrites) 141 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 + 142 236 // Default to first index file if path is empty 143 237 let requestPath = filePath || INDEX_FILES[0]; 144 238 ··· 360 454 361 455 // Internal function to serve a file with rewriting 362 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 + 363 462 // Default to first index file if path is empty 364 463 let requestPath = filePath || INDEX_FILES[0]; 365 464 ··· 581 680 return false; 582 681 } 583 682 683 + // Mark site as being cached to prevent serving stale content during update 684 + markSiteAsBeingCached(did, rkey); 685 + 584 686 try { 585 687 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 586 688 // Clear redirect rules cache since the site was updated ··· 590 692 } catch (err) { 591 693 logger.error('Failed to cache site', err, { did, rkey }); 592 694 return false; 695 + } finally { 696 + // Always unmark, even if caching fails 697 + unmarkSiteAsBeingCached(did, rkey); 593 698 } 594 699 } 595 700 ··· 618 723 const rawPath = url.pathname.replace(/^\//, ''); 619 724 const path = sanitizePath(rawPath); 620 725 621 - // Check if this is sites.wisp.place subdomain 622 - if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 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}`) { 623 729 // Sanitize the path FIRST to prevent path traversal 624 730 const sanitizedFullPath = sanitizePath(rawPath); 625 731 ··· 652 758 const did = await resolveDid(identifier); 653 759 if (!did) { 654 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(); 655 766 } 656 767 657 768 // Ensure site is cached ··· 697 808 return c.text('Invalid site configuration', 500); 698 809 } 699 810 811 + // Check if site is currently being cached - return updating response early 812 + if (isSiteBeingCached(customDomain.did, rkey)) { 813 + return siteUpdatingResponse(); 814 + } 815 + 700 816 const cached = await ensureSiteCached(customDomain.did, rkey); 701 817 if (!cached) { 702 818 return c.text('Site not found', 404); ··· 725 841 return c.text('Invalid site configuration', 500); 726 842 } 727 843 844 + // Check if site is currently being cached - return updating response early 845 + if (isSiteBeingCached(domainInfo.did, rkey)) { 846 + return siteUpdatingResponse(); 847 + } 848 + 728 849 const cached = await ensureSiteCached(domainInfo.did, rkey); 729 850 if (!cached) { 730 851 return c.text('Site not found', 404); ··· 750 871 const rkey = customDomain.rkey; 751 872 if (!isValidRkey(rkey)) { 752 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(); 753 879 } 754 880 755 881 const cached = await ensureSiteCached(customDomain.did, rkey);
+2 -2
src/lib/oauth-client.ts
··· 110 110 // Loopback client for local development 111 111 // For loopback, scopes and redirect_uri must be in client_id query string 112 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=*'; 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 114 const params = new URLSearchParams(); 115 115 params.append('redirect_uri', redirectUri); 116 116 params.append('scope', scope); ··· 145 145 application_type: 'web', 146 146 token_endpoint_auth_method: 'private_key_jwt', 147 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=*", 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 149 dpop_bound_access_tokens: true, 150 150 jwks_uri: `${config.domain}/jwks.json`, 151 151 subject_type: 'public',