tangled
alpha
login
or
join now
nekomimi.pet
/
wisp.place-monorepo
88
fork
atom
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
88
fork
atom
overview
issues
9
pulls
pipelines
caching consolidation, fix tiered storage
nekomimi.pet
1 month ago
1a77307f
f7e83b32
+270
-315
9 changed files
expand all
collapse all
unified
split
apps
hosting-service
src
index.ts
lib
cache-invalidation.ts
cache-manager.ts
cache.ts
db.ts
file-serving.ts
site-cache.ts
server.ts
packages
@wispplace
tiered-storage
src
tiers
DiskStorageTier.ts
+6
-5
apps/hosting-service/src/index.ts
···
2
2
import { serve } from '@hono/node-server';
3
3
import { initializeGrafanaExporters, createLogger } from '@wispplace/observability';
4
4
import { mkdirSync, existsSync } from 'fs';
5
5
-
import { startDomainCacheCleanup, stopDomainCacheCleanup, closeDatabase, CACHE_ONLY } from './lib/db';
5
5
+
import { closeDatabase, CACHE_ONLY } from './lib/db';
6
6
+
import { cache } from './lib/cache-manager';
6
7
import { closeRevalidateQueue } from './lib/revalidate-queue';
7
8
import { startCacheInvalidationSubscriber, stopCacheInvalidationSubscriber } from './lib/cache-invalidation';
8
9
import { storage, getStorageConfig } from './lib/storage';
···
24
25
logger.info('Created cache directory', { CACHE_DIR });
25
26
}
26
27
27
27
-
// Start domain cache cleanup
28
28
-
startDomainCacheCleanup();
28
28
+
// Start in-memory cache cleanup
29
29
+
cache.startCleanup();
29
30
30
31
// Start cache invalidation subscriber (listens for firehose-service updates via Redis pub/sub)
31
32
startCacheInvalidationSubscriber();
···
87
88
// Graceful shutdown
88
89
process.on('SIGINT', async () => {
89
90
logger.info('Shutting down...');
90
90
-
stopDomainCacheCleanup();
91
91
+
cache.stopCleanup();
91
92
await stopCacheInvalidationSubscriber();
92
93
await closeRevalidateQueue();
93
94
await closeDatabase();
···
97
98
98
99
process.on('SIGTERM', async () => {
99
100
logger.info('Shutting down...');
100
100
-
stopDomainCacheCleanup();
101
101
+
cache.stopCleanup();
101
102
await stopCacheInvalidationSubscriber();
102
103
await closeRevalidateQueue();
103
104
await closeDatabase();
+4
-3
apps/hosting-service/src/lib/cache-invalidation.ts
···
8
8
9
9
import Redis from 'ioredis';
10
10
import { storage } from './storage';
11
11
-
import { clearRedirectRulesCache } from './site-cache';
11
11
+
import { cache } from './cache-manager';
12
12
13
13
const CHANNEL = 'wisp:cache-invalidate';
14
14
···
63
63
const deleted = await storage.invalidate(prefix);
64
64
console.log(`[CacheInvalidation] Cleared ${deleted} keys from tiered storage for ${did}/${rkey}`);
65
65
66
66
-
// Clear redirect rules cache
67
67
-
clearRedirectRulesCache(did, rkey);
66
66
+
// Clear in-memory caches for this site
67
67
+
cache.delete('redirectRules', `${did}:${rkey}`);
68
68
+
cache.delete('settings', `${did}:${rkey}`);
68
69
} catch (err) {
69
70
console.error('[CacheInvalidation] Error processing message:', err);
70
71
}
+208
apps/hosting-service/src/lib/cache-manager.ts
···
1
1
+
/**
2
2
+
* Centralized in-memory cache manager for the hosting service.
3
3
+
*
4
4
+
* Replaces the scattered TTL Maps (domains, customDomains, settings, handles)
5
5
+
* and the LRU redirect-rules cache with a single, namespace-aware cache.
6
6
+
*/
7
7
+
8
8
+
interface NamespaceConfig {
9
9
+
/** Time-to-live in milliseconds. Entries older than this are stale. */
10
10
+
ttl?: number;
11
11
+
/** Maximum number of entries before LRU eviction kicks in. */
12
12
+
maxEntries?: number;
13
13
+
/** Maximum total estimated size (bytes) before LRU eviction kicks in. */
14
14
+
maxSize?: number;
15
15
+
/** Estimate the byte size of a value. Required when maxSize is set. */
16
16
+
estimateSize?: (value: unknown) => number;
17
17
+
}
18
18
+
19
19
+
interface CacheEntry {
20
20
+
value: unknown;
21
21
+
timestamp: number;
22
22
+
size: number;
23
23
+
}
24
24
+
25
25
+
interface NamespaceStats {
26
26
+
hits: number;
27
27
+
misses: number;
28
28
+
evictions: number;
29
29
+
entries: number;
30
30
+
sizeBytes: number;
31
31
+
}
32
32
+
33
33
+
interface GetOrFetchOpts<T> {
34
34
+
/** Skip caching when predicate returns false (e.g. don't cache null). */
35
35
+
cacheIf?: (value: T) => boolean;
36
36
+
}
37
37
+
38
38
+
export class CacheManager<NS extends string = string> {
39
39
+
private namespaces: Map<NS, Map<string, CacheEntry>> = new Map();
40
40
+
private configs: Map<NS, NamespaceConfig> = new Map();
41
41
+
private stats: Map<NS, NamespaceStats> = new Map();
42
42
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
43
43
+
44
44
+
constructor(config: Record<NS, NamespaceConfig>) {
45
45
+
for (const [ns, cfg] of Object.entries(config) as [NS, NamespaceConfig][]) {
46
46
+
this.namespaces.set(ns, new Map());
47
47
+
this.configs.set(ns, cfg);
48
48
+
this.stats.set(ns, { hits: 0, misses: 0, evictions: 0, entries: 0, sizeBytes: 0 });
49
49
+
}
50
50
+
}
51
51
+
52
52
+
// ── Primary API ──────────────────────────────────────────────────────
53
53
+
54
54
+
async getOrFetch<T>(
55
55
+
ns: NS,
56
56
+
key: string,
57
57
+
fetcher: () => T | Promise<T>,
58
58
+
opts?: GetOrFetchOpts<T>,
59
59
+
): Promise<T> {
60
60
+
const existing = this.get<T>(ns, key);
61
61
+
if (existing !== undefined) return existing;
62
62
+
63
63
+
const value = await fetcher();
64
64
+
65
65
+
if (!opts?.cacheIf || opts.cacheIf(value)) {
66
66
+
this.set(ns, key, value);
67
67
+
}
68
68
+
69
69
+
return value;
70
70
+
}
71
71
+
72
72
+
get<T>(ns: NS, key: string): T | undefined {
73
73
+
const map = this.namespaces.get(ns);
74
74
+
const cfg = this.configs.get(ns);
75
75
+
const st = this.stats.get(ns);
76
76
+
if (!map || !cfg || !st) return undefined;
77
77
+
78
78
+
const entry = map.get(key);
79
79
+
if (!entry) {
80
80
+
st.misses++;
81
81
+
return undefined;
82
82
+
}
83
83
+
84
84
+
// TTL check
85
85
+
if (cfg.ttl && Date.now() - entry.timestamp > cfg.ttl) {
86
86
+
map.delete(key);
87
87
+
st.entries = map.size;
88
88
+
st.sizeBytes -= entry.size;
89
89
+
st.misses++;
90
90
+
return undefined;
91
91
+
}
92
92
+
93
93
+
// Touch for LRU: delete + re-insert moves to end of Map iteration order
94
94
+
map.delete(key);
95
95
+
map.set(key, entry);
96
96
+
97
97
+
st.hits++;
98
98
+
return entry.value as T;
99
99
+
}
100
100
+
101
101
+
set(ns: NS, key: string, value: unknown): void {
102
102
+
const map = this.namespaces.get(ns);
103
103
+
const cfg = this.configs.get(ns);
104
104
+
const st = this.stats.get(ns);
105
105
+
if (!map || !cfg || !st) return;
106
106
+
107
107
+
const size = cfg.estimateSize ? cfg.estimateSize(value) : 0;
108
108
+
109
109
+
// Remove existing entry first
110
110
+
const existing = map.get(key);
111
111
+
if (existing) {
112
112
+
st.sizeBytes -= existing.size;
113
113
+
map.delete(key);
114
114
+
}
115
115
+
116
116
+
// LRU eviction
117
117
+
while (map.size > 0) {
118
118
+
const overCount = cfg.maxEntries !== undefined && map.size >= cfg.maxEntries;
119
119
+
const overSize = cfg.maxSize !== undefined && st.sizeBytes + size > cfg.maxSize;
120
120
+
if (!overCount && !overSize) break;
121
121
+
122
122
+
const oldest = map.keys().next().value;
123
123
+
if (oldest === undefined) break;
124
124
+
const evicted = map.get(oldest)!;
125
125
+
map.delete(oldest);
126
126
+
st.sizeBytes -= evicted.size;
127
127
+
st.evictions++;
128
128
+
}
129
129
+
130
130
+
map.set(key, { value, timestamp: Date.now(), size });
131
131
+
st.sizeBytes += size;
132
132
+
st.entries = map.size;
133
133
+
}
134
134
+
135
135
+
delete(ns: NS, key: string): void {
136
136
+
const map = this.namespaces.get(ns);
137
137
+
const st = this.stats.get(ns);
138
138
+
if (!map || !st) return;
139
139
+
140
140
+
const entry = map.get(key);
141
141
+
if (entry) {
142
142
+
map.delete(key);
143
143
+
st.sizeBytes -= entry.size;
144
144
+
st.entries = map.size;
145
145
+
}
146
146
+
}
147
147
+
148
148
+
clear(ns: NS): void {
149
149
+
const map = this.namespaces.get(ns);
150
150
+
const st = this.stats.get(ns);
151
151
+
if (!map || !st) return;
152
152
+
153
153
+
map.clear();
154
154
+
st.entries = 0;
155
155
+
st.sizeBytes = 0;
156
156
+
}
157
157
+
158
158
+
// ── Stats ────────────────────────────────────────────────────────────
159
159
+
160
160
+
getStats(): Record<NS, NamespaceStats> {
161
161
+
const out = {} as Record<NS, NamespaceStats>;
162
162
+
for (const [ns, st] of this.stats) {
163
163
+
out[ns] = { ...st };
164
164
+
}
165
165
+
return out;
166
166
+
}
167
167
+
168
168
+
// ── Periodic cleanup ─────────────────────────────────────────────────
169
169
+
170
170
+
startCleanup(intervalMs = 30 * 60_000): void {
171
171
+
if (this.cleanupTimer) return;
172
172
+
173
173
+
this.cleanupTimer = setInterval(() => {
174
174
+
const now = Date.now();
175
175
+
for (const [ns, cfg] of this.configs) {
176
176
+
if (!cfg.ttl) continue;
177
177
+
const map = this.namespaces.get(ns)!;
178
178
+
const st = this.stats.get(ns)!;
179
179
+
for (const [key, entry] of map) {
180
180
+
if (now - entry.timestamp > cfg.ttl) {
181
181
+
map.delete(key);
182
182
+
st.sizeBytes -= entry.size;
183
183
+
}
184
184
+
}
185
185
+
st.entries = map.size;
186
186
+
}
187
187
+
}, intervalMs);
188
188
+
}
189
189
+
190
190
+
stopCleanup(): void {
191
191
+
if (this.cleanupTimer) {
192
192
+
clearInterval(this.cleanupTimer);
193
193
+
this.cleanupTimer = null;
194
194
+
}
195
195
+
}
196
196
+
}
197
197
+
198
198
+
// ── Singleton ────────────────────────────────────────────────────────────
199
199
+
200
200
+
type CacheNamespace = 'domains' | 'customDomains' | 'settings' | 'handles' | 'redirectRules';
201
201
+
202
202
+
export const cache = new CacheManager<CacheNamespace>({
203
203
+
domains: { ttl: 5 * 60_000, maxEntries: 5000 },
204
204
+
customDomains: { ttl: 5 * 60_000, maxEntries: 5000 },
205
205
+
settings: { ttl: 5 * 60_000, maxEntries: 1000 },
206
206
+
handles: { ttl: 10 * 60_000, maxEntries: 5000 },
207
207
+
redirectRules: { maxEntries: 1000, maxSize: 10 * 1024 * 1024, estimateSize: (v) => (v as unknown[]).length * 100 },
208
208
+
});
+4
-120
apps/hosting-service/src/lib/cache.ts
···
1
1
/**
2
2
-
* Cache management for wisp-hosting-service
2
2
+
* Cache statistics for wisp-hosting-service
3
3
*
4
4
* With tiered storage, most caching is handled transparently.
5
5
-
* This module provides a generic LRU cache and exposes storage stats.
5
5
+
* In-memory caches are managed by the centralized CacheManager.
6
6
*/
7
7
8
8
import { storage } from './storage';
9
9
-
10
10
-
// In-memory LRU cache for rewritten HTML (for path rewriting in subdomain routes)
11
11
-
interface CacheEntry<T> {
12
12
-
value: T;
13
13
-
size: number;
14
14
-
timestamp: number;
15
15
-
}
16
16
-
17
17
-
interface CacheStats {
18
18
-
hits: number;
19
19
-
misses: number;
20
20
-
evictions: number;
21
21
-
currentSize: number;
22
22
-
currentCount: number;
23
23
-
}
24
24
-
25
25
-
export class LRUCache<T> {
26
26
-
private cache: Map<string, CacheEntry<T>>;
27
27
-
private maxSize: number;
28
28
-
private maxCount: number;
29
29
-
private currentSize: number;
30
30
-
private stats: CacheStats;
31
31
-
32
32
-
constructor(maxSize: number, maxCount: number) {
33
33
-
this.cache = new Map();
34
34
-
this.maxSize = maxSize;
35
35
-
this.maxCount = maxCount;
36
36
-
this.currentSize = 0;
37
37
-
this.stats = {
38
38
-
hits: 0,
39
39
-
misses: 0,
40
40
-
evictions: 0,
41
41
-
currentSize: 0,
42
42
-
currentCount: 0,
43
43
-
};
44
44
-
}
45
45
-
46
46
-
get(key: string): T | null {
47
47
-
const entry = this.cache.get(key);
48
48
-
if (!entry) {
49
49
-
this.stats.misses++;
50
50
-
return null;
51
51
-
}
52
52
-
53
53
-
// Move to end (most recently used)
54
54
-
this.cache.delete(key);
55
55
-
this.cache.set(key, entry);
56
56
-
57
57
-
this.stats.hits++;
58
58
-
return entry.value;
59
59
-
}
60
60
-
61
61
-
set(key: string, value: T, size: number): void {
62
62
-
// Remove existing entry if present
63
63
-
if (this.cache.has(key)) {
64
64
-
const existing = this.cache.get(key)!;
65
65
-
this.currentSize -= existing.size;
66
66
-
this.cache.delete(key);
67
67
-
}
68
68
-
69
69
-
// Evict entries if needed
70
70
-
while (
71
71
-
(this.cache.size >= this.maxCount || this.currentSize + size > this.maxSize) &&
72
72
-
this.cache.size > 0
73
73
-
) {
74
74
-
const firstKey = this.cache.keys().next().value;
75
75
-
if (!firstKey) break; // Should never happen, but satisfy TypeScript
76
76
-
const firstEntry = this.cache.get(firstKey);
77
77
-
if (!firstEntry) break; // Should never happen, but satisfy TypeScript
78
78
-
this.cache.delete(firstKey);
79
79
-
this.currentSize -= firstEntry.size;
80
80
-
this.stats.evictions++;
81
81
-
}
82
82
-
83
83
-
// Add new entry
84
84
-
this.cache.set(key, {
85
85
-
value,
86
86
-
size,
87
87
-
timestamp: Date.now(),
88
88
-
});
89
89
-
this.currentSize += size;
90
90
-
91
91
-
// Update stats
92
92
-
this.stats.currentSize = this.currentSize;
93
93
-
this.stats.currentCount = this.cache.size;
94
94
-
}
95
95
-
96
96
-
delete(key: string): boolean {
97
97
-
const entry = this.cache.get(key);
98
98
-
if (!entry) return false;
99
99
-
100
100
-
this.cache.delete(key);
101
101
-
this.currentSize -= entry.size;
102
102
-
this.stats.currentSize = this.currentSize;
103
103
-
this.stats.currentCount = this.cache.size;
104
104
-
return true;
105
105
-
}
106
106
-
107
107
-
size(): number {
108
108
-
return this.cache.size;
109
109
-
}
110
110
-
111
111
-
clear(): void {
112
112
-
this.cache.clear();
113
113
-
this.currentSize = 0;
114
114
-
this.stats.currentSize = 0;
115
115
-
this.stats.currentCount = 0;
116
116
-
}
117
117
-
118
118
-
getStats(): CacheStats {
119
119
-
return { ...this.stats };
120
120
-
}
121
121
-
122
122
-
getHitRate(): number {
123
123
-
const total = this.stats.hits + this.stats.misses;
124
124
-
return total === 0 ? 0 : (this.stats.hits / total) * 100;
125
125
-
}
126
126
-
}
9
9
+
import { cache } from './cache-manager';
127
10
128
11
// Get overall cache statistics
129
12
export async function getCacheStats() {
···
131
14
132
15
return {
133
16
tieredStorage: tieredStats,
17
17
+
inMemory: cache.getStats(),
134
18
};
135
19
}
+30
-120
apps/hosting-service/src/lib/db.ts
···
1
1
import postgres from 'postgres';
2
2
import { createHash } from 'crypto';
3
3
import type { DomainLookup, CustomDomainLookup, SiteCache, SiteSettingsCache } from '@wispplace/database';
4
4
+
import { cache } from './cache-manager';
4
5
5
6
const sql = postgres(
6
7
process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp',
···
13
14
// Cache-only mode: skip all DB writes and only use tiered storage
14
15
export const CACHE_ONLY = process.env.CACHE_ONLY === 'true';
15
16
16
16
-
// Domain lookup cache with TTL
17
17
-
const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
18
18
-
19
19
-
interface CachedDomain<T> {
20
20
-
value: T;
21
21
-
timestamp: number;
22
22
-
}
23
23
-
24
24
-
const domainCache = new Map<string, CachedDomain<DomainLookup | null>>();
25
25
-
const customDomainCache = new Map<string, CachedDomain<CustomDomainLookup | null>>();
26
26
-
27
27
-
let cleanupInterval: NodeJS.Timeout | null = null;
28
28
-
29
29
-
export function startDomainCacheCleanup() {
30
30
-
if (cleanupInterval) return;
31
31
-
32
32
-
cleanupInterval = setInterval(() => {
33
33
-
const now = Date.now();
34
34
-
35
35
-
for (const [key, entry] of domainCache.entries()) {
36
36
-
if (now - entry.timestamp > DOMAIN_CACHE_TTL) {
37
37
-
domainCache.delete(key);
38
38
-
}
39
39
-
}
40
40
-
41
41
-
for (const [key, entry] of customDomainCache.entries()) {
42
42
-
if (now - entry.timestamp > DOMAIN_CACHE_TTL) {
43
43
-
customDomainCache.delete(key);
44
44
-
}
45
45
-
}
46
46
-
47
47
-
for (const [key, entry] of settingsCache.entries()) {
48
48
-
if (now - entry.timestamp > SETTINGS_CACHE_TTL) {
49
49
-
settingsCache.delete(key);
50
50
-
}
51
51
-
}
52
52
-
}, 30 * 60 * 1000); // Run every 30 minutes
53
53
-
}
54
54
-
55
55
-
export function stopDomainCacheCleanup() {
56
56
-
if (cleanupInterval) {
57
57
-
clearInterval(cleanupInterval);
58
58
-
cleanupInterval = null;
59
59
-
}
60
60
-
}
61
61
-
62
17
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
63
18
const key = domain.toLowerCase();
64
64
-
65
65
-
// Check cache first
66
66
-
const cached = domainCache.get(key);
67
67
-
if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
68
68
-
return cached.value;
69
69
-
}
70
70
-
71
71
-
// Query database
72
72
-
const result = await sql<DomainLookup[]>`
73
73
-
SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1
74
74
-
`;
75
75
-
const data = result[0] || null;
76
76
-
77
77
-
// Cache the result
78
78
-
domainCache.set(key, { value: data, timestamp: Date.now() });
79
79
-
80
80
-
return data;
19
19
+
return cache.getOrFetch('domains', key, async () => {
20
20
+
const result = await sql<DomainLookup[]>`
21
21
+
SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1
22
22
+
`;
23
23
+
return result[0] || null;
24
24
+
});
81
25
}
82
26
83
27
export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> {
84
28
const key = domain.toLowerCase();
85
85
-
86
86
-
// Check cache first
87
87
-
const cached = customDomainCache.get(key);
88
88
-
if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
89
89
-
return cached.value;
90
90
-
}
91
91
-
92
92
-
// Query database
93
93
-
const result = await sql<CustomDomainLookup[]>`
94
94
-
SELECT id, domain, did, rkey, verified FROM custom_domains
95
95
-
WHERE domain = ${key} AND verified = true LIMIT 1
96
96
-
`;
97
97
-
const data = result[0] || null;
98
98
-
99
99
-
// Cache the result
100
100
-
customDomainCache.set(key, { value: data, timestamp: Date.now() });
101
101
-
102
102
-
return data;
29
29
+
return cache.getOrFetch('customDomains', key, async () => {
30
30
+
const result = await sql<CustomDomainLookup[]>`
31
31
+
SELECT id, domain, did, rkey, verified FROM custom_domains
32
32
+
WHERE domain = ${key} AND verified = true LIMIT 1
33
33
+
`;
34
34
+
return result[0] || null;
35
35
+
});
103
36
}
104
37
105
38
export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> {
106
106
-
const key = `hash:${hash}`;
107
107
-
108
108
-
// Check cache first
109
109
-
const cached = customDomainCache.get(key);
110
110
-
if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
111
111
-
return cached.value;
112
112
-
}
113
113
-
114
114
-
// Query database
115
115
-
const result = await sql<CustomDomainLookup[]>`
116
116
-
SELECT id, domain, did, rkey, verified FROM custom_domains
117
117
-
WHERE id = ${hash} AND verified = true LIMIT 1
118
118
-
`;
119
119
-
const data = result[0] || null;
120
120
-
121
121
-
// Cache the result
122
122
-
customDomainCache.set(key, { value: data, timestamp: Date.now() });
123
123
-
124
124
-
return data;
39
39
+
return cache.getOrFetch('customDomains', `hash:${hash}`, async () => {
40
40
+
const result = await sql<CustomDomainLookup[]>`
41
41
+
SELECT id, domain, did, rkey, verified FROM custom_domains
42
42
+
WHERE id = ${hash} AND verified = true LIMIT 1
43
43
+
`;
44
44
+
return result[0] || null;
45
45
+
});
125
46
}
126
47
127
48
export async function upsertSite(did: string, rkey: string, displayName?: string) {
···
253
174
254
175
// Site cache queries
255
176
256
256
-
const SETTINGS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
257
257
-
const settingsCache = new Map<string, CachedDomain<SiteSettingsCache | null>>();
258
258
-
259
177
export async function getSiteSettingsCache(did: string, rkey: string): Promise<SiteSettingsCache | null> {
260
260
-
const key = `${did}:${rkey}`;
261
261
-
262
262
-
const cached = settingsCache.get(key);
263
263
-
if (cached && Date.now() - cached.timestamp < SETTINGS_CACHE_TTL) {
264
264
-
return cached.value;
265
265
-
}
266
266
-
267
267
-
const result = await sql<SiteSettingsCache[]>`
268
268
-
SELECT did, rkey, record_cid, directory_listing, spa_mode, custom_404, index_files, clean_urls, headers, cached_at, updated_at
269
269
-
FROM site_settings_cache
270
270
-
WHERE did = ${did} AND rkey = ${rkey}
271
271
-
LIMIT 1
272
272
-
`;
273
273
-
const data = result[0] || null;
274
274
-
275
275
-
settingsCache.set(key, { value: data, timestamp: Date.now() });
276
276
-
return data;
178
178
+
return cache.getOrFetch('settings', `${did}:${rkey}`, async () => {
179
179
+
const result = await sql<SiteSettingsCache[]>`
180
180
+
SELECT did, rkey, record_cid, directory_listing, spa_mode, custom_404, index_files, clean_urls, headers, cached_at, updated_at
181
181
+
FROM site_settings_cache
182
182
+
WHERE did = ${did} AND rkey = ${rkey}
183
183
+
LIMIT 1
184
184
+
`;
185
185
+
return result[0] || null;
186
186
+
});
277
187
}
278
188
279
189
export async function getSiteCache(did: string, rkey: string): Promise<SiteCache | null> {
+7
-15
apps/hosting-service/src/lib/file-serving.ts
···
12
12
import { isHtmlContent } from './html-rewriter';
13
13
import { generate404Page, generateDirectoryListing } from './page-generators';
14
14
import { getIndexFiles, applyCustomHeaders } from './request-utils';
15
15
-
import { getRedirectRulesFromCache, setRedirectRulesInCache } from './site-cache';
15
15
+
import { cache } from './cache-manager';
16
16
import { storage } from './storage';
17
17
import { getSiteCache } from './db';
18
18
import { enqueueRevalidate } from './revalidate-queue';
···
187
187
const indexFiles = getIndexFiles(settings);
188
188
189
189
// Check for redirect rules first (_redirects wins over settings)
190
190
-
let redirectRules = getRedirectRulesFromCache(did, rkey);
191
191
-
192
192
-
if (redirectRules === null) {
193
193
-
// Load rules (not in cache or evicted)
194
194
-
redirectRules = await span(trace, 'storage:redirectRules', () => loadRedirectRules(did, rkey));
195
195
-
setRedirectRulesInCache(did, rkey, redirectRules);
196
196
-
}
190
190
+
const redirectRules = await cache.getOrFetch('redirectRules', `${did}:${rkey}`, () =>
191
191
+
span(trace, 'storage:redirectRules', () => loadRedirectRules(did, rkey))
192
192
+
);
197
193
198
194
// Apply redirect rules if any exist
199
195
if (redirectRules.length > 0) {
···
488
484
const indexFiles = getIndexFiles(settings);
489
485
490
486
// Check for redirect rules first (_redirects wins over settings)
491
491
-
let redirectRules = getRedirectRulesFromCache(did, rkey);
492
492
-
493
493
-
if (redirectRules === null) {
494
494
-
// Load rules (not in cache or evicted)
495
495
-
redirectRules = await span(trace, 'storage:redirectRules', () => loadRedirectRules(did, rkey));
496
496
-
setRedirectRulesInCache(did, rkey, redirectRules);
497
497
-
}
487
487
+
const redirectRules = await cache.getOrFetch('redirectRules', `${did}:${rkey}`, () =>
488
488
+
span(trace, 'storage:redirectRules', () => loadRedirectRules(did, rkey))
489
489
+
);
498
490
499
491
// Apply redirect rules if any exist
500
492
if (redirectRules.length > 0) {
-37
apps/hosting-service/src/lib/site-cache.ts
···
1
1
-
/**
2
2
-
* Redirect rules cache utilities
3
3
-
*/
4
4
-
5
5
-
import { LRUCache } from './cache';
6
6
-
import type { RedirectRule } from './redirects';
7
7
-
8
8
-
// Cache for redirect rules (per site) - LRU with 1000 site limit
9
9
-
// Each entry is relatively small (array of redirect rules), so 1000 sites should be < 10MB
10
10
-
const redirectRulesCache = new LRUCache<RedirectRule[]>(10 * 1024 * 1024, 1000); // 10MB, 1000 sites
11
11
-
12
12
-
/**
13
13
-
* Clear redirect rules cache for a specific site
14
14
-
* Should be called when a site is updated/recached
15
15
-
*/
16
16
-
export function clearRedirectRulesCache(did: string, rkey: string) {
17
17
-
const cacheKey = `${did}:${rkey}`;
18
18
-
redirectRulesCache.delete(cacheKey);
19
19
-
}
20
20
-
21
21
-
/**
22
22
-
* Get redirect rules from cache
23
23
-
*/
24
24
-
export function getRedirectRulesFromCache(did: string, rkey: string): RedirectRule[] | null {
25
25
-
const cacheKey = `${did}:${rkey}`;
26
26
-
return redirectRulesCache.get(cacheKey);
27
27
-
}
28
28
-
29
29
-
/**
30
30
-
* Set redirect rules in cache
31
31
-
*/
32
32
-
export function setRedirectRulesInCache(did: string, rkey: string, rules: RedirectRule[]) {
33
33
-
const cacheKey = `${did}:${rkey}`;
34
34
-
// Estimate size: roughly 100 bytes per rule
35
35
-
const estimatedSize = rules.length * 100;
36
36
-
redirectRulesCache.set(cacheKey, rules, estimatedSize);
37
37
-
}
+4
-11
apps/hosting-service/src/server.ts
···
13
13
import { isValidRkey, extractHeaders } from './lib/request-utils';
14
14
import { serveFromCache, serveFromCacheWithRewrite } from './lib/file-serving';
15
15
import { getRevalidateMetrics } from './lib/revalidate-metrics';
16
16
+
import { cache } from './lib/cache-manager';
16
17
17
18
const logger = createLogger('hosting-service');
18
19
19
19
-
// Cache handle → DID resolutions for 10 minutes to avoid hitting bsky API on every request
20
20
-
const HANDLE_CACHE_TTL = 10 * 60 * 1000;
21
21
-
const handleCache = new Map<string, { did: string; timestamp: number }>();
22
22
-
23
20
async function resolveDidCached(identifier: string): Promise<string | null> {
24
21
if (identifier.startsWith('did:')) return identifier;
25
25
-
const cached = handleCache.get(identifier);
26
26
-
if (cached && Date.now() - cached.timestamp < HANDLE_CACHE_TTL) {
27
27
-
return cached.did;
28
28
-
}
29
29
-
const did = await resolveDid(identifier);
30
30
-
if (did) handleCache.set(identifier, { did, timestamp: Date.now() });
31
31
-
return did;
22
22
+
return cache.getOrFetch('handles', identifier, () => resolveDid(identifier), {
23
23
+
cacheIf: (v) => v !== null,
24
24
+
});
32
25
}
33
26
34
27
const BASE_HOST_ENV = process.env.BASE_HOST || 'wisp.place';
+7
-4
packages/@wispplace/tiered-storage/src/tiers/DiskStorageTier.ts
···
180
180
const data = await readFile(filePath);
181
181
return new Uint8Array(data);
182
182
} catch (error) {
183
183
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
183
183
+
const code = (error as NodeJS.ErrnoException).code;
184
184
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
184
185
return null;
185
186
}
186
187
throw error;
···
218
219
219
220
return { data: new Uint8Array(dataBuffer), metadata };
220
221
} catch (error) {
221
221
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
222
222
+
const code = (error as NodeJS.ErrnoException).code;
223
223
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
222
224
return null;
223
225
}
224
226
throw error;
···
256
258
257
259
return { stream, metadata };
258
260
} catch (error) {
259
259
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
261
261
+
const code = (error as NodeJS.ErrnoException).code;
262
262
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
260
263
return null;
261
264
}
262
265
throw error;
···
435
438
return metadata;
436
439
} catch (error) {
437
440
const code = (error as NodeJS.ErrnoException).code;
438
438
-
if (code === 'ENOENT') {
441
441
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
439
442
return null;
440
443
}
441
444
if (error instanceof SyntaxError) {