+15
-6
hosting-service/src/lib/backfill.ts
+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
+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
+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
+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
+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',